Add a per-conversation Box fleet with OpenCode bootstrap

40415a01718d · Devin AI · · parent 1245da661734

Add a per-conversation Box fleet with OpenCode bootstrap

Give the chat agent four tools - box_new, box_list, box_exec, and
box_stop - that provision and drive Box VMs as agent computers. Each
conversation owns the boxes it creates, holds at most ten active boxes,
and serializes creates through an advisory lock so concurrent requests
cannot exceed the cap. Box creation carries an idempotency key, attaches
no account secrets, injects the OpenRouter key through the box
environment, and installs OpenCode pointed at the configured OpenRouter
model through the setup script. The typed Req client validates box ids
before building paths and maps auth, billing, rate-limit, provider, and
transport failures to safe errors.

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified config/config.exs
  • modified config/runtime.exs
  • added lib/openagents/box.ex
  • added lib/openagents/box/client.ex
  • added lib/openagents/box/conversation_box.ex
  • added lib/openagents/tools/box_exec.ex
  • added lib/openagents/tools/box_list.ex
  • added lib/openagents/tools/box_new.ex
  • added lib/openagents/tools/box_stop.ex
  • modified lib/openagents/tools/conversation_execution_context.ex
  • modified lib/openagents/tools/runner.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260823034851_create_conversation_boxes.exs
  • added test/openagents/box_test.exs
  • added test/openagents/tools/box_tools_test.exs

Diff

15 files changed, +1552 -3

config/config.exs modified +13 -1

@@ -102,6 +102,14 @@ config :openagents,

102 102
  openai_api_key: nil,
103 103
  openrouter_api_key: nil,
104 104
  openrouter_model: "stealth/ox-alpha",
105
  box_api_key: nil,
106
  box_api: [
107
    base_url: "https://ascii.dev/api/box/v1",
108
    maximum_active_boxes: 10,
109
    ttl_seconds: 3_600,
110
    poll_interval_ms: 1_000,
111
    poll_attempts: 60
112
  ],
105 113
  shadow_programs: [
106 114
    enabled: false,
107 115
    provider: OpenAgents.ShadowPrograms.OpenAI,

@@ -175,7 +183,11 @@ config :openagents,

175 183
    OpenAgents.Tools.RepoEdit,
176 184
    OpenAgents.Tools.RepoWrite,
177 185
    OpenAgents.Tools.RepoCommitPush,
178
    OpenAgents.Tools.ScvDeploy
186
    OpenAgents.Tools.ScvDeploy,
187
    OpenAgents.Tools.BoxNew,
188
    OpenAgents.Tools.BoxList,
189
    OpenAgents.Tools.BoxExec,
190
    OpenAgents.Tools.BoxStop
179 191
  ],
180 192
  conversation_reset_enabled: false,
181 193
  github_api: [
config/runtime.exs modified +2

@@ -95,6 +95,7 @@ config :openagents, :runtime_role, runtime_role

95 95
if config_env() == :dev do
96 96
  config :openagents, :openai_api_key, optional_text.("OPENAI_API_KEY")
97 97
  config :openagents, :openrouter_api_key, optional_text.("OPENROUTER_API_KEY")
98
  config :openagents, :box_api_key, optional_text.("BOX_API_KEY")
98 99
end
99 100
100 101
# The changelog seed. Idempotent and off the boot path. It was gated behind a

@@ -404,6 +405,7 @@ if config_env() == :prod and runtime_role == :web do

404 405
    github_oauth_scopes: github_oauth_scopes,
405 406
    openai_api_key: required_text.("OPENAI_API_KEY"),
406 407
    openrouter_api_key: optional_text.("OPENROUTER_API_KEY"),
408
    box_api_key: optional_text.("BOX_API_KEY"),
407 409
    inference_proxy_url: optional_text.("OPENAGENTS_INFERENCE_PROXY_URL"),
408 410
    forge_enabled: forge_enabled,
409 411
    forge_deploy_lane_enabled: forge_deploy_enabled,
lib/openagents/box.ex added +251

@@ -0,0 +1,251 @@

1
defmodule OpenAgents.Box do
2
  @moduledoc """
3
  Per-conversation pool of Box VMs used as agent computers.
4
5
  Each conversation owns the boxes it creates: every read and command is
6
  scoped by conversation id, so one conversation can never see or drive
7
  another conversation's boxes. The pool caps active boxes per conversation,
8
  provisions with idempotency keys so a lost response cannot leave a second
9
  billable box, and bootstraps every new box with the OpenCode harness wired
10
  to the application's OpenRouter credentials through the box environment —
11
  the key never appears in a command line or a command log.
12
  """
13
14
  import Ecto.Query
15
16
  alias OpenAgents.Box.Client
17
  alias OpenAgents.Box.ConversationBox
18
  alias OpenAgents.Repo
19
  alias OpenAgents.RuntimeConfig
20
21
  @default_maximum_active_boxes 10
22
  @default_ttl_seconds 3_600
23
  @default_poll_interval_ms 1_000
24
  @default_poll_attempts 30
25
  @runnable_states ~w(ready idle running)
26
27
  @doc "The most active boxes one conversation can hold at a time."
28
  @spec maximum_active_boxes() :: pos_integer()
29
  def maximum_active_boxes do
30
    settings()[:maximum_active_boxes] || @default_maximum_active_boxes
31
  end
32
33
  @doc "Lists a conversation's boxes, refreshing the state of the active ones."
34
  @spec list_boxes(String.t()) :: [ConversationBox.t()]
35
  def list_boxes(conversation_id) when is_binary(conversation_id) do
36
    conversation_id
37
    |> boxes_query()
38
    |> Repo.all()
39
    |> Enum.map(&refresh/1)
40
  end
41
42
  @doc """
43
  Provisions a new box for a conversation and bootstraps OpenCode on it.
44
45
  Refuses with `:box_quota_reached` past the per-conversation cap. The create
46
  request carries an idempotency key, attaches no account secrets to the box
47
  (`noEnv`), injects the OpenRouter key as a box environment variable when the
48
  application holds one, and installs OpenCode through the box setup script.
49
  """
50
  @spec create_box(String.t()) :: {:ok, ConversationBox.t()} | {:error, term()}
51
  def create_box(conversation_id) when is_binary(conversation_id) do
52
    transaction =
53
      Repo.transaction(
54
        fn ->
55
          lock_conversation(conversation_id)
56
57
          with :ok <- check_quota(conversation_id),
58
               {:ok, body} <- Client.create_box(create_attributes(), Ecto.UUID.generate()),
59
               {:ok, box_id} <- box_id(body) do
60
            %ConversationBox{}
61
            |> ConversationBox.changeset(%{
62
              conversation_id: conversation_id,
63
              box_id: box_id,
64
              state: box_state(body)
65
            })
66
            |> Repo.insert!()
67
          else
68
            {:error, reason} -> Repo.rollback(reason)
69
          end
70
        end,
71
        timeout: 60_000
72
      )
73
74
    with {:ok, record} <- transaction do
75
      {:ok, await_runnable(record)}
76
    end
77
  end
78
79
  @doc """
80
  Runs one shell command on a conversation-owned box.
81
82
  Returns the Box command result body. A box id the conversation does not own
83
  refuses with `:box_not_owned` before any request leaves the host.
84
  """
85
  @spec run_command(String.t(), String.t(), String.t(), pos_integer()) ::
86
          {:ok, map()} | {:error, term()}
87
  def run_command(conversation_id, box_id, command, timeout_seconds)
88
      when is_binary(conversation_id) and is_binary(box_id) and is_binary(command) and
89
             is_integer(timeout_seconds) do
90
    with {:ok, record} <- fetch_owned(conversation_id, box_id),
91
         :ok <- ensure_active(record) do
92
      Client.command(box_id, %{
93
        "command" => command,
94
        "timeoutSeconds" => timeout_seconds
95
      })
96
    end
97
  end
98
99
  @doc "Stops and archives a conversation-owned box, releasing its quota slot."
100
  @spec stop_box(String.t(), String.t()) :: {:ok, ConversationBox.t()} | {:error, term()}
101
  def stop_box(conversation_id, box_id)
102
      when is_binary(conversation_id) and is_binary(box_id) do
103
    with {:ok, record} <- fetch_owned(conversation_id, box_id),
104
         :ok <- ensure_active(record),
105
         {:ok, _body} <- Client.stop_box(box_id) do
106
      {:ok,
107
       record
108
       |> ConversationBox.changeset(%{state: "archiving", stopped_at: DateTime.utc_now()})
109
       |> Repo.update!()}
110
    end
111
  end
112
113
  # Serializes concurrent creates for one conversation so two simultaneous
114
  # box_new calls cannot both pass the quota check.
115
  defp lock_conversation(conversation_id) do
116
    Repo.query!("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
117
      "conversation_boxes:" <> conversation_id
118
    ])
