Share Box output safety across API and tools

3d1b17905755 · Devin AI · · parent c21a6ad02057

Share Box output safety across API and tools

Expose authenticated, conversation-owned Box fleet controls through the API while keeping command output bounded and safe across the API and tool surfaces. Keep the box:control scope independent from forge, deployment, chat, and agent-participation authority so callers receive only the capability they requested. Supervise the rate-limit state and isolate foreign conversations before provider requests.\n\nIssue #106\n\nCo-Authored-By: Christopher David <chris@openagents.com>

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 INVARIANTS.md
  • modified config/config.exs
  • modified docs/api-authentication.md
  • modified docs/openagents-cli/api.md
  • modified lib/openagents/api_tokens.ex
  • modified lib/openagents/application.ex
  • modified lib/openagents/box.ex
  • modified lib/openagents/conversations.ex
  • modified lib/openagents/tools/box_exec.ex
  • added lib/openagents/tools/box_output.ex
  • modified lib/openagents_web/api_route_authority.ex
  • added lib/openagents_web/box_rate_limiter.ex
  • added lib/openagents_web/controllers/box_controller.ex
  • added lib/openagents_web/plugs/box_control_auth.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260823131739_allow_box_control_api_token_scope.exs
  • modified test/openagents/api_tokens_test.exs
  • modified test/openagents/tools/box_tools_test.exs
  • added test/openagents_web/controllers/box_controller_test.exs
  • modified test/openagents_web/route_authority_test.exs
  • modified test/support/conn_case.ex

Diff

23 files changed, +1016 -54

INVARIANTS.md modified +15

@@ -255,6 +255,20 @@ Evidence: `OpenAgents.Agents`, `OpenAgentsWeb.Plugs.DualPrincipalAuth`,

255 255
`test/openagents/agents_test.exs`, and
256 256
`test/openagents_web/controllers/agent_controller_test.exs`.
257 257
258
### IDENTITY-005 — Box control is account-scoped and human-only
259
260
Status: Current
261
262
The `box:control` API scope is independent from forge, deployment, chat, and
263
agent participation scopes. Only a human account token can use it in this
264
slice. Every request names a conversation owned by that account, and a foreign
265
conversation or Box is indistinguishable from a missing one. Agent Box control
266
through an approved human link is deferred to the linked-principal contract.
267
268
Evidence: `OpenAgentsWeb.Plugs.BoxControlAuth`, `OpenAgents.Box`,
269
`OpenAgentsWeb.BoxController`, and
270
`test/openagents_web/controllers/box_controller_test.exs`.
271
258 272
## Data authority and synchronization
259 273
260 274
### DATA-001 — PostgreSQL is authoritative

@@ -2285,6 +2299,7 @@ contract; the invariant prose above defines the assertion, not the filename.

2285 2299
| IDENTITY-002 | `test/openagents_web/auth_gate_test.exs` |
2286 2300
| IDENTITY-003 | `test/openagents/memory_portability_test.exs` |
2287 2301
| IDENTITY-004 | `test/openagents/agents_test.exs`, `test/openagents_web/controllers/agent_controller_test.exs` |
2302
| IDENTITY-005 | `test/openagents_web/controllers/box_controller_test.exs` |
2288 2303
| PROMISE-001 | `test/openagents/promise_registry_test.exs`, `test/openagents_web/controllers/project_controller_test.exs` |
2289 2304
| PROMISE-002 | `test/openagents/promise_registry_test.exs` |
2290 2305
| DATA-001 | `test/openagents/conversations_test.exs` |
config/config.exs modified +4 -1

@@ -108,7 +108,10 @@ config :openagents,

108 108
    maximum_active_boxes: 10,
109 109
    ttl_seconds: 3_600,
110 110
    poll_interval_ms: 1_000,
111
    poll_attempts: 60
111
    poll_attempts: 60,
112
    rate_limit_window_seconds: 60,
113
    create_rate_limit: 10,
114
    command_rate_limit: 30
112 115
  ],
113 116
  # The provider does not report a context window, so the `/chat` console shows
114 117
  # a context meter only where a deployment states one.
docs/api-authentication.md modified +26 -1

@@ -11,7 +11,8 @@ Agent participation writes use an `oa_agent_…` credential with exact

11 11
`agent:participate` scope.
12 12
13 13
Create a token in the authenticated browser at `/settings/api-tokens`. Choose a
14
name and a lifetime from 1 through 90 days. The `oa_pat_…` plaintext appears
14
name, one or more independent scopes, and a lifetime from 1 through 90 days.
15
The `oa_pat_…` plaintext appears
15 16
once; OpenAgents stores only its SHA-256 digest. Send it as a bearer:
16 17
17 18
```sh

@@ -32,6 +33,30 @@ Account export includes the same metadata with `credential_exported: false`.

32 33
Product-data deletion retains API credentials until the person revokes them;
33 34
credential management is independent from conversation deletion.
34 35
36
### Box control credentials
37
38
The `box:control` scope gives a human account token access to the Box API. It
39
does not grant forge, deployment, chat, or agent-participation authority, and
40
those scopes do not grant Box authority.
41
42
Use the conversation ID owned by the token's account:
43
44
```sh
45
openagents api -X GET conversations/CONVERSATION_ID/boxes
46
openagents api -X POST conversations/CONVERSATION_ID/boxes
47
openagents api -X GET conversations/CONVERSATION_ID/boxes/BOX_ID
48
openagents api -X POST --input command.json \
49
  conversations/CONVERSATION_ID/boxes/BOX_ID/commands
50
openagents api -X POST conversations/CONVERSATION_ID/boxes/BOX_ID/stop
51
```
52
53
The API returns only Box IDs, lifecycle and setup state, timestamps, and
54
bounded, redacted command output. It never returns provider, desktop, viewer,
55
or token-bearing URLs. A foreign conversation or Box returns `404` without a
56
provider request. Agent participation credentials receive
57
`{"error":{"code":"agent_box_control_forbidden"}}`; linked-agent Box control is
58
deferred to the linked-principal contract.
59
35 60
### Agent participation credentials
36 61
37 62
An agent can register without GitHub by sending its handle and display name to
docs/openagents-cli/api.md modified +22

@@ -140,6 +140,28 @@ participating without a link.

140 140
An unlinked link record uses the `unlinked` status, while an explicit human
141 141
decline uses `rejected`; a later request reuses either record as `pending`.
142 142
143
## Work with Box computers
144
145
Use a human account token with the `box:control` scope to manage the Box
146
computers owned by one conversation:
147
148
```sh
149
openagents api conversations/CONVERSATION_ID/boxes
150
openagents api -X POST conversations/CONVERSATION_ID/boxes
151
openagents api conversations/CONVERSATION_ID/boxes/BOX_ID
152
openagents api -X POST --input command.json \
153
  conversations/CONVERSATION_ID/boxes/BOX_ID/commands