119
  end
120
121
  defp boxes_query(conversation_id) do
122
    from box in ConversationBox,
123
      where: box.conversation_id == ^conversation_id,
124
      order_by: [asc: box.inserted_at]
125
  end
126
127
  defp check_quota(conversation_id) do
128
    active =
129
      Repo.one(
130
        from box in ConversationBox,
131
          where: box.conversation_id == ^conversation_id and is_nil(box.stopped_at),
132
          select: count(box.id)
133
      )
134
135
    if active < maximum_active_boxes(), do: :ok, else: {:error, :box_quota_reached}
136
  end
137
138
  defp ensure_active(%ConversationBox{stopped_at: nil}), do: :ok
139
  defp ensure_active(%ConversationBox{}), do: {:error, :box_stopped}
140
141
  defp fetch_owned(conversation_id, box_id) do
142
    case Repo.one(
143
           from box in ConversationBox,
144
             where: box.conversation_id == ^conversation_id and box.box_id == ^box_id
145
         ) do
146
      %ConversationBox{} = record -> {:ok, record}
147
      nil -> {:error, :box_not_owned}
148
    end
149
  end
150
151
  defp create_attributes do
152
    attributes = %{
153
      "ttlSeconds" => settings()[:ttl_seconds] || @default_ttl_seconds,
154
      "noEnv" => true,
155
      "setupScript" => setup_script()
156
    }
157
158
    case RuntimeConfig.fetch_secret(:openrouter_api_key) do
159
      {:ok, key} -> Map.put(attributes, "env", %{"OPENROUTER_API_KEY" => key})
160
      {:error, :not_configured} -> attributes
161
    end
162
  end
163
164
  # Installs the OpenCode harness and points its default model at the
165
  # application's configured OpenRouter model. OpenCode reads the
166
  # OPENROUTER_API_KEY environment variable natively, so the setup script
167
  # never touches the credential.
168
  defp setup_script do
169
    model = Application.get_env(:openagents, :openrouter_model, "stealth/ox-alpha")
170
171
    configuration =
172
      Jason.encode!(%{
173
        "$schema" => "https://opencode.ai/config.json",
174
        "model" => "openrouter/#{model}"
175
      })
176
177
    """
178
    #!/bin/bash
179
    set -euo pipefail
180
    curl -fsSL https://opencode.ai/install | bash
181
    mkdir -p "$HOME/.config/opencode"
182
    cat > "$HOME/.config/opencode/opencode.json" <<'OPENCODE_CONFIGURATION'
183
    #{configuration}
184
    OPENCODE_CONFIGURATION
185
    """
186
  end
187
188
  defp await_runnable(record) do
189
    attempts = settings()[:poll_attempts] || @default_poll_attempts
190
    interval = settings()[:poll_interval_ms] || @default_poll_interval_ms
191
    poll(record, attempts, interval)
192
  end
193
194
  defp poll(record, attempts_left, interval) do
195
    record = refresh(record)
196
197
    cond do
198
      record.state in @runnable_states and record.setup_status in ["done", "failed"] ->
199
        record
200
201
      record.state == "error" or attempts_left <= 0 ->
202
        record
203
204
      true ->
205
        Process.sleep(interval)
206
        poll(record, attempts_left - 1, interval)
207
    end
208
  end
209
210
  defp refresh(%ConversationBox{stopped_at: %DateTime{}} = record), do: record
211
212
  defp refresh(%ConversationBox{} = record) do
213
    case Client.get_box(record.box_id) do
214
      {:ok, body} ->
215
        record
216
        |> ConversationBox.changeset(%{
217
          state: box_state(body),
218
          setup_status: setup_status(body)
219
        })
220
        |> Repo.update!()
221
222
      {:error, _reason} ->
223
        record
224
    end
225
  end
226
227
  defp box_id(body) do
228
    case body do
229
      %{"box" => %{"id" => box_id}} when is_binary(box_id) -> {:ok, box_id}
230
      %{"id" => box_id} when is_binary(box_id) -> {:ok, box_id}
231
      _other -> {:error, :box_response_invalid}
232
    end
233
  end
234
235
  defp box_state(body) do
236
    state = unwrapped(body)["state"] || unwrapped(body)["status"]
237
    if is_binary(state) and state in ConversationBox.states(), do: state, else: "provisioning"
238
  end
239
240
  defp setup_status(body) do
241
    case unwrapped(body)["setupStatus"] do
242
      status when status in ["pending", "running", "done", "failed"] -> status
243
      _unknown -> "pending"
244
    end
245
  end
246
247
  defp unwrapped(%{"box" => %{} = box}), do: box
248
  defp unwrapped(%{} = body), do: body
249
250
  defp settings, do: Application.get_env(:openagents, :box_api, [])
251
end
lib/openagents/box/client.ex added +112

@@ -0,0 +1,112 @@

1
defmodule OpenAgents.Box.Client do
2
  @moduledoc """
3
  Typed `Req` client for the Box Public API v1 at `ascii.dev`.
4
5
  Every function returns `{:ok, body}` for a `2xx` response and a typed
6
  `{:error, reason}` otherwise. The bearer key comes from the `:box_api_key`
7
  application setting; an absent key fails closed with `:box_not_configured`
8
  before any request leaves the host. Desktop and viewer URLs never pass
9
  through this module: no function requests them, so a token-bearing URL
10
  cannot reach a caller or a log.
11
  """
12
13
  @default_base_url "https://ascii.dev/api/box/v1"
14
15
  @box_id_pattern ~r/^bx_[23456789abcdefghjkmnpqrstuvwxyz]{8}$/
16
17
  @type body :: map()
18
19
  @doc "Provisions a new box. The idempotency key makes a retried create safe."
20
  @spec create_box(map(), String.t()) :: {:ok, body()} | {:error, term()}
21
  def create_box(attributes, idempotency_key)
22
      when is_map(attributes) and is_binary(idempotency_key) do
23
    request(:post, "/boxes", json: attributes, headers: [{"idempotency-key", idempotency_key}])
24
  end
25
26
  @doc "Reads one box's current state and setup status."
27
  @spec get_box(String.t()) :: {:ok, body()} | {:error, term()}
28
  def get_box(box_id) when is_binary(box_id) do
29
    with :ok <- validate_box_id(box_id), do: request(:get, "/boxes/#{box_id}", [])
30
  end
31
32
  @doc "Stops and archives a box; a snapshot remains available for resume."
33
  @spec stop_box(String.t()) :: {:ok, body()} | {:error, term()}
34
  def stop_box(box_id) when is_binary(box_id) do
35
    with :ok <- validate_box_id(box_id), do: request(:post, "/boxes/#{box_id}/stop", [])
36
  end
37
38
  @doc "Runs one shell command on a box and returns its captured result."
39
  @spec command(String.t(), map()) :: {:ok, body()} | {:error, term()}
40
  def command(box_id, attributes) when is_binary(box_id) and is_map(attributes) do
41
    with :ok <- validate_box_id(box_id) do
42
      request(:post, "/boxes/#{box_id}/commands", json: attributes)
43
    end
44
  end
45
46
  @doc "Whether a string is a well-formed box id."
47
  @spec valid_box_id?(String.t()) :: boolean()
48
  def valid_box_id?(box_id) when is_binary(box_id), do: Regex.match?(@box_id_pattern, box_id)
49
  def valid_box_id?(_box_id), do: false
50
51
  defp validate_box_id(box_id) do
52
    if valid_box_id?(box_id), do: :ok, else: {:error, :box_not_found}
53
  end
54
55
  defp request(method, api_path, options) do
56
    with {:ok, api_key} <- api_key() do
57
      settings = Application.get_env(:openagents, :box_api, [])