154
openagents api -X POST conversations/CONVERSATION_ID/boxes/BOX_ID/stop
155
```
156
157
The Box API returns bounded, redacted command output and selected lifecycle
158
metadata. It does not return provider, desktop, viewer, or token-bearing URLs.
159
The conversation must belong to the token's account. A foreign conversation or
160
Box returns `404` without contacting the provider. Agent participation
161
credentials are refused with
162
`{"error":{"code":"agent_box_control_forbidden"}}` until linked-agent Box
163
control is implemented.
164
143 165
## Work with issues
144 166
145 167
List open issues. The API returns an object with an `issues` array:
lib/openagents/api_tokens.ex modified +1 -1

@@ -14,7 +14,7 @@ defmodule OpenAgents.ApiTokens do

14 14
  # `deployments:promote` fleet scope: holding it lets a caller address the
15 15
  # deployment API, while repository membership and environment policy still
16 16
  # decide what it may deploy.
17
  @allowed_scopes ["chat:account", "forge:write", "deployments:write"]
17
  @allowed_scopes ["chat:account", "forge:write", "deployments:write", "box:control"]
18 18
  @maximum_lifetime_days 90
19 19
20 20
  @spec create(User.t(), map()) ::
lib/openagents/application.ex modified +2 -1

@@ -53,7 +53,8 @@ defmodule OpenAgents.Application do

53 53
        OpenAgents.Forge.BootConverge,
54 54
        {DNSCluster, query: Application.get_env(:openagents, :dns_cluster_query) || :ignore},
55 55
        {Phoenix.PubSub, name: OpenAgents.PubSub},
56
        OpenAgents.RuntimeSupervisor
56
        OpenAgents.RuntimeSupervisor,
57
        OpenAgentsWeb.BoxRateLimiter
57 58
      ] ++ analytics_children() ++ [OpenAgentsWeb.Endpoint]
58 59
59 60
    # See https://elixir.hexdocs.pm/Supervisor.html
lib/openagents/box.ex modified +9

@@ -39,6 +39,15 @@ defmodule OpenAgents.Box do

39 39
    |> Enum.map(&refresh/1)
40 40
  end
41 41
42
  @doc "Reads one conversation-owned box, refreshing its provider state."
43
  @spec get_box(String.t(), String.t()) :: {:ok, ConversationBox.t()} | {:error, term()}
44
  def get_box(conversation_id, box_id)
45
      when is_binary(conversation_id) and is_binary(box_id) do
46
    with {:ok, record} <- fetch_owned(conversation_id, box_id) do
47
      {:ok, refresh(record)}
48
    end
49
  end
50
42 51
  @doc """
43 52
  Provisions a new box for a conversation and bootstraps OpenCode on it.
44 53
lib/openagents/conversations.ex modified +15

@@ -82,6 +82,21 @@ defmodule OpenAgents.Conversations do

82 82
    |> Repo.one()
83 83
  end
84 84
85
  @doc "Gets a conversation owned by the account, returning nil for other accounts or invalid IDs."
86
  @spec get_conversation_for_user(User.t(), String.t()) :: Conversation.t() | nil
87
  def get_conversation_for_user(%User{id: user_id}, conversation_id)
88
      when is_binary(conversation_id) do
89
    with {:ok, conversation_id} <- Ecto.UUID.cast(conversation_id) do
90
      from(c in Conversation,
91
        join: v in assoc(c, :visitor),
92
        where: c.id == ^conversation_id and v.user_id == ^user_id
93
      )
94
      |> Repo.one()
95
    else
96
      :error -> nil
97
    end
98
  end
99
85 100
  @doc false
86 101
  def get_conversation_for_browser(browser_key) when is_binary(browser_key) do
87 102
    browser_key_hash = :crypto.hash(:sha256, browser_key)
lib/openagents/tools/box_exec.ex modified +3 -46

@@ -12,13 +12,12 @@ defmodule OpenAgents.Tools.BoxExec do

12 12
13 13
  alias OpenAgents.Box
14 14
  alias OpenAgents.Modules.Metadata
15
  alias OpenAgents.Tools.{ExecutionResult, Redaction, Tool}
15
  alias OpenAgents.Tools.{BoxOutput, ExecutionResult, Tool}
16 16
17 17
  @default_timeout_seconds 60
18 18
  # The registry caps a tool run at 600 seconds; the remote command budget
19 19
  # stays below it so the HTTP round trip fits inside the tool budget.
20 20
  @maximum_timeout_seconds 570
21
  @maximum_stream_bytes 24 * 1_024
22 21
23 22
  @impl true
24 23
  def specification do

@@ -101,8 +100,8 @@ defmodule OpenAgents.Tools.BoxExec do

101 100
  end
102 101
103 102
  defp build_result(box_id, body) do
104
    {stdout, stdout_truncated} = bounded(body["stdout"])
105
    {stderr, stderr_truncated} = bounded(body["stderr"])
103
    {stdout, stdout_truncated} = BoxOutput.bounded(body["stdout"])
104
    {stderr, stderr_truncated} = BoxOutput.bounded(body["stderr"])
106 105
    exit_code = body["exitCode"]
107 106
    timed_out = body["timedOut"] == true
108 107

@@ -141,46 +140,4 @@ defmodule OpenAgents.Tools.BoxExec do

141 140
       target_receipt_refs: ["box:#{box_id}"]
142 141
     }}
143 142
  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 143
end
lib/openagents/tools/box_output.ex added +67

@@ -0,0 +1,67 @@

1
defmodule OpenAgents.Tools.BoxOutput do
2
  @moduledoc "Bounds and redacts output returned by Box commands."
3
4
  alias OpenAgents.Tools.Redaction
5
6
  @maximum_stream_bytes 24 * 1_024
7
  @credential_url_pattern ~r{https?://[^\s/@:]+:[^\s/@]+@[^\s]+}i
8
  @token_query_url_pattern ~r{https?://[^\s]+[?&](?:access[_-]?token|token|api[_-]?key|auth(?:entication)?[_-]?token|secret|credential)=[^\s&#]+[^\s]*}i
9
  @provider_url_pattern ~r{https?://(?:desktop|viewer)\.ascii\.dev[^\s]*}i
10
11
  @spec bounded(term()) :: {String.t(), boolean()}
12
  def bounded(nil), do: {"", false}
13
14
  def bounded(stream) when is_binary(stream) do
15
    redacted =
16
      stream
17
      |> scrub()
18
      |> Redaction.redact_text()
19
      |> redact_credential_urls()
20
21
    if byte_size(redacted) <= @maximum_stream_bytes do
22
      {redacted, false}
23
    else
24
      {tail_bytes(redacted, @maximum_stream_bytes), true}
25
    end
26
  end
27
28
  def bounded(_other), do: {"", false}
29
30
  defp redact_credential_urls(text) do
31
    text =
32
      Regex.replace(@credential_url_pattern, text, "[REDACTED_URL]")
33
34
    text =
35
      Regex.replace(@token_query_url_pattern, text, "[REDACTED_URL]")
36
37
    Regex.replace(@provider_url_pattern, text, "[REDACTED_URL]")
38
  end
39
40
  defp scrub(output) do
41
    if String.valid?(output) do
42
      output
43
    else
44
      output
45
      |> String.chunk(:valid)
46
      |> Enum.map_join(fn chunk -> if String.valid?(chunk), do: chunk, else: "\uFFFD" end)
47
    end
48
  end
49
50
  defp tail_bytes(text, limit) do
51
    text
52
    |> binary_part(byte_size(text) - limit, limit)
53
    |> trim_partial_prefix(3)
54
  end
55
56
  defp trim_partial_prefix(text, 0), do: text
57
58
  defp trim_partial_prefix(text, attempts) do
59
    case text do
60
      <<_first, rest::binary>> ->
61
        if String.valid?(text), do: text, else: trim_partial_prefix(rest, attempts - 1)
62
63
      _empty ->
64
        text
65
    end
66
  end
67
end
lib/openagents_web/api_route_authority.ex modified +5

@@ -88,6 +88,11 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

88 88
      "post /api/v3/chat/turns" => :required_bearer,
89 89
      "get /api/v3/capacity" => :required_bearer,
90 90
      "post /api/v3/capacity/matches" => :required_bearer,
91
      "get /api/v3/conversations/:conversation_id/boxes" => :required_bearer,
92
      "post /api/v3/conversations/:conversation_id/boxes" => :required_bearer,
93
      "get /api/v3/conversations/:conversation_id/boxes/:box_id" => :required_bearer,
94
      "post /api/v3/conversations/:conversation_id/boxes/:box_id/commands" => :required_bearer,
95
      "post /api/v3/conversations/:conversation_id/boxes/:box_id/stop" => :required_bearer,
91 96
      "delete /api/v3/repos/:owner/:repo" => :required_bearer,
92 97
      "delete /api/v3/repos/:owner/:repo/issues/:issue_number/assignees" => :required_bearer,
93 98
      "delete /api/v3/repos/:owner/:repo/issues/:issue_number/labels/:name" => :required_bearer,
lib/openagents_web/box_rate_limiter.ex added +89

@@ -0,0 +1,89 @@

1
defmodule OpenAgentsWeb.BoxRateLimiter do
2
  @moduledoc false
3
4
  use GenServer
5
6
  @table :openagents_box_api_rate_limits
7
  @default_window_seconds 60
8
  @default_create_limit 10
9
  @default_command_limit 30
10
  @minimum_sweep_interval_ms 1_000
11
  @maximum_sweep_interval_ms 60_000
12
13
  @spec start_link(keyword()) :: GenServer.on_start()
14
  def start_link(options \\ []) do
15
    GenServer.start_link(__MODULE__, options, name: __MODULE__)
16
  end
17
18
  @spec allow?(term(), :create | :command) :: :ok | {:error, :rate_limited}
19
  def allow?(principal, operation) when operation in [:create, :command] do
20
    GenServer.call(__MODULE__, {:allow, principal, operation})
21
  end
22
23
  @impl true
24
  def init(_options) do
25
    table = :ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
26
    schedule_sweep()
27
    {:ok, %{table: table}}
28
  end
29
30
  @impl true
31
  def handle_call({:allow, principal, operation}, _from, %{table: table} = state) do
32
    window_seconds = setting(:rate_limit_window_seconds, @default_window_seconds)
33
    limit = setting(rate_limit_key(operation), default_limit(operation))
34
    bucket = div(System.system_time(:second), window_seconds)
35
    key = {operation, principal, bucket}
36
37
    :ets.insert_new(table, {key, 0})
38
    count = :ets.update_counter(table, key, {2, 1})
39
40
    reply =
41
      if count <= limit do
42
        :ok
43
      else
44
        :ets.update_counter(table, key, {2, -1})
45
        {:error, :rate_limited}
46
      end
47
48
    {:reply, reply, state}
49
  end
50
51
  @impl true
52
  def handle_info(:sweep, %{table: table} = state) do
53
    current_bucket = div(System.system_time(:second), setting(:rate_limit_window_seconds, 60))
54
55
    table
56
    |> :ets.tab2list()
57
    |> Enum.each(fn {{_operation, _principal, bucket} = key, _count} ->
58
      if bucket < current_bucket, do: :ets.delete(table, key)
59
    end)
60
61
    schedule_sweep()
62
    {:noreply, state}
63
  end
64
65
  defp schedule_sweep do
66
    Process.send_after(self(), :sweep, sweep_interval_ms())
67
  end
68
69
  defp sweep_interval_ms do
70
    :rate_limit_window_seconds
71
    |> setting(@default_window_seconds)
72
    |> then(&:timer.seconds/1)
73
    |> max(@minimum_sweep_interval_ms)
74
    |> min(@maximum_sweep_interval_ms)
75
  end
76
77
  defp rate_limit_key(:create), do: :create_rate_limit
78
  defp rate_limit_key(:command), do: :command_rate_limit
79
80
  defp default_limit(:create), do: @default_create_limit
81
  defp default_limit(:command), do: @default_command_limit
82
83
  defp setting(key, default) do
84
    case Keyword.get(Application.get_env(:openagents, :box_api, []), key, default) do
85
      value when is_integer(value) and value > 0 -> value
86
      _ -> default
87
    end
88
  end
89
end
lib/openagents_web/controllers/box_controller.ex added +182

@@ -0,0 +1,182 @@

1
defmodule OpenAgentsWeb.BoxController do
2
  @moduledoc "Authenticated API access to conversation-owned Box computers."
3
4
  use OpenAgentsWeb, :controller
5
6
  alias OpenAgents.{Box, Conversations}
7
  alias OpenAgents.Conversations.Conversation
8
  alias OpenAgents.Tools.BoxOutput
9
  alias OpenAgentsWeb.BoxRateLimiter
10
11
  @default_timeout_seconds 60
12
  @maximum_timeout_seconds 570
13
14
  def index(conn, %{"conversation_id" => conversation_id}) do
15
    with {:ok, _conversation} <- owned_conversation(conn, conversation_id) do
16
      boxes = Box.list_boxes(conversation_id)
17
      json(conn, %{"boxes" => Enum.map(boxes, &box_projection/1)})
18
    else
19
      {:error, :not_found} -> refusal(conn, :not_found, "conversation_not_found")
20
    end
21
  end
22
23
  def create(conn, %{"conversation_id" => conversation_id}) do
24
    with {:ok, _conversation} <- owned_conversation(conn, conversation_id),
25
         :ok <- BoxRateLimiter.allow?(conn.assigns.current_user.id, :create),
26
         {:ok, record} <- Box.create_box(conversation_id) do
27
      conn
28
      |> put_status(:created)
29
      |> json(%{"box" => box_projection(record)})
30
    else
31
      {:error, :not_found} -> refusal(conn, :not_found, "conversation_not_found")
32
      {:error, :rate_limited} -> refusal(conn, :too_many_requests, "box_api_rate_limited")
33
      {:error, reason} -> box_error(conn, reason)
34
    end
35
  end
36
37
  def show(conn, %{"conversation_id" => conversation_id, "box_id" => box_id}) do
38
    with {:ok, _conversation} <- owned_conversation(conn, conversation_id),
39
         {:ok, record} <- Box.get_box(conversation_id, box_id) do
40
      json(conn, %{"box" => box_projection(record)})
41
    else
42
      {:error, :not_found} -> refusal(conn, :not_found, "conversation_not_found")
43
      {:error, reason} -> box_error(conn, reason)
44
    end
45
  end
46
47
  def command(
48
        conn,
49
        %{"conversation_id" => conversation_id, "box_id" => box_id, "command" => command} = params
50
      )
51
      when is_binary(command) do
52
    with :ok <- validate_command(command),
53
         {:ok, timeout_seconds} <- timeout_seconds(params),
54
         {:ok, _conversation} <- owned_conversation(conn, conversation_id),
55
         :ok <- BoxRateLimiter.allow?(conn.assigns.current_user.id, :command),
56
         {:ok, body} <- Box.run_command(conversation_id, box_id, command, timeout_seconds) do
57
      json(conn, %{"result" => command_projection(box_id, body)})
58
    else
59
      {:error, :invalid_command} ->
60
        refusal(conn, :unprocessable_entity, "invalid_command")
61
62
      {:error, :invalid_command_timeout} ->
63
        refusal(conn, :unprocessable_entity, "invalid_command_timeout")
64
65
      {:error, :not_found} ->
66
        refusal(conn, :not_found, "conversation_not_found")
67
68
      {:error, :rate_limited} ->
69
        refusal(conn, :too_many_requests, "box_api_rate_limited")
70
71
      {:error, reason} ->
72
        box_error(conn, reason)
73
    end
74
  end
75
76
  def command(conn, _params), do: refusal(conn, :unprocessable_entity, "invalid_command")
77
78
  def stop(conn, %{"conversation_id" => conversation_id, "box_id" => box_id}) do
79
    with {:ok, _conversation} <- owned_conversation(conn, conversation_id),
80
         {:ok, record} <- Box.stop_box(conversation_id, box_id) do
81
      json(conn, %{"box" => box_projection(record)})
82
    else
83
      {:error, :not_found} -> refusal(conn, :not_found, "conversation_not_found")
84
      {:error, reason} -> box_error(conn, reason)
85
    end
86
  end
87
88
  defp owned_conversation(conn, conversation_id) do
89
    case Conversations.get_conversation_for_user(conn.assigns.current_user, conversation_id) do
90
      %Conversation{} = conversation -> {:ok, conversation}
91
      nil -> {:error, :not_found}
92
    end
93
  end
94
95
  defp box_projection(record) do
96
    %{
97
      "box_id" => record.box_id,
98
      "state" => record.state,
99
      "setup_status" => record.setup_status,
100
      "created_at" => iso8601(record.inserted_at),
101
      "updated_at" => iso8601(record.updated_at),
102
      "stopped_at" => iso8601(record.stopped_at)
103
    }
104
  end
105
106
  defp command_projection(box_id, body) do
107
    {stdout, stdout_truncated} = BoxOutput.bounded(body["stdout"])
108
    {stderr, stderr_truncated} = BoxOutput.bounded(body["stderr"])
109
110
    %{
111
      "box_id" => box_id,
112
      "exit_code" => body["exitCode"],
113
      "stdout" => stdout,
114
      "stderr" => stderr,
115
      "timed_out" => body["timedOut"] == true,
116
      "stdout_truncated" => stdout_truncated or body["stdoutTruncated"] == true,
117
      "stderr_truncated" => stderr_truncated or body["stderrTruncated"] == true
118
    }
119
  end
120
121
  defp validate_command(command) do
122
    cond do
123
      String.trim(command) == "" -> {:error, :invalid_command}
124
      not String.valid?(command) -> {:error, :invalid_command}
125
      String.contains?(command, "\0") -> {:error, :invalid_command}
126
      byte_size(command) > 4_000 -> {:error, :invalid_command}
127
      true -> :ok
128
    end
129
  end
130
131
  defp timeout_seconds(params) do
132
    case Map.get(params, "timeout_seconds", @default_timeout_seconds) do
133
      seconds
134
      when is_integer(seconds) and seconds >= 1 and seconds <= @maximum_timeout_seconds ->
135
        {:ok, seconds}
136
137
      _invalid ->
138
        {:error, :invalid_command_timeout}
139
    end
140
  end
141
142
  defp iso8601(nil), do: nil
143
  defp iso8601(%DateTime{} = value), do: DateTime.to_iso8601(value)
144
145
  defp box_error(conn, :box_quota_reached),
146
    do: refusal(conn, :conflict, "box_quota_reached")
147
148
  defp box_error(conn, reason) when reason in [:box_not_owned, :box_not_found],
149
    do: refusal(conn, :not_found, "box_not_found")
150
151
  defp box_error(conn, :box_billing_required),
152
    do: refusal(conn, :payment_required, "box_billing_required")
153
154
  defp box_error(conn, :box_rate_limited),
155
    do: refusal(conn, :too_many_requests, "box_provider_rate_limited")
156
157
  defp box_error(conn, reason) when reason in [:box_not_configured, :box_unreachable],
158
    do: refusal(conn, :service_unavailable, Atom.to_string(reason))
159
160
  defp box_error(conn, :box_response_invalid),
161
    do: refusal(conn, :bad_gateway, "box_provider_response_invalid")
162
163
  defp box_error(conn, {:box_request_refused, _status, _code}),
164
    do: refusal(conn, :bad_gateway, "box_provider_request_refused")
165
166
  defp box_error(conn, :box_unauthorized),
167
    do: refusal(conn, :bad_gateway, "box_provider_unauthorized")
168
169
  defp box_error(conn, :box_stopped),
170
    do: refusal(conn, :conflict, "box_stopped")
171
172
  defp box_error(conn, :box_not_ready),
173
    do: refusal(conn, :conflict, "box_not_ready")
174
175
  defp box_error(conn, _reason), do: refusal(conn, :bad_gateway, "box_request_failed")
176
177
  defp refusal(conn, status, code) do
178
    conn
179
    |> put_status(status)
180
    |> json(%{"error" => %{"code" => code}})
181
  end
182
end
lib/openagents_web/plugs/box_control_auth.ex added +51

@@ -0,0 +1,51 @@

1
defmodule OpenAgentsWeb.Plugs.BoxControlAuth do
2
  @moduledoc "Authenticates a human bearer credential with Box control authority."
3
4
  import Plug.Conn
5
6
  alias OpenAgents.Agents
7
  alias OpenAgents.ApiTokens
8
9
  def init(options), do: Keyword.fetch!(options, :scope)
10
11
  def call(conn, required_scope) do
12
    with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
13
         true <- token != "" do
14
      authenticate(conn, token, required_scope)
15
    else
16
      _ -> refuse(conn, :unauthorized, "invalid_api_token")
17
    end
18
  end
19
20
  defp authenticate(conn, "oa_agent_" <> _rest = token, _required_scope) do
21
    case Agents.authenticate(token, "agent:participate") do
22
      {:ok, _agent, _credential} ->
23
        refuse(conn, :forbidden, "agent_box_control_forbidden")
24
25
      _ ->
26
        refuse(conn, :unauthorized, "invalid_api_token")
27
    end
28
  end
29
30
  defp authenticate(conn, token, required_scope) do
31
    case ApiTokens.authenticate(token, required_scope) do
32
      {:ok, user, credential} ->
33
        conn
34
        |> put_resp_header("cache-control", "no-store")
35
        |> assign(:current_user, user)
36
        |> assign(:api_token, credential)
37
        |> assign(:api_scope, required_scope)
38
39
      _ ->
40
        refuse(conn, :unauthorized, "invalid_api_token")
41
    end
42
  end
43
44
  defp refuse(conn, status, code) do
45
    conn
46
    |> put_status(status)
47
    |> put_resp_header("cache-control", "no-store")
48
    |> Phoenix.Controller.json(%{"error" => %{"code" => code}})
49
    |> halt()
50
  end
51
end
lib/openagents_web/route_authority.ex modified +22

@@ -239,6 +239,28 @@ defmodule OpenAgentsWeb.RouteAuthority do

239 239
        true
240 240
      )
241 241
242
  defp policy(%{path: "/api/v3/conversations/:conversation_id/boxes", verb: verb})
243
       when verb in [:get, :post],
244
       do:
245
         declaration(
246
           :authenticated_api,
247
           "human account bearer token",
248
           "box:control",
249
           verb == :post
250
         )
251
252
  defp policy(%{path: "/api/v3/conversations/:conversation_id/boxes/:box_id", verb: :get}),
253
    do: declaration(:authenticated_api, "human account bearer token", "box:control", false)
254
255
  defp policy(%{
256
         path: "/api/v3/conversations/:conversation_id/boxes/:box_id/commands",
257
         verb: :post
258
       }),
259
       do: declaration(:authenticated_api, "human account bearer token", "box:control", true)
260
261
  defp policy(%{path: "/api/v3/conversations/:conversation_id/boxes/:box_id/stop", verb: :post}),
262
    do: declaration(:authenticated_api, "human account bearer token", "box:control", true)
263
242 264
  defp policy(%{path: "/api/operator/artifact-listings" <> _path, verb: verb}),
243 265
    do:
244 266
      declaration(
lib/openagents_web/router.ex modified +16

@@ -60,6 +60,12 @@ defmodule OpenAgentsWeb.Router do

60 60
    plug OpenAgentsWeb.Plugs.ApiTokenAuth, scope: "chat:account"
61 61
  end
62 62
63
  pipeline :box_control_api do
64
    plug :accepts, ["json"]
65
    plug OpenAgentsWeb.Plugs.RequestOrigin
66
    plug OpenAgentsWeb.Plugs.BoxControlAuth, scope: "box:control"
67
  end
68
63 69
  # The deployment control plane authenticates two principals: a human holding
64 70
  # `deployments:write`, and a short-lived workflow grant. Neither carries the
65 71
  # operator-only fleet promotion authority.

@@ -407,6 +413,16 @@ defmodule OpenAgentsWeb.Router do

407 413
    post "/capacity/matches", CapacityController, :matches
408 414
  end
409 415
416
  scope "/api/v3/conversations/:conversation_id/boxes", OpenAgentsWeb do
417
    pipe_through :box_control_api
418
419
    get "/", BoxController, :index
420
    post "/", BoxController, :create
421
    get "/:box_id", BoxController, :show
422
    post "/:box_id/commands", BoxController, :command
423
    post "/:box_id/stop", BoxController, :stop
424
  end
425
410 426
  scope "/api/v3", OpenAgentsWeb do
411 427
    pipe_through :forge_write_api
412 428
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -254,7 +254,8 @@

254 254
    20260823120247,
255 255
    20260823122038,
256 256
    20260823122039,
257
    20260823122040
257
    20260823122040,
258
    20260823131739
258 259
  ],
259 260
  "required_tables": [
260 261
    "users",
priv/repo/migrations/20260823131739_allow_box_control_api_token_scope.exs added +12

@@ -0,0 +1,12 @@

1
defmodule OpenAgents.Repo.Migrations.AllowBoxControlApiTokenScope do
2
  use Ecto.Migration
3
4
  def change do
5
    drop constraint(:api_tokens, :api_tokens_scopes_allowed)
6
7
    create constraint(:api_tokens, :api_tokens_scopes_allowed,
8
             check:
9
               "scopes <@ ARRAY['chat:account', 'forge:write', 'deployments:write', 'box:control']::varchar[]"
10
           )
11
  end
12
end
test/openagents/api_tokens_test.exs modified +18

@@ -61,6 +61,24 @@ defmodule OpenAgents.ApiTokensTest do

61 61
             })
62 62
  end
63 63
64
  test "box control is an independent allowed scope" do
65
    {:ok, user} = Accounts.upsert_github_user(profile(803, "api-box-owner"))
66
67
    assert {:ok, token, plaintext} =
68
             ApiTokens.create(user, %{
69
               name: "box automation",
70
               scopes: ["box:control"],
71
               lifetime_days: 1
72
             })
73
74
    assert token.scopes == ["box:control"]
75
    assert {:ok, authenticated, _used} = ApiTokens.authenticate(plaintext, "box:control")
76
    assert authenticated.id == user.id
77
    assert {:error, :invalid_api_token} = ApiTokens.authenticate(plaintext, "forge:write")
78
    assert {:error, :invalid_api_token} = ApiTokens.authenticate(plaintext, "deployments:write")
79
    assert {:error, :invalid_api_token} = ApiTokens.authenticate(plaintext, "chat:account")
80
  end
81
64 82
  defp profile(id, login) do
65 83
    %{
66 84
      github_id: id,
test/openagents/tools/box_tools_test.exs modified +13 -3

@@ -183,7 +183,7 @@ defmodule OpenAgents.Tools.BoxToolsTest do

183 183
184 184
      Req.Test.json(conn, %{
185 185
        "exitCode" => 0,
186
        "stdout" => "0.4.0\n",
186
        "stdout" => "clone https://openagents.com/OpenAgentsInc/openagents.com\n",
187 187
        "stderr" => "",
188 188
        "timedOut" => false
189 189
      })

@@ -198,7 +198,10 @@ defmodule OpenAgents.Tools.BoxToolsTest do

198 198
199 199
    assert outcome["status"] == "succeeded"
200 200
    assert outcome["result"]["exit_code"] == 0
201
    assert outcome["result"]["stdout"] == "0.4.0\n"
201
202
    assert outcome["result"]["stdout"] ==
203
             "clone https://openagents.com/OpenAgentsInc/openagents.com\n"
204
202 205
    assert outcome["target_receipt_refs"] == ["box:#{@box_id}"]
203 206
  end
204 207

@@ -212,7 +215,10 @@ defmodule OpenAgents.Tools.BoxToolsTest do

212 215
    Req.Test.expect(__MODULE__, fn conn ->
213 216
      Req.Test.json(conn, %{
214 217
        "exitCode" => 0,
215
        "stdout" => "key is sk-or-v1-abcdefghijklmnop1234 done\n",
218
        "stdout" =>
219
          "key is sk-or-v1-abcdefghijklmnop1234 done\n" <>
220
            "clone https://openagents.com/OpenAgentsInc/openagents.com\n" <>
221
            "https://viewer.ascii.dev/desktop?access_token=secret\n",
216 222
        "stderr" => "",
217 223
        "timedOut" => false
218 224
      })

@@ -226,7 +232,11 @@ defmodule OpenAgents.Tools.BoxToolsTest do

226 232
             )
227 233
228 234
    refute outcome["result"]["stdout"] =~ "sk-or-v1"
235
    refute outcome["result"]["stdout"] =~ "viewer.ascii.dev"
229 236
    assert outcome["result"]["stdout"] =~ "[REDACTED]"
237
238
    assert outcome["result"]["stdout"] =~
239
             "clone https://openagents.com/OpenAgentsInc/openagents.com"
230 240
  end
231 241
232 242
  test "box_exec reports a timed-out command as failed", %{
test/openagents_web/controllers/box_controller_test.exs added +417

@@ -0,0 +1,417 @@

1
defmodule OpenAgentsWeb.BoxControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: false
3
4
  alias OpenAgents.{Conversations, Repo}
5
  alias OpenAgents.Box.ConversationBox
6
7
  setup {Req.Test, :verify_on_exit!}
8
9
  setup do
10
    original_api = Application.get_env(:openagents, :box_api)
11
    original_key = Application.get_env(:openagents, :box_api_key)
12
13
    Application.put_env(
14
      :openagents,
15
      :box_api,
16
      Keyword.merge(original_api || [],
17
        base_url: "https://box-api.internal",
18
        poll_interval_ms: 0,
19
        poll_attempts: 3,
20
        request_options: [plug: {Req.Test, __MODULE__}, retry_delay: 0]
21
      )
22
    )
23
24
    Application.put_env(:openagents, :box_api_key, "box_api_test_credential")
25
    Req.Test.stub(__MODULE__, fn request -> Req.Test.json(request, box_body()) end)
26
27
    on_exit(fn ->
28
      restore_env(:box_api, original_api)
29
      restore_env(:box_api_key, original_key)
30
    end)
31
32
    :ok
33
  end
34
35
  test "human box control token completes the create, command, list, show, and stop cycle", %{
36
    conn: conn
37
  } do
38
    user = github_user("api-token-box-api-cycle")
39
    {:ok, conversation} = Conversations.ensure_conversation(user)
40
41
    Req.Test.expect(__MODULE__, fn request ->
42
      assert request.method == "POST"
43
      assert request.request_path == "/boxes"
44
      Req.Test.json(request, box_body())
45
    end)
46
47
    create_response =
48
      conn
49
      |> put_box_api_token("box-api-cycle")
50
      |> post(box_path(conversation.id), %{})
51
      |> json_response(201)
52
53
    box_id = create_response["box"]["box_id"]
54
    assert create_response["box"]["state"] == "ready"
55
    refute Map.has_key?(create_response["box"], "desktopUrl")
56
57
    Req.Test.expect(__MODULE__, fn request ->
58
      assert request.method == "POST"
59
      assert request.request_path == "/boxes/#{box_id}/commands"
60
      Req.Test.json(request, command_body())
61
    end)
62
63
    command_response =
64
      conn
65
      |> put_box_api_token("box-api-cycle")
66
      |> post("#{box_path(conversation.id)}/#{box_id}/commands", %{"command" => "echo hi"})
67
      |> json_response(200)
68
69
    assert command_response["result"]["exit_code"] == 0
70
    assert command_response["result"]["stdout"] == "hi\n"
71
72
    Req.Test.stub(__MODULE__, fn request -> Req.Test.json(request, box_body()) end)
73
74
    assert %{"boxes" => [%{"box_id" => ^box_id}]} =
75
             conn
76
             |> put_box_api_token("box-api-cycle")
77
             |> get(box_path(conversation.id))
78
             |> json_response(200)
79
80
    assert %{"box" => %{"box_id" => ^box_id}} =
81
             conn
82
             |> put_box_api_token("box-api-cycle")
83
             |> get("#{box_path(conversation.id)}/#{box_id}")
84
             |> json_response(200)
85
86
    Req.Test.expect(__MODULE__, fn request ->
87
      assert request.method == "POST"
88
      assert request.request_path == "/boxes/#{box_id}/stop"
89
      Req.Test.json(request, box_body(%{"state" => "archiving"}))
90
    end)
91
92
    assert %{"box" => %{"box_id" => ^box_id, "state" => "archiving"}} =
93
             conn
94
             |> put_box_api_token("box-api-cycle")
95
             |> post("#{box_path(conversation.id)}/#{box_id}/stop", %{})
96
             |> json_response(200)
97
  end
98
99
  test "a token without box control is refused on every route", %{conn: conn} do
100
    user = github_user("api-token-box-api-auth")
101
    {:ok, conversation} = Conversations.ensure_conversation(user)
102
    box_id = "bx_8bhkse3n"
103
104
    paths = [
105
      {:get, box_path(conversation.id), %{}},
106
      {:post, box_path(conversation.id), %{}},
107
      {:get, "#{box_path(conversation.id)}/#{box_id}", %{}},
108
      {:post, "#{box_path(conversation.id)}/#{box_id}/commands", %{"command" => "id"}},
109
      {:post, "#{box_path(conversation.id)}/#{box_id}/stop", %{}}
110
    ]
111
112
    for {method, path, params} <- paths do
113
      response =
114
        conn
115
        |> put_forge_api_token("box-api-auth")
116
        |> request(method, path, params)
117
118
      assert response.status == 401
119
120
      assert json_response(response, 401) == %{
121
               "error" => %{"code" => "invalid_api_token"}
122
             }
123
    end
124
  end
125
126
  test "an agent credential receives a typed refusal on every route", %{conn: conn} do
127
    {:ok, _agent, credential} =
128
      OpenAgents.Agents.register(%{
129
        "handle" => "box-agent-auth",
130
        "display_name" => "Box agent",
131
        "registration_ip" => "198.51.100.21"
132
      })
133
134
    {:ok, conversation} = Conversations.ensure_conversation(github_user("box-agent-owner"))
135
    authorization = "Bearer " <> credential
136
137
    for {method, path, params} <- [
138
          {:get, box_path(conversation.id), %{}},
139
          {:post, box_path(conversation.id), %{}},
140
          {:get, "#{box_path(conversation.id)}/bx_8bhkse3n", %{}},
141
          {:post, "#{box_path(conversation.id)}/bx_8bhkse3n/commands", %{"command" => "id"}},
142
          {:post, "#{box_path(conversation.id)}/bx_8bhkse3n/stop", %{}}
143
        ] do
144
      response =
145
        conn
146
        |> put_req_header("authorization", authorization)
147
        |> request(method, path, params)
148
149
      assert response.status == 403
150
151
      assert json_response(response, 403) == %{
152
               "error" => %{"code" => "agent_box_control_forbidden"}
153
             }
154
    end
155
  end
156
157
  test "a foreign box returns 404 without an outbound provider request", %{conn: conn} do
158
    owner = github_user("api-token-box-api-owner")
159
    {:ok, conversation} = Conversations.ensure_conversation(owner)
160
161
    {:ok, foreign_conversation} =
162
      Conversations.ensure_conversation(github_user("box-api-foreign"))
163
164
    insert_box(foreign_conversation.id, "bx_8bhkse3n")
165
166
    Req.Test.stub(__MODULE__, fn request ->
167
      send(self(), :unexpected_box_provider_request)
168
      Req.Test.json(request, box_body())
169
    end)
170
171
    assert conn
172
           |> put_box_api_token("box-api-owner")
173
           |> post("#{box_path(conversation.id)}/bx_8bhkse3n/commands", %{"command" => "id"})
174
           |> json_response(404) == %{"error" => %{"code" => "box_not_found"}}
175
176
    refute_receive :unexpected_box_provider_request
177
  end
178
179
  test "provider failures use typed status and error mappings", %{conn: conn} do
180
    user_key = "box-api-provider-errors"
181
    user = github_user("api-token-" <> user_key)
182
    {:ok, conversation} = Conversations.ensure_conversation(user)
183
184
    for {status, error_code, response_status} <- [
185
          {402, "box_billing_required", 402},
186
          {429, "box_provider_rate_limited", 429},
187
          {409, "box_provider_request_refused", 502}
188
        ] do
189
      Req.Test.expect(__MODULE__, fn request ->
190
        request |> Plug.Conn.put_status(status) |> Req.Test.json(%{"code" => "provider detail"})
191
      end)
192
193
      response =
194
        conn
195
        |> put_box_api_token(user_key)
196
        |> post(box_path(conversation.id), %{})
197
198
      assert json_response(response, response_status) == %{"error" => %{"code" => error_code}}
199
      refute response.resp_body =~ "provider detail"
200
    end
201
202
    Req.Test.expect(__MODULE__, fn request -> Req.Test.transport_error(request, :econnrefused) end)
203
204
    assert conn
205
           |> put_box_api_token(user_key)
206
           |> post(box_path(conversation.id), %{})
207
           |> json_response(503) == %{"error" => %{"code" => "box_unreachable"}}
208
209
    Req.Test.expect(__MODULE__, fn request -> Req.Test.json(request, []) end)
210
211
    assert conn
212
           |> put_box_api_token(user_key)
213
           |> post(box_path(conversation.id), %{})
214
           |> json_response(502) == %{
215
             "error" => %{"code" => "box_provider_response_invalid"}
216
           }
217
218
    Application.delete_env(:openagents, :box_api_key)
219
220
    assert conn
221
           |> put_box_api_token(user_key)
222
           |> post(box_path(conversation.id), %{})
223
           |> json_response(503) == %{"error" => %{"code" => "box_not_configured"}}
224
  end
225
226
  test "quota, redaction, bounds, timeout, and provider URLs stay safe", %{conn: conn} do
227
    user_key = "box-api-safe-output"
228
    user = github_user("api-token-" <> user_key)
229
    {:ok, conversation} = Conversations.ensure_conversation(user)
230
231
    for index <- 1..OpenAgents.Box.maximum_active_boxes() do
232
      insert_box(conversation.id, "bx_aaaaaaa#{Enum.at(~w(2 3 4 5 6 7 8 9 a b), index - 1)}")
233
    end
234
235
    assert conn
236
           |> put_box_api_token(user_key)
237
           |> post(box_path(conversation.id), %{})
238
           |> json_response(409) == %{"error" => %{"code" => "box_quota_reached"}}
239
240
    insert_box(conversation.id, "bx_8bhkse3n")
241
    large_output = String.duplicate("x", 30_000)
242
243
    Req.Test.expect(__MODULE__, fn request ->
244
      Req.Test.json(request, %{
245
        "exitCode" => 124,
246
        "stdout" => "sk-or-v1-abcdefghijklmnop " <> large_output,
247
        "stderr" =>
248
          "clone https://openagents.com/OpenAgentsInc/openagents.com\n" <>
249
            "https://viewer.ascii.dev/desktop?access_token=secret",
250
        "timedOut" => true,
251
        "desktopUrl" => "https://viewer.ascii.dev/desktop?token=secret"
252
      })
253
    end)
254
255
    response =
256
      conn
257
      |> put_box_api_token(user_key)
258
      |> post("#{box_path(conversation.id)}/bx_8bhkse3n/commands", %{"command" => "id"})
259
      |> json_response(200)
260
261
    result = response["result"]
262
    assert result["exit_code"] == 124
263
    assert result["timed_out"]
264
    assert result["stdout_truncated"]
265
266
    assert result["stderr"] ==
267
             "clone https://openagents.com/OpenAgentsInc/openagents.com\n[REDACTED_URL]"
268
269
    refute response["result"]["stdout"] =~ "sk-or-v1"
270
    refute inspect(response) =~ "desktopUrl"
271
    refute inspect(response) =~ "viewer.ascii.dev"
272
    refute inspect(response) =~ "access_token"
273
  end
274
275
  test "a foreign conversation returns 404 without an outbound provider request", %{
276
    conn: conn
277
  } do
278
    {:ok, _owner_conversation} =
279
      Conversations.ensure_conversation(github_user("api-token-box-api-isolation"))
280
281
    {:ok, foreign_conversation} =
282
      Conversations.ensure_conversation(github_user("box-api-foreign-conversation"))
283
284
    insert_box(foreign_conversation.id, "bx_8bhkse3n")
285
286
    Req.Test.stub(__MODULE__, fn request ->
287
      send(self(), :unexpected_box_provider_request)
288
      Req.Test.json(request, box_body())
289
    end)
290
291
    paths = [
292
      {:get, box_path(foreign_conversation.id), %{}},
293
      {:post, box_path(foreign_conversation.id), %{}},
294
      {:get, "#{box_path(foreign_conversation.id)}/bx_8bhkse3n", %{}},
295
      {:post, "#{box_path(foreign_conversation.id)}/bx_8bhkse3n/commands",
296
       %{
297
         "command" => "id"
298
       }},
299
      {:post, "#{box_path(foreign_conversation.id)}/bx_8bhkse3n/stop", %{}}
300
    ]
301
302
    for {method, path, params} <- paths do
303
      response =
304
        conn
305
        |> put_box_api_token("box-api-isolation")
306
        |> request(method, path, params)
307
308
      assert response.status == 404
309
310
      assert json_response(response, 404) == %{
311
               "error" => %{"code" => "conversation_not_found"}
312
             }
313
    end
314
315
    refute_receive :unexpected_box_provider_request
316
  end
317
318
  test "creates and commands are rate limited per human principal", %{conn: conn} do
319
    original_api = Application.get_env(:openagents, :box_api)
320
321
    Application.put_env(
322
      :openagents,
323
      :box_api,
324
      Keyword.merge(original_api || [], create_rate_limit: 1, command_rate_limit: 1)
325
    )
326
327
    on_exit(fn -> restore_env(:box_api, original_api) end)
328
329
    user_key = "box-api-rate-limit"
330
    user = github_user("api-token-" <> user_key)
331
    {:ok, conversation} = Conversations.ensure_conversation(user)
332
333
    Req.Test.expect(__MODULE__, fn request -> Req.Test.json(request, box_body()) end)
334
335
    assert conn
336
           |> put_box_api_token(user_key)
337
           |> post(box_path(conversation.id), %{})
338
           |> json_response(201)
339
340
    assert conn
341
           |> put_box_api_token(user_key)
342
           |> post(box_path(conversation.id), %{})
343
           |> json_response(429) == %{"error" => %{"code" => "box_api_rate_limited"}}
344
345
    Req.Test.expect(__MODULE__, fn request -> Req.Test.json(request, command_body()) end)
346
347
    command_path = "#{box_path(conversation.id)}/bx_8bhkse3n/commands"
348
349
    assert conn
350
           |> put_box_api_token(user_key)
351
           |> post(command_path, %{"command" => "id"})
352
           |> json_response(200)
353
354
    assert conn
355
           |> put_box_api_token(user_key)
356
           |> post(command_path, %{"command" => "id"})
357
           |> json_response(429) == %{"error" => %{"code" => "box_api_rate_limited"}}
358
  end
359
360
  test "rate-limit buckets are shared across request processes" do
361
    original_api = Application.get_env(:openagents, :box_api)
362
363
    Application.put_env(
364
      :openagents,
365
      :box_api,
366
      Keyword.merge(original_api || [], create_rate_limit: 1)
367
    )
368
369
    on_exit(fn -> restore_env(:box_api, original_api) end)
370
371
    first = Task.async(fn -> OpenAgentsWeb.BoxRateLimiter.allow?("shared-principal", :create) end)
372
    assert Task.await(first) == :ok
373
374
    second =
375
      Task.async(fn -> OpenAgentsWeb.BoxRateLimiter.allow?("shared-principal", :create) end)
376
377
    assert Task.await(second) == {:error, :rate_limited}
378
  end
379
380
  defp box_path(conversation_id), do: "/api/v3/conversations/#{conversation_id}/boxes"
381
382
  defp box_body(overrides \\ %{}) do
383
    %{
384
      "box" =>
385
        Map.merge(
386
          %{"id" => "bx_8bhkse3n", "state" => "ready", "setupStatus" => "done"},
387
          overrides
388
        )
389
    }
390
  end
391
392
  defp command_body do
393
    %{
394
      "exitCode" => 0,
395
      "stdout" => "hi\n",
396
      "stderr" => "",
397
      "timedOut" => false
398
    }
399
  end
400
401
  defp insert_box(conversation_id, box_id) do
402
    %ConversationBox{}
403
    |> ConversationBox.changeset(%{
404
      conversation_id: conversation_id,
405
      box_id: box_id,
406
      state: "ready",
407
      setup_status: "done"
408
    })
409
    |> Repo.insert!()
410
  end
411
412
  defp restore_env(key, nil), do: Application.delete_env(:openagents, key)
413
  defp restore_env(key, value), do: Application.put_env(:openagents, key, value)
414
415
  defp request(conn, :get, path, _params), do: get(conn, path)
416
  defp request(conn, :post, path, params), do: post(conn, path, params)
417
end
test/openagents_web/route_authority_test.exs modified +20

@@ -160,6 +160,26 @@ defmodule OpenAgentsWeb.RouteAuthorityTest do

160 160
           ).pipe_through == [:chat_account_api]
161 161
  end
162 162
163
  test "Box control uses its own human account bearer pipeline" do
164
    route = route!(:get, "/api/v3/conversations/:conversation_id/boxes")
165
    create = route!(:post, "/api/v3/conversations/:conversation_id/boxes")
166
    command = route!(:post, "/api/v3/conversations/:conversation_id/boxes/:box_id/commands")
167
168
    assert route.class == :authenticated_api
169
    assert route.principal == "human account bearer token"
170
    assert route.scope == "box:control"
171
    refute route.mutation
172
    assert create.mutation
173
    assert command.mutation
174
175
    assert Phoenix.Router.route_info(
176
             OpenAgentsWeb.Router,
177
             "GET",
178
             "/api/v3/conversations/00000000-0000-4000-8000-000000000001/boxes",
179
             "stage.openagents.com"
180
           ).pipe_through == [:box_control_api]
181
  end
182
163 183
  test "operator and machine surfaces cannot drift into browser or public classes" do
164 184
    assert route!(:get, "/admin").class == :operator
165 185
    assert route!(:get, "/admin/forge").scope == "forge:promote"
test/support/conn_case.ex modified +5

@@ -130,6 +130,11 @@ defmodule OpenAgentsWeb.ConnCase do

130 130
    put_api_token_for_user(conn, user, ["chat:account"])
131 131
  end
132 132
133
  def put_box_api_token(conn, key) when is_binary(key) do
134
    user = github_user("api-token-" <> key)
135
    put_api_token_for_user(conn, user, ["box:control"])
136
  end
137
133 138
  defp put_api_token_for_user(conn, user, scopes) do
134 139
    {:ok, _credential, plaintext} =
135 140
      OpenAgents.ApiTokens.create(user, %{

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