58
      base_url = settings[:base_url] || @default_base_url
59
60
      request_options =
61
        options
62
        |> Keyword.put(:receive_timeout, settings[:receive_timeout_ms] || 630_000)
63
        |> Keyword.merge(settings[:request_options] || [])
64
        |> Keyword.put(:auth, {:bearer, api_key})
65
        |> Keyword.put_new(:retry, retry_policy(method))
66
        |> Keyword.put_new(:max_retries, 2)
67
        |> Keyword.put_new(:retry_log_level, false)
68
69
      case Req.request([method: method, url: base_url <> api_path] ++ request_options) do
70
        {:ok, %Req.Response{status: status, body: body}}
71
        when status in 200..299 and is_map(body) ->
72
          {:ok, body}
73
74
        {:ok, %Req.Response{status: status}} when status in 200..299 ->
75
          {:error, :box_response_invalid}
76
77
        {:ok, %Req.Response{status: 401}} ->
78
          {:error, :box_unauthorized}
79
80
        {:ok, %Req.Response{status: status}} when status in [402, 403] ->
81
          {:error, :box_billing_required}
82
83
        {:ok, %Req.Response{status: 404}} ->
84
          {:error, :box_not_found}
85
86
        {:ok, %Req.Response{status: 429}} ->
87
          {:error, :box_rate_limited}
88
89
        {:ok, %Req.Response{status: status, body: body}} ->
90
          {:error, {:box_request_refused, status, error_code(body)}}
91
92
        {:error, _transport} ->
93
          {:error, :box_unreachable}
94
      end
95
    end
96
  end
97
98
  # Reads retry transparently; a command is not replayed because a timed-out
99
  # command may still be running on the box.
100
  defp retry_policy(:get), do: :safe_transient
101
  defp retry_policy(_method), do: false
102
103
  defp api_key do
104
    case Application.fetch_env(:openagents, :box_api_key) do
105
      {:ok, key} when is_binary(key) and byte_size(key) > 0 -> {:ok, key}
106
      _missing -> {:error, :box_not_configured}
107
    end
108
  end
109
110
  defp error_code(%{"code" => code}) when is_binary(code), do: code
111
  defp error_code(_body), do: "unknown"
112
end
lib/openagents/box/conversation_box.ex added +42

@@ -0,0 +1,42 @@

1
defmodule OpenAgents.Box.ConversationBox do
2
  @moduledoc "One box a conversation provisioned, with its last observed lifecycle state."
3
  use Ecto.Schema
4
  import Ecto.Changeset
5
6
  @primary_key {:id, :binary_id, autogenerate: true}
7
  @foreign_key_type :binary_id
8
9
  @states ~w(init provisioning provisioned cloning ready idle running archiving archived error)
10
  @setup_statuses ~w(pending running done failed)
11
12
  schema "conversation_boxes" do
13
    belongs_to :conversation, OpenAgents.Conversations.Conversation
14
    field :box_id, :string
15
    field :state, :string, default: "provisioning"
16
    field :setup_status, :string, default: "pending"
17
    field :stopped_at, :utc_datetime_usec
18
    timestamps(type: :utc_datetime_usec)
19
  end
20
21
  def changeset(conversation_box, attrs) do
22
    conversation_box
23
    |> cast(attrs, [:state, :setup_status, :stopped_at])
24
    |> put_programmatic_change(attrs, :conversation_id)
25
    |> put_programmatic_change(attrs, :box_id)
26
    |> validate_required([:conversation_id, :box_id, :state, :setup_status])
27
    |> validate_inclusion(:state, @states)
28
    |> validate_inclusion(:setup_status, @setup_statuses)
29
    |> unique_constraint(:box_id)
30
    |> foreign_key_constraint(:conversation_id)
31
  end
32
33
  defp put_programmatic_change(changeset, attrs, field) do
34
    case Map.fetch(attrs, field) do
35
      {:ok, value} -> put_change(changeset, field, value)
36
      :error -> changeset
37
    end
38
  end
39
40
  @spec states() :: [String.t()]
41
  def states, do: @states
42
end
lib/openagents/tools/box_exec.ex added +186

@@ -0,0 +1,186 @@

1
defmodule OpenAgents.Tools.BoxExec do
2
  @moduledoc """
3
  Runs one shell command on a conversation-owned Box VM.
4
5
  The command runs remotely through the Box command endpoint and returns the
6
  standard exit status with bounded, redacted output. Ownership is checked
7
  before the request leaves the host, so a box id from another conversation
8
  refuses without a remote call.
9
  """
10
11
  @behaviour OpenAgents.Tools.Tool
12
13
  alias OpenAgents.Box
14
  alias OpenAgents.Modules.Metadata
15
  alias OpenAgents.Tools.{ExecutionResult, Redaction, Tool}
16
17
  @default_timeout_seconds 60
18
  # The registry caps a tool run at 600 seconds; the remote command budget
19
  # stays below it so the HTTP round trip fits inside the tool budget.
20
  @maximum_timeout_seconds 570
21
  @maximum_stream_bytes 24 * 1_024
22
23
  @impl true
24
  def specification do
25
    %Tool{
26
      module_id: "openagents.tool.box_exec.v1",
27
      name: "box_exec",
28
      version: 1,
29
      description:
30
        "Runs one shell command on one of this conversation's Box VMs and returns its exit " <>
31
          "code with bounded stdout and stderr. Use `opencode run \"<task>\"` to drive the " <>
32
          "installed OpenCode harness. Get box ids from box_list or box_new.",
33
      input_schema: %{
34
        "type" => "object",
35
        "properties" => %{
36
          "box_id" => %{"type" => "string", "maxLength" => 32},
37
          "command" => %{"type" => "string", "maxLength" => 4_000},
38
          "timeout_seconds" => %{
39
            "type" => "integer",
40
            "minimum" => 1,
41
            "maximum" => @maximum_timeout_seconds
42
          }
43
        },
44
        "required" => ["box_id", "command"],
45
        "additionalProperties" => false
46
      },
47
      output_schema: %{"type" => "object", "properties" => %{}, "additionalProperties" => true},
48
      side_effect: :reversible_write,
49
      required_scope: "browser_conversation",
50
      required_authority: "box.control",
51
      executor: %{id: "ascii.box", disclosure: "the Box VM service at ascii.dev"},
52
      maintainer: "OpenAgents",
53
      attribution: ["OpenAgentsInc/openagents.com"],
54
      policy_facets: %{"privacy" => "browser_conversation", "residency" => "external_provider"},
55
      module_metadata:
56
        Metadata.first_party("box.control", "browser_conversation",
57
          effect: :reversible_write,
58
          privacy: "browser_conversation",
59
          residency: "external_provider",
60
          surfaces: ["text", "voice"],
61
          approval_class: "exact_current_user_consent",
62
          approval_enforcement: "executor_consent"
63
        ),
64
      timeout_ms: (@maximum_timeout_seconds + 30) * 1_000,
65
      maximum_input_bytes: 8_192,
66
      maximum_output_bytes: 64 * 1_024,
67
      implementation: __MODULE__
68
    }
69
  end
70
71
  @impl true
72
  def execute(%{"box_id" => box_id, "command" => command} = arguments, context)
73
      when is_binary(box_id) and is_binary(command) do
74
    with :ok <- validate_command(command),
75
         {:ok, timeout_seconds} <- timeout_seconds(arguments),
76
         {:ok, body} <-
77
           Box.run_command(context.conversation_id, box_id, command, timeout_seconds) do
78
      build_result(box_id, body)
79
    end
80
  end
81
82
  def execute(_arguments, _context), do: {:error, :invalid_command}
83
84
  defp validate_command(command) do
85
    cond do
86
      String.trim(command) == "" -> {:error, :invalid_command}
87
      not String.valid?(command) -> {:error, :invalid_command}
88
      String.contains?(command, "\0") -> {:error, :invalid_command}
89
      true -> :ok
90
    end
91
  end
92
93
  defp timeout_seconds(arguments) do
94
    case Map.get(arguments, "timeout_seconds", @default_timeout_seconds) do
95
      seconds when is_integer(seconds) and seconds >= 1 and seconds <= @maximum_timeout_seconds ->
96
        {:ok, seconds}
97
98
      _invalid ->
99
        {:error, :invalid_command_timeout}
100
    end
101
  end
102
103
  defp build_result(box_id, body) do
104
    {stdout, stdout_truncated} = bounded(body["stdout"])
105
    {stderr, stderr_truncated} = bounded(body["stderr"])
106
    exit_code = body["exitCode"]
107
    timed_out = body["timedOut"] == true
108
109
    status =
110
      cond do
111
        timed_out -> "failed"
112
        exit_code == 0 -> "succeeded"
113
        true -> "failed"
114
      end
115
116
    {:ok,
117
     %ExecutionResult{
118
       result: %{
119
         "schema" => "openagents.box_exec_result.v1",
120
         "box_id" => box_id,
121
         "exit_code" => exit_code,
122
         "signal" => body["signal"],
123
         "timed_out" => timed_out,
124
         "stdout" => stdout,
125
         "stderr" => stderr,
126
         "stdout_truncated" => stdout_truncated or body["stdoutTruncated"] == true,
127
         "stderr_truncated" => stderr_truncated or body["stderrTruncated"] == true
128
       },
129
       status: status,
130
       error:
131
         if(status == "failed",
132
           do: %{
133
             "code" => if(timed_out, do: "command_timed_out", else: "command_failed"),
134
             "message" =>
135
               if(timed_out,
136
                 do: "The command did not finish within the requested timeout.",
137
                 else: "The command exited with a nonzero status."
138
               )
139
           }
140
         ),
141
       target_receipt_refs: ["box:#{box_id}"]
142
     }}
143
  end
144
145
  defp bounded(nil), do: {"", false}
146
147
  defp bounded(stream) when is_binary(stream) do
148
    redacted = stream |> scrub() |> Redaction.redact_text()
149
150
    if byte_size(redacted) <= @maximum_stream_bytes do
151
      {redacted, false}
152
    else
153
      {tail_bytes(redacted, @maximum_stream_bytes), true}
154
    end
155
  end
156
157
  defp bounded(_other), do: {"", false}
158
159
  defp scrub(output) do
160
    if String.valid?(output) do
161
      output
162
    else
163
      output
164
      |> String.chunk(:valid)
165
      |> Enum.map_join(fn chunk -> if String.valid?(chunk), do: chunk, else: "\uFFFD" end)
166
    end
167
  end
168
169
  defp tail_bytes(text, limit) do
170
    text
171
    |> binary_part(byte_size(text) - limit, limit)
172
    |> trim_partial_prefix(3)
173
  end
174
175
  defp trim_partial_prefix(text, 0), do: text
176
177
  defp trim_partial_prefix(text, attempts) do
178
    case text do
179
      <<_first, rest::binary>> ->
180
        if String.valid?(text), do: text, else: trim_partial_prefix(rest, attempts - 1)
181
182
      _empty ->
183
        text
184
    end
185
  end
186
end
lib/openagents/tools/box_list.ex added +100

@@ -0,0 +1,100 @@

1
defmodule OpenAgents.Tools.BoxList do
2
  @moduledoc "Lists the Box VMs this conversation has provisioned."
3
4
  @behaviour OpenAgents.Tools.Tool
5
6
  alias OpenAgents.Box
7
  alias OpenAgents.Modules.Metadata
8
  alias OpenAgents.Tools.{ExecutionResult, Tool}
9
10
  @impl true
11
  def specification do
12
    %Tool{
13
      module_id: "openagents.tool.box_list.v1",
14
      name: "box_list",
15
      version: 1,
16
      description:
17
        "Lists this conversation's Box VMs with each box's id, lifecycle state, OpenCode " <>
18
          "setup status, and creation time. Use it before box_exec or box_stop.",
19
      input_schema: %{
20
        "type" => "object",
21
        "properties" => %{},
22
        "additionalProperties" => false
23
      },
24
      output_schema: output_schema(),
25
      side_effect: :read_only,
26
      required_scope: "browser_conversation",
27
      required_authority: "box.control",
28
      executor: %{id: "ascii.box", disclosure: "the Box VM service at ascii.dev"},
29
      maintainer: "OpenAgents",
30
      attribution: ["OpenAgentsInc/openagents.com"],
31
      policy_facets: %{"privacy" => "browser_conversation", "residency" => "external_provider"},
32
      module_metadata:
33
        Metadata.first_party("box.control", "browser_conversation",
34
          effect: :read_only,
35
          privacy: "browser_conversation",
36
          residency: "external_provider",
37
          surfaces: ["text", "voice"]
38
        ),
39
      timeout_ms: 30_000,
40
      maximum_input_bytes: 256,
41
      maximum_output_bytes: 16_384,
42
      implementation: __MODULE__
43
    }
44
  end
45
46
  @impl true
47
  def execute(_arguments, context) do
48
    boxes = Enum.map(Box.list_boxes(context.conversation_id), &summary/1)
49
50
    {:ok,
51
     %ExecutionResult{
52
       result: %{
53
         "schema" => "openagents.box_list_result.v1",
54
         "status" => if(boxes == [], do: "empty", else: "matches"),
55
         "boxes" => boxes
56
       },
57
       target_receipt_refs: Enum.map(boxes, &"box:#{&1["box_id"]}")
58
     }}
59
  end
60
61
  defp summary(record) do
62
    base = %{
63
      "box_id" => record.box_id,
64
      "state" => record.state,
65
      "setup_status" => record.setup_status,
66
      "created_at" => DateTime.to_iso8601(record.inserted_at)
67
    }
68
69
    case record.stopped_at do
70
      %DateTime{} = stopped_at -> Map.put(base, "stopped_at", DateTime.to_iso8601(stopped_at))
71
      nil -> base
72
    end
73
  end
74
75
  defp output_schema do
76
    box_schema = %{
77
      "type" => "object",
78
      "properties" => %{
79
        "box_id" => %{"type" => "string", "maxLength" => 32},
80
        "state" => %{"type" => "string", "maxLength" => 16},
81
        "setup_status" => %{"type" => "string", "maxLength" => 16},
82
        "created_at" => %{"type" => "string", "maxLength" => 40},
83
        "stopped_at" => %{"type" => "string", "maxLength" => 40}
84
      },
85
      "required" => ["box_id", "state", "setup_status", "created_at"],
86
      "additionalProperties" => false
87
    }
88
89
    %{
90
      "type" => "object",
91
      "properties" => %{
92
        "schema" => %{"type" => "string", "maxLength" => 64},
93
        "status" => %{"type" => "string", "maxLength" => 16},
94
        "boxes" => %{"type" => "array", "maxItems" => 100, "items" => box_schema}
95
      },
96
      "required" => ["schema", "status", "boxes"],
97
      "additionalProperties" => false
98
    }
99
  end
100
end
lib/openagents/tools/box_new.ex added +85

@@ -0,0 +1,85 @@

1
defmodule OpenAgents.Tools.BoxNew do
2
  @moduledoc """
3
  Provisions a new Box VM for this conversation and bootstraps OpenCode on it.
4
5
  The pool caps active boxes per conversation, so a runaway loop cannot
6
  provision unbounded machines. The OpenRouter credential travels through the
7
  box environment, never through this tool's arguments or result.
8
  """
9
10
  @behaviour OpenAgents.Tools.Tool
11
12
  alias OpenAgents.Box
13
  alias OpenAgents.Modules.Metadata
14
  alias OpenAgents.Tools.{ExecutionResult, Tool}
15
16
  @impl true
17
  def specification do
18
    %Tool{
19
      module_id: "openagents.tool.box_new.v1",
20
      name: "box_new",
21
      version: 1,
22
      description:
23
        "Provisions a new Box VM for this conversation, waits for it to become runnable, " <>
24
          "and installs the OpenCode agent harness configured for OpenRouter. A conversation " <>
25
          "holds at most #{Box.maximum_active_boxes()} active boxes; stop one with box_stop " <>
26
          "to free a slot. Drive the box with box_exec.",
27
      input_schema: %{
28
        "type" => "object",
29
        "properties" => %{},
30
        "additionalProperties" => false
31
      },
32
      output_schema: %{"type" => "object", "properties" => %{}, "additionalProperties" => true},
33
      side_effect: :reversible_write,
34
      required_scope: "browser_conversation",
35
      required_authority: "box.control",
36
      executor: %{id: "ascii.box", disclosure: "the Box VM service at ascii.dev"},
37
      maintainer: "OpenAgents",
38
      attribution: ["OpenAgentsInc/openagents.com"],
39
      policy_facets: %{"privacy" => "browser_conversation", "residency" => "external_provider"},
40
      module_metadata:
41
        Metadata.first_party("box.control", "browser_conversation",
42
          effect: :reversible_write,
43
          privacy: "browser_conversation",
44
          residency: "external_provider",
45
          surfaces: ["text", "voice"],
46
          approval_class: "exact_current_user_consent",
47
          approval_enforcement: "executor_consent"
48
        ),
49
      timeout_ms: 90_000,
50
      maximum_input_bytes: 256,
51
      maximum_output_bytes: 4_096,
52
      implementation: __MODULE__
53
    }
54
  end
55
56
  @impl true
57
  def execute(_arguments, context) do
58
    case Box.create_box(context.conversation_id) do
59
      {:ok, record} ->
60
        {:ok,
61
         %ExecutionResult{
62
           result: %{
63
             "schema" => "openagents.box_new_result.v1",
64
             "box_id" => record.box_id,
65
             "state" => record.state,
66
             "setup_status" => record.setup_status
67
           },
68
           status: if(record.setup_status == "failed", do: "failed", else: "succeeded"),
69
           error:
70
             if(record.setup_status == "failed",
71
               do: %{
72
                 "code" => "box_setup_failed",
73
                 "message" =>
74
                   "The box is running but the OpenCode setup script failed. " <>
75
                     "Inspect it with box_exec or stop it with box_stop."
76
               }
77
             ),
78
           target_receipt_refs: ["box:#{record.box_id}"]
79
         }}
80
81
      {:error, reason} ->
82
        {:error, reason}
83
    end
84
  end
85
end
lib/openagents/tools/box_stop.ex added +77

@@ -0,0 +1,77 @@

1
defmodule OpenAgents.Tools.BoxStop do
2
  @moduledoc """
3
  Stops and archives a conversation-owned Box VM.
4
5
  Stopping snapshots the box and frees a slot in the conversation's box
6
  quota. The box remains resumable on the provider side.
7
  """
8
9
  @behaviour OpenAgents.Tools.Tool
10
11
  alias OpenAgents.Box
12
  alias OpenAgents.Modules.Metadata
13
  alias OpenAgents.Tools.{ExecutionResult, Tool}
14
15
  @impl true
16
  def specification do
17
    %Tool{
18
      module_id: "openagents.tool.box_stop.v1",
19
      name: "box_stop",
20
      version: 1,
21
      description:
22
        "Stops and archives one of this conversation's Box VMs, freeing a slot in the " <>
23
          "conversation's box quota. Files persist in a snapshot.",
24
      input_schema: %{
25
        "type" => "object",
26
        "properties" => %{
27
          "box_id" => %{"type" => "string", "maxLength" => 32}
28
        },
29
        "required" => ["box_id"],
30
        "additionalProperties" => false
31
      },
32
      output_schema: %{"type" => "object", "properties" => %{}, "additionalProperties" => true},
33
      side_effect: :reversible_write,
34
      required_scope: "browser_conversation",
35
      required_authority: "box.control",
36
      executor: %{id: "ascii.box", disclosure: "the Box VM service at ascii.dev"},
37
      maintainer: "OpenAgents",
38
      attribution: ["OpenAgentsInc/openagents.com"],
39
      policy_facets: %{"privacy" => "browser_conversation", "residency" => "external_provider"},
40
      module_metadata:
41
        Metadata.first_party("box.control", "browser_conversation",
42
          effect: :reversible_write,
43
          privacy: "browser_conversation",
44
          residency: "external_provider",
45
          surfaces: ["text", "voice"],
46
          approval_class: "exact_current_user_consent",
47
          approval_enforcement: "executor_consent"
48
        ),
49
      timeout_ms: 30_000,
50
      maximum_input_bytes: 512,
51
      maximum_output_bytes: 4_096,
52
      implementation: __MODULE__
53
    }
54
  end
55
56
  @impl true
57
  def execute(%{"box_id" => box_id}, context) when is_binary(box_id) do
58
    case Box.stop_box(context.conversation_id, box_id) do
59
      {:ok, record} ->
60
        {:ok,
61
         %ExecutionResult{
62
           result: %{
63
             "schema" => "openagents.box_stop_result.v1",
64
             "box_id" => record.box_id,
65
             "state" => record.state,
66
             "stopped_at" => DateTime.to_iso8601(record.stopped_at)
67
           },
68
           target_receipt_refs: ["box:#{record.box_id}"]
69
         }}
70
71
      {:error, reason} ->
72
        {:error, reason}
73
    end
74
  end
75
76
  def execute(_arguments, _context), do: {:error, :box_not_owned}
77
end
lib/openagents/tools/conversation_execution_context.ex modified +1

@@ -16,6 +16,7 @@ defmodule OpenAgents.Tools.ConversationExecutionContext do

16 16
  alias OpenAgents.Tools.ExecutionContext
17 17
18 18
  @authorities MapSet.new([
19
                 "box.control",
19 20
                 "command.execute",
20 21
                 "computer.control",
21 22
                 "conversation.read",
lib/openagents/tools/runner.ex modified +32 -1

@@ -309,7 +309,10 @@ defmodule OpenAgents.Tools.Runner do

309 309
              :publication_receipt_stale,
310 310
              :publication_branch_refused,
311 311
              :pull_requests_disabled,
312
              :forbidden
312
              :forbidden,
313
              :box_quota_reached,
314
              :box_not_owned,
315
              :box_stopped
313 316
            ],
314 317
       do: "refused"
315 318

@@ -509,6 +512,34 @@ defmodule OpenAgents.Tools.Runner do

509 512
  defp error_message({:workspace_clone_failed, _detail}),
510 513
    do: "Cloning the job workspace from the forge failed."
511 514
515
  defp error_message(:box_not_configured),
516
    do: "This deployment has no Box API credential configured."
517
518
  defp error_message(:box_unauthorized), do: "The Box API rejected this deployment's credential."
519
520
  defp error_message(:box_billing_required),
521
    do: "The Box account needs billing attention before new work can run."
522
523
  defp error_message(:box_rate_limited),
524
    do: "The Box API is rate limiting this deployment. Try again shortly."
525
526
  defp error_message(:box_unreachable), do: "The Box API could not be reached."
527
  defp error_message(:box_not_found), do: "That box no longer exists on the Box service."
528
529
  defp error_message(:box_quota_reached),
530
    do: "This conversation already has its maximum number of active boxes. Stop one first."
531
532
  defp error_message(:box_not_owned), do: "That box does not belong to this conversation."
533
  defp error_message(:box_stopped), do: "That box is stopped. Provision a new one with box_new."
534
535
  defp error_message(:box_not_ready),
536
    do: "The box did not become ready in time. Check box_list and try again."
537
538
  defp error_message({:box_request_refused, _status, _code}),
539
    do: "The Box API refused the request."
540
541
  defp error_message(:box_response_invalid), do: "The Box API returned an unexpected response."
542
512 543
  defp error_message(_reason), do: "The tool call failed validation or execution."
513 544
514 545
  defp validate_refs(refs) when is_list(refs) and length(refs) <= @maximum_reference_count do
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -234,7 +234,8 @@

234 234
    20260823000143,
235 235
    20260823010819,
236 236
    20260823013135,
237
    20260823021021
237
    20260823021021,
238
    20260823034851
238 239
  ],
239 240
  "required_tables": [
240 241
    "users",
priv/repo/migrations/20260823034851_create_conversation_boxes.exs added +21

@@ -0,0 +1,21 @@

1
defmodule OpenAgents.Repo.Migrations.CreateConversationBoxes do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:conversation_boxes, primary_key: false) do
6
      add :id, :binary_id, primary_key: true
7
8
      add :conversation_id, references(:conversations, type: :binary_id, on_delete: :delete_all),
9
        null: false
10
11
      add :box_id, :string, null: false
12
      add :state, :string, null: false, default: "provisioning"
13
      add :setup_status, :string, null: false, default: "pending"
14
      add :stopped_at, :utc_datetime_usec
15
      timestamps(type: :utc_datetime_usec)
16
    end
17
18
    create unique_index(:conversation_boxes, [:box_id])
19
    create index(:conversation_boxes, [:conversation_id])
20
  end
21
end
test/openagents/box_test.exs added +284

@@ -0,0 +1,284 @@

1
defmodule OpenAgents.BoxTest do
2
  use OpenAgents.DataCase
3
4
  alias OpenAgents.Box
5
  alias OpenAgents.Box.Client
6
  alias OpenAgents.Box.ConversationBox
7
  alias OpenAgents.Conversations
8
  alias OpenAgents.Repo
9
10
  @api_key "box_test_0000000000000000000000000000000000000000000000000000000000000"
11
  @box_id "bx_8bhkse3n"
12
13
  setup {Req.Test, :verify_on_exit!}
14
15
  setup do
16
    original_api = Application.get_env(:openagents, :box_api)
17
    original_key = Application.get_env(:openagents, :box_api_key)
18
19
    Application.put_env(:openagents, :box_api,
20
      base_url: "https://box-api.internal",
21
      poll_interval_ms: 0,
22
      poll_attempts: 3,
23
      request_options: [plug: {Req.Test, __MODULE__}, retry_delay: 0]
24
    )
25
26
    Application.put_env(:openagents, :box_api_key, @api_key)
27
28
    on_exit(fn ->
29
      restore_env(:box_api, original_api)
30
      restore_env(:box_api_key, original_key)
31
    end)
32
33
    {:ok, conversation} = Conversations.ensure_conversation("box-pool-test")
34
    %{conversation_id: conversation.id}
35
  end
36
37
  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
38
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
39
40
  defp box_body(overrides \\ %{}) do
41
    %{
42
      "box" =>
43
        Map.merge(
44
          %{"id" => @box_id, "state" => "ready", "setupStatus" => "done"},
45
          overrides
46
        )
47
    }
48
  end
49
50
  describe "create_box/1" do
51
    test "provisions, records ownership, and polls to runnable", %{conversation_id: cid} do
52
      Req.Test.expect(__MODULE__, fn conn ->
53
        assert conn.method == "POST"
54
        assert conn.request_path == "/boxes"
55
        assert ["Bearer " <> _key] = Plug.Conn.get_req_header(conn, "authorization")
56
        assert [idempotency_key] = Plug.Conn.get_req_header(conn, "idempotency-key")
57
        assert byte_size(idempotency_key) > 0
58
59
        {:ok, raw, conn} = Plug.Conn.read_body(conn)
60
        payload = Jason.decode!(raw)
61
        assert payload["noEnv"] == true
62
        assert payload["setupScript"] =~ "opencode.ai/install"
63
        assert payload["setupScript"] =~ "openrouter/stealth/ox-alpha"
64
65
        Req.Test.json(conn, box_body(%{"state" => "provisioning", "setupStatus" => "pending"}))
66
      end)
67
68
      Req.Test.expect(__MODULE__, fn conn ->
69
        assert conn.method == "GET"
70
        assert conn.request_path == "/boxes/#{@box_id}"
71
        Req.Test.json(conn, box_body())
72
      end)
73
74
      assert {:ok, record} = Box.create_box(cid)
75
      assert record.box_id == @box_id
76
      assert record.conversation_id == cid
77
      assert record.state == "ready"
78
      assert record.setup_status == "done"
79
      assert record.stopped_at == nil
80
    end
81
82
    test "injects the OpenRouter key through the box environment only", %{conversation_id: cid} do
83
      original = Application.get_env(:openagents, :openrouter_api_key)
84
      Application.put_env(:openagents, :openrouter_api_key, "sk-or-v1-test0000000000000000")
85
      on_exit(fn -> restore_env(:openrouter_api_key, original) end)
86
87
      Req.Test.expect(__MODULE__, fn conn ->
88
        {:ok, raw, conn} = Plug.Conn.read_body(conn)
89
        payload = Jason.decode!(raw)
90
        assert payload["env"] == %{"OPENROUTER_API_KEY" => "sk-or-v1-test0000000000000000"}
91
        refute payload["setupScript"] =~ "sk-or-v1"
92
        Req.Test.json(conn, box_body())
93
      end)
94
95
      Req.Test.stub(__MODULE__, fn conn -> Req.Test.json(conn, box_body()) end)
96
97
      assert {:ok, record} = Box.create_box(cid)
98
      assert record.setup_status == "done"
99
    end
100
101
    test "surfaces a failed setup honestly", %{conversation_id: cid} do
102
      Req.Test.expect(__MODULE__, fn conn ->
103
        Req.Test.json(conn, box_body(%{"setupStatus" => "pending"}))
104
      end)
105
106
      Req.Test.expect(__MODULE__, fn conn ->
107
        Req.Test.json(conn, box_body(%{"setupStatus" => "failed"}))
108
      end)
109
110
      assert {:ok, record} = Box.create_box(cid)
111
      assert record.setup_status == "failed"
112
    end
113
114
    test "refuses past the per-conversation cap without a remote call", %{conversation_id: cid} do
115
      for index <- 1..Box.maximum_active_boxes() do
116
        insert_box(cid, "bx_aaaaaaa#{Enum.at(~w(2 3 4 5 6 7 8 9 a b), index - 1)}")
117
      end
118
119
      assert {:error, :box_quota_reached} = Box.create_box(cid)
120
    end
121
122
    test "a stopped box frees its quota slot", %{conversation_id: cid} do
123
      for index <- 1..Box.maximum_active_boxes() do
124
        insert_box(cid, "bx_aaaaaaa#{Enum.at(~w(2 3 4 5 6 7 8 9 a b), index - 1)}")
125
      end
126
127
      Req.Test.expect(__MODULE__, fn conn ->
128
        assert conn.request_path == "/boxes/bx_aaaaaaa2/stop"
129
        Req.Test.json(conn, box_body(%{"id" => "bx_aaaaaaa2", "state" => "archiving"}))
130
      end)
131
132
      assert {:ok, stopped} = Box.stop_box(cid, "bx_aaaaaaa2")
133
      assert %DateTime{} = stopped.stopped_at
134
      assert stopped.state == "archiving"
135
136
      Req.Test.expect(__MODULE__, fn conn ->
137
        assert conn.request_path == "/boxes"
138
        Req.Test.json(conn, box_body())
139
      end)
140
141
      Req.Test.stub(__MODULE__, fn conn -> Req.Test.json(conn, box_body()) end)
142
143
      assert {:ok, _record} = Box.create_box(cid)
144
    end
145
146
    test "another conversation's boxes do not count against this quota", %{conversation_id: cid} do
147
      {:ok, other} = Conversations.ensure_conversation("box-pool-other")
148
149
      for index <- 1..Box.maximum_active_boxes() do
150
        insert_box(other.id, "bx_aaaaaaa#{Enum.at(~w(2 3 4 5 6 7 8 9 a b), index - 1)}")
151
      end
152
153
      Req.Test.expect(__MODULE__, fn conn ->
154
        Req.Test.json(conn, box_body())
155
      end)
156
157
      Req.Test.stub(__MODULE__, fn conn -> Req.Test.json(conn, box_body()) end)
158
159
      assert {:ok, _record} = Box.create_box(cid)
160
    end
161
162
    test "a provider refusal rolls back and stores nothing", %{conversation_id: cid} do
163
      Req.Test.expect(__MODULE__, fn conn ->
164
        conn
165
        |> Plug.Conn.put_status(429)
166
        |> Req.Test.json(%{"code" => "rate_limited"})
167
      end)
168
169
      assert {:error, :box_rate_limited} = Box.create_box(cid)
170
      assert Repo.aggregate(ConversationBox, :count) == 0
171
    end
172
  end
173
174
  describe "run_command/4" do
175
    test "runs a command on an owned box", %{conversation_id: cid} do
176
      insert_box(cid, @box_id)
177
178
      Req.Test.expect(__MODULE__, fn conn ->
179
        assert conn.method == "POST"
180
        assert conn.request_path == "/boxes/#{@box_id}/commands"
181
        {:ok, raw, conn} = Plug.Conn.read_body(conn)
182
        assert %{"command" => "echo hi", "timeoutSeconds" => 60} = Jason.decode!(raw)
183
184
        Req.Test.json(conn, %{
185
          "exitCode" => 0,
186
          "stdout" => "hi\n",
187
          "stderr" => "",
188
          "success" => true,
189
          "timedOut" => false
190
        })
191
      end)
192
193
      assert {:ok, body} = Box.run_command(cid, @box_id, "echo hi", 60)
194
      assert body["exitCode"] == 0
195
    end
196
197
    test "refuses a box owned by another conversation", %{conversation_id: cid} do
198
      {:ok, other} = Conversations.ensure_conversation("box-owner-other")
199
      insert_box(other.id, @box_id)
200
201
      assert {:error, :box_not_owned} = Box.run_command(cid, @box_id, "id", 60)
202
    end
203
204
    test "refuses a stopped box", %{conversation_id: cid} do
205
      insert_box(cid, @box_id, stopped_at: DateTime.utc_now())
206
207
      assert {:error, :box_stopped} = Box.run_command(cid, @box_id, "id", 60)
208
      assert {:error, :box_stopped} = Box.stop_box(cid, @box_id)
209
    end
210
  end
211
212
  describe "client" do
213
    test "fails closed without a configured credential", %{conversation_id: cid} do
214
      Application.delete_env(:openagents, :box_api_key)
215
216
      assert {:error, :box_not_configured} = Box.create_box(cid)
217
      assert {:error, :box_not_configured} = Client.get_box(@box_id)
218
    end
219
220
    test "maps provider statuses to typed errors" do
221
      for {status, expected} <- [
222
            {401, :box_unauthorized},
223
            {402, :box_billing_required},
224
            {404, :box_not_found},
225
            {429, :box_rate_limited}
226
          ] do
227
        Req.Test.expect(__MODULE__, fn conn ->
228
          conn |> Plug.Conn.put_status(status) |> Req.Test.json(%{})
229
        end)
230
231
        assert Client.stop_box(@box_id) == {:error, expected}
232
      end
233
234
      Req.Test.expect(__MODULE__, fn conn ->
235
        conn |> Plug.Conn.put_status(409) |> Req.Test.json(%{"code" => "provider_not_configured"})
236
      end)
237
238
      assert Client.stop_box(@box_id) ==
239
               {:error, {:box_request_refused, 409, "provider_not_configured"}}
240
    end
241
242
    test "a transport failure is unreachable, not a crash" do
243
      Req.Test.expect(__MODULE__, fn conn ->
244
        Req.Test.transport_error(conn, :econnrefused)
245
      end)
246
247
      assert Client.command(@box_id, %{"command" => "id"}) == {:error, :box_unreachable}
248
    end
249
250
    test "retries a transient read failure" do
251
      Req.Test.expect(__MODULE__, fn conn ->
252
        conn |> Plug.Conn.put_status(500) |> Req.Test.json(%{})
253
      end)
254
255
      Req.Test.expect(__MODULE__, fn conn ->
256
        Req.Test.json(conn, box_body())
257
      end)
258
259
      assert {:ok, %{"box" => %{"id" => @box_id}}} = Client.get_box(@box_id)
260
    end
261
262
    test "rejects a malformed box id before any request leaves the host" do
263
      for bad <- ["", "bx_", "bx_UPPERCASE", "../boxes", "bx_8bhkse3n/desktop", "bx_11111111"] do
264
        refute Client.valid_box_id?(bad)
265
        assert Client.get_box(bad) == {:error, :box_not_found}
266
      end
267
268
      assert Client.valid_box_id?(@box_id)
269
    end
270
  end
271
272
  defp insert_box(conversation_id, box_id, attributes \\ []) do
273
    %ConversationBox{}
274
    |> ConversationBox.changeset(
275
      Enum.into(attributes, %{
276
        conversation_id: conversation_id,
277
        box_id: box_id,
278
        state: "ready",
279
        setup_status: "done"
280
      })
281
    )
282
    |> Repo.insert!()
283
  end
284
end
test/openagents/tools/box_tools_test.exs added +344

@@ -0,0 +1,344 @@

1
defmodule OpenAgents.Tools.BoxToolsTest do
2
  use OpenAgents.DataCase
3
4
  alias OpenAgents.Box.ConversationBox
5
  alias OpenAgents.Conversations
6
  alias OpenAgents.Repo
7
  alias OpenAgents.Tools.{ConversationExecutionContext, Registry, Runner}
8
9
  @tools [
10
    OpenAgents.Tools.BoxNew,
11
    OpenAgents.Tools.BoxList,
12
    OpenAgents.Tools.BoxExec,
13
    OpenAgents.Tools.BoxStop
14
  ]
15
16
  @box_id "bx_8bhkse3n"
17
18
  setup {Req.Test, :verify_on_exit!}
19
20
  setup do
21
    original_api = Application.get_env(:openagents, :box_api)
22
    original_key = Application.get_env(:openagents, :box_api_key)
23
24
    Application.put_env(:openagents, :box_api,
25
      base_url: "https://box-api.internal",
26
      poll_interval_ms: 0,
27
      poll_attempts: 3,
28
      request_options: [plug: {Req.Test, __MODULE__}, retry_delay: 0]
29
    )
30
31
    Application.put_env(:openagents, :box_api_key, "box_test_credential_value")
32
33
    on_exit(fn ->
34
      restore_env(:box_api, original_api)
35
      restore_env(:box_api_key, original_key)
36
    end)
37
38
    assert {:ok, snapshot} = Registry.build(@tools)
39
40
    {:ok, conversation} = Conversations.ensure_conversation("box-tools-test")
41
    owner = Conversations.get_conversation_owner!(conversation)
42
43
    context =
44
      ConversationExecutionContext.build(%{
45
        surface: "text",
46
        conversation_id: conversation.id,
47
        owner_visitor_id: owner.id,
48
        owner_user_id: owner.user_id,
49
        module_registry_snapshot: snapshot
50
      })
51
52
    %{snapshot: snapshot, context: context, conversation_id: conversation.id}
53
  end
54
55
  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
56
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
57
58
  defp call(name, arguments) do
59
    %{
60
      call_id: "call-#{System.unique_integer([:positive])}",
61
      name: name,
62
      version: 1,
63
      raw_arguments: Jason.encode!(arguments)
64
    }
65
  end
66
67
  defp box_body(overrides \\ %{}) do
68
    %{
69
      "box" =>
70
        Map.merge(
71
          %{"id" => @box_id, "state" => "ready", "setupStatus" => "done"},
72
          overrides
73
        )
74
    }
75
  end
76
77
  defp insert_box(conversation_id, box_id, attributes \\ []) do
78
    %ConversationBox{}
79
    |> ConversationBox.changeset(
80
      Enum.into(attributes, %{
81
        conversation_id: conversation_id,
82
        box_id: box_id,
83
        state: "ready",
84
        setup_status: "done"
85
      })
86
    )
87
    |> Repo.insert!()
88
  end
89
90
  test "box_new provisions a box and returns safe metadata", %{
91
    snapshot: snapshot,
92
    context: context
93
  } do
94
    Req.Test.expect(__MODULE__, fn conn ->
95
      assert conn.method == "POST"
96
      assert conn.request_path == "/boxes"
97
      Req.Test.json(conn, box_body())
98
    end)
99
100
    Req.Test.stub(__MODULE__, fn conn -> Req.Test.json(conn, box_body()) end)
101
102
    assert {:ok, outcome} = Runner.run(snapshot, call("box_new", %{}), context)
103
    assert outcome["status"] == "succeeded"
104
    assert outcome["result"]["box_id"] == @box_id
105
    assert outcome["result"]["state"] == "ready"
106
    assert outcome["result"]["setup_status"] == "done"
107
    assert outcome["target_receipt_refs"] == ["box:#{@box_id}"]
108
    refute inspect(outcome) =~ "box_test_credential_value"
109
  end
110
111
  test "box_new reports a failed OpenCode setup as a failure", %{
112
    snapshot: snapshot,
113
    context: context
114
  } do
115
    Req.Test.expect(__MODULE__, fn conn ->
116
      Req.Test.json(conn, box_body(%{"setupStatus" => "pending"}))
117
    end)
118
119
    Req.Test.expect(__MODULE__, fn conn ->
120
      Req.Test.json(conn, box_body(%{"setupStatus" => "failed"}))
121
    end)
122
123
    assert {:ok, outcome} = Runner.run(snapshot, call("box_new", %{}), context)
124
    assert outcome["status"] == "failed"
125
    assert outcome["error"]["code"] == "box_setup_failed"
126
    assert outcome["result"]["box_id"] == @box_id
127
  end
128
129
  test "box_new refuses past the quota with a typed error", %{
130
    snapshot: snapshot,
131
    context: context,
132
    conversation_id: cid
133
  } do
134
    for index <- 1..OpenAgents.Box.maximum_active_boxes() do
135
      insert_box(cid, "bx_aaaaaaa#{Enum.at(~w(2 3 4 5 6 7 8 9 a b), index - 1)}")
136
    end
137
138
    assert {:ok, outcome} = Runner.run(snapshot, call("box_new", %{}), context)
139
    assert outcome["status"] == "refused"
140
    assert outcome["error"]["code"] == "box_quota_reached"
141
  end
142
143
  test "box_new fails closed without a Box credential", %{
144
    snapshot: snapshot,
145
    context: context
146
  } do
147
    Application.delete_env(:openagents, :box_api_key)
148
149
    assert {:ok, outcome} = Runner.run(snapshot, call("box_new", %{}), context)
150
    assert outcome["status"] == "failed"
151
    assert outcome["error"]["code"] == "box_not_configured"
152
    refute inspect(outcome) =~ "box_test_credential_value"
153
  end
154
155
  test "box_list returns only this conversation's boxes", %{
156
    snapshot: snapshot,
157
    context: context,
158
    conversation_id: cid
159
  } do
160
    {:ok, other} = Conversations.ensure_conversation("box-tools-other")
161
    insert_box(other.id, "bx_aaaaaaa2")
162
    insert_box(cid, @box_id, stopped_at: DateTime.utc_now(), state: "archived")
163
164
    assert {:ok, outcome} = Runner.run(snapshot, call("box_list", %{}), context)
165
    assert outcome["status"] == "succeeded"
166
    assert [box] = outcome["result"]["boxes"]
167
    assert box["box_id"] == @box_id
168
    assert box["state"] == "archived"
169
    assert is_binary(box["stopped_at"])
170
  end
171
172
  test "box_exec runs a command and reports the exit status", %{
173
    snapshot: snapshot,
174
    context: context,
175
    conversation_id: cid
176
  } do
177
    insert_box(cid, @box_id)
178
179
    Req.Test.expect(__MODULE__, fn conn ->
180
      assert conn.request_path == "/boxes/#{@box_id}/commands"
181
      {:ok, raw, conn} = Plug.Conn.read_body(conn)
182
      assert %{"command" => "opencode --version", "timeoutSeconds" => 60} = Jason.decode!(raw)
183
184
      Req.Test.json(conn, %{
185
        "exitCode" => 0,
186
        "stdout" => "0.4.0\n",
187
        "stderr" => "",
188
        "timedOut" => false
189
      })
190
    end)
191
192
    assert {:ok, outcome} =
193
             Runner.run(
194
               snapshot,
195
               call("box_exec", %{"command" => "opencode --version", "box_id" => @box_id}),
196
               context
197
             )
198
199
    assert outcome["status"] == "succeeded"
200
    assert outcome["result"]["exit_code"] == 0
201
    assert outcome["result"]["stdout"] == "0.4.0\n"
202
    assert outcome["target_receipt_refs"] == ["box:#{@box_id}"]
203
  end
204
205
  test "box_exec redacts credential-shaped output", %{
206
    snapshot: snapshot,
207
    context: context,
208
    conversation_id: cid
209
  } do
210
    insert_box(cid, @box_id)
211
212
    Req.Test.expect(__MODULE__, fn conn ->
213
      Req.Test.json(conn, %{
214
        "exitCode" => 0,
215
        "stdout" => "key is sk-or-v1-abcdefghijklmnop1234 done\n",
216
        "stderr" => "",
217
        "timedOut" => false
218
      })
219
    end)
220
221
    assert {:ok, outcome} =
222
             Runner.run(
223
               snapshot,
224
               call("box_exec", %{"command" => "env", "box_id" => @box_id}),
225
               context
226
             )
227
228
    refute outcome["result"]["stdout"] =~ "sk-or-v1"
229
    assert outcome["result"]["stdout"] =~ "[REDACTED]"
230
  end
231
232
  test "box_exec reports a timed-out command as failed", %{
233
    snapshot: snapshot,
234
    context: context,
235
    conversation_id: cid
236
  } do
237
    insert_box(cid, @box_id)
238
239
    Req.Test.expect(__MODULE__, fn conn ->
240
      Req.Test.json(conn, %{
241
        "exitCode" => nil,
242
        "stdout" => "",
243
        "stderr" => "",
244
        "timedOut" => true
245
      })
246
    end)
247
248
    assert {:ok, outcome} =
249
             Runner.run(
250
               snapshot,
251
               call("box_exec", %{
252
                 "command" => "sleep 999",
253
                 "box_id" => @box_id,
254
                 "timeout_seconds" => 1
255
               }),
256
               context
257
             )
258
259
    assert outcome["status"] == "failed"
260
    assert outcome["error"]["code"] == "command_timed_out"
261
    assert outcome["result"]["timed_out"] == true
262
  end
263
264
  test "box_exec refuses a box this conversation does not own", %{
265
    snapshot: snapshot,
266
    context: context
267
  } do
268
    {:ok, other} = Conversations.ensure_conversation("box-tools-foreign")
269
    insert_box(other.id, @box_id)
270
271
    assert {:ok, outcome} =
272
             Runner.run(
273
               snapshot,
274
               call("box_exec", %{"command" => "id", "box_id" => @box_id}),
275
               context
276
             )
277
278
    assert outcome["status"] == "refused"
279
    assert outcome["error"]["code"] == "box_not_owned"
280
  end
281
282
  test "box_exec rejects an out-of-range timeout", %{
283
    snapshot: snapshot,
284
    context: context,
285
    conversation_id: cid
286
  } do
287
    insert_box(cid, @box_id)
288
289
    assert {:ok, outcome} =
290
             Runner.run(
291
               snapshot,
292
               call("box_exec", %{
293
                 "command" => "id",
294
                 "box_id" => @box_id,
295
                 "timeout_seconds" => 601
296
               }),
297
               context
298
             )
299
300
    assert outcome["status"] in ["failed", "refused"]
301
    refute outcome["error"] == nil
302
  end
303
304
  test "box_stop archives the box and frees the slot", %{
305
    snapshot: snapshot,
306
    context: context,
307
    conversation_id: cid
308
  } do
309
    insert_box(cid, @box_id)
310
311
    Req.Test.expect(__MODULE__, fn conn ->
312
      assert conn.request_path == "/boxes/#{@box_id}/stop"
313
      Req.Test.json(conn, box_body(%{"state" => "archiving"}))
314
    end)
315
316
    assert {:ok, outcome} =
317
             Runner.run(snapshot, call("box_stop", %{"box_id" => @box_id}), context)
318
319
    assert outcome["status"] == "succeeded"
320
    assert outcome["result"]["state"] == "archiving"
321
    assert is_binary(outcome["result"]["stopped_at"])
322
323
    assert {:ok, refused} =
324
             Runner.run(snapshot, call("box_stop", %{"box_id" => @box_id}), context)
325
326
    assert refused["status"] == "refused"
327
    assert refused["error"]["code"] == "box_stopped"
328
  end
329
330
  test "box tools require the box.control authority", %{snapshot: snapshot, context: context} do
331
    stripped = %{context | authorities: MapSet.delete(context.authorities, "box.control")}
332
333
    for {name, arguments} <- [
334
          {"box_new", %{}},
335
          {"box_list", %{}},
336
          {"box_exec", %{"command" => "id", "box_id" => @box_id}},
337
          {"box_stop", %{"box_id" => @box_id}}
338
        ] do
339
      assert {:ok, outcome} = Runner.run(snapshot, call(name, arguments), stripped)
340
      assert outcome["status"] == "refused"
341
      assert outcome["error"]["code"] == "authority_refused"
342
    end
343
  end
344
end

This page updates live while a promote is in flight · changelog