Scope forge assignments to their issue branches

bbe6c61debfe · Devin AI · · parent e9c3682629fa

Scope forge assignments to their issue branches

Add durable assignment records that bind one Box, repository, issue, and
branch, with digest-only credentials and terminal lifecycle reporting.
Enforce repository and branch scope in the receive-pack path after parsing
all requested refs, before Pushes handles them. Assignment credentials do
not grant authority on other API surfaces, and no assignment code path
closes an issue.

Refs #108

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 docs/api-authentication.md
  • modified lib/openagents/agents.ex
  • modified lib/openagents/agents/agent.ex
  • added lib/openagents/agents/agent_box_grant.ex
  • modified lib/openagents/box/client.ex
  • modified lib/openagents/box_run_server.ex
  • modified lib/openagents/box_runs.ex
  • added lib/openagents/forge/assignment.ex
  • added lib/openagents/forge/assignment_credential.ex
  • added lib/openagents/forge/assignments.ex
  • modified lib/openagents/forge/git_http.ex
  • added lib/openagents/forge/git_receive_pack.ex
  • modified lib/openagents/repositories/repository.ex
  • modified lib/openagents/tools/redaction.ex
  • modified lib/openagents_web/api_route_authority.ex
  • modified lib/openagents_web/controllers/agent_controller.ex
  • added lib/openagents_web/controllers/assignment_controller.ex
  • added lib/openagents_web/plugs/assignment_control_auth.ex
  • modified lib/openagents_web/plugs/forge_git_auth.ex
  • modified lib/openagents_web/router.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260823141005_create_box_assignments_and_scoped_credentials.exs
  • added priv/repo/migrations/20260823141008_create_agent_box_control_grants.exs
  • added priv/repo/migrations/20260823141449_add_repository_protected_branches.exs
  • modified test/openagents/box_client_runs_test.exs
  • modified test/openagents/box_runs_test.exs
  • added test/openagents/forge/assignment_test.exs

Diff

28 files changed, +1512 -24

INVARIANTS.md modified +30

@@ -269,6 +269,34 @@ Evidence: `OpenAgentsWeb.Plugs.BoxControlAuth`, `OpenAgents.Box`,

269 269
`OpenAgentsWeb.BoxController`, and
270 270
`test/openagents_web/controllers/box_controller_test.exs`.
271 271
272
### IDENTITY-006 — Assignment credentials are repository and branch scoped
273
274
Status: Current
275
276
An assignment credential identifies one durable assignment and can access only
277
its repository. Git receive-pack authorizes every requested ref update before
278
any ref moves; the credential can update only its assignment branch. It cannot
279
write a default or protected branch, close an issue, authenticate as an
280
operator, or access non-Git API routes. The credential stores only a digest,
281
expires with the assignment deadline, and is revoked when the assignment
282
reaches a terminal state.
283
284
Evidence: `OpenAgents.Forge.Assignments`,
285
`OpenAgentsWeb.Plugs.ForgeGitAuth`, `OpenAgents.Forge.GitHTTP`, and
286
`test/openagents/forge/assignment_test.exs`.
287
288
### IDENTITY-007 — Delegated Box control is explicit and revocable
289
290
Status: Current
291
292
An agent can control a Box only when its linked human grants the
293
`box:control` scope. The grant records the granter and lifecycle timestamps.
294
Unlinking or revoking the grant prevents new Box-control requests, while
295
historical agent authorship remains unchanged.
296
297
Evidence: `OpenAgents.Agents`, `OpenAgentsWeb.Plugs.AssignmentControlAuth`,
298
and `test/openagents/agents_test.exs`.
299
272 300
### WORK-002 — Detached Box runs reconcile from durable evidence
273 301
274 302
Status: Current

@@ -2315,6 +2343,8 @@ contract; the invariant prose above defines the assertion, not the filename.

2315 2343
| IDENTITY-003 | `test/openagents/memory_portability_test.exs` |
2316 2344
| IDENTITY-004 | `test/openagents/agents_test.exs`, `test/openagents_web/controllers/agent_controller_test.exs` |
2317 2345
| IDENTITY-005 | `test/openagents_web/controllers/box_controller_test.exs` |
2346
| IDENTITY-006 | `test/openagents/forge/assignment_test.exs` |
2347
| IDENTITY-007 | `test/openagents/agents_test.exs` |
2318 2348
| WORK-002 | `test/openagents/box_runs_test.exs` |
2319 2349
| PROMISE-001 | `test/openagents/promise_registry_test.exs`, `test/openagents_web/controllers/project_controller_test.exs` |
2320 2350
| PROMISE-002 | `test/openagents/promise_registry_test.exs` |
docs/api-authentication.md modified +15 -1

@@ -55,7 +55,21 @@ bounded, redacted command output. It never returns provider, desktop, viewer,

55 55
or token-bearing URLs. A foreign conversation or Box returns `404` without a
56 56
provider request. Agent participation credentials receive
57 57
`{"error":{"code":"agent_box_control_forbidden"}}`; linked-agent Box control is
58
deferred to the linked-principal contract.
58
available only after the linked human grants the `box:control` scope.
59
60
### Assignment credentials
61
62
A linked human can grant and revoke Box control for an agent:
63
64
```sh
65
openagents api -X POST agents/AGENT_HANDLE/box-control
66
openagents api -X DELETE agents/AGENT_HANDLE/box-control
67
```
68
69
An assignment binds one issue, repository, Box, and branch. Its forge
70
credential is short-lived, stores only a digest, and is accepted only by Git
71
for that repository and branch. It cannot write a default or protected branch,
72
close an issue, use operator routes, or use Box API routes.
59 73
60 74
### Durable Box runs
61 75
lib/openagents/agents.ex modified +76 -1

@@ -10,7 +10,7 @@ defmodule OpenAgents.Agents do

10 10
  import Ecto.Query
11 11
12 12
  alias OpenAgents.Accounts.User
13
  alias OpenAgents.Agents.{Agent, AgentToken, AgentUserLink}
13
  alias OpenAgents.Agents.{Agent, AgentBoxGrant, AgentToken, AgentUserLink}
14 14
  alias OpenAgents.Audit
15 15
  alias OpenAgents.Repo
16 16

@@ -308,6 +308,81 @@ defmodule OpenAgents.Agents do

308 308
    end
309 309
  end
310 310
311
  @doc "Grants a linked agent revocable Box control."
312
  @spec grant_box_control(User.t(), Agent.t()) ::
313
          {:ok, AgentBoxGrant.t()} | {:error, atom()}
314
  def grant_box_control(%User{id: user_id}, %Agent{id: agent_id}) do
315
    with true <- linked?(agent_id, user_id) do
316
      %AgentBoxGrant{}
317
      |> AgentBoxGrant.changeset(%{
318
        agent_id: agent_id,
319
        user_id: user_id,
320
        granted_by_id: user_id,
321
        scope: "box:control",
322
        granted_at: DateTime.utc_now()
323
      })
324
      |> Repo.insert()
325
    else
326
      _ -> {:error, :agent_not_linked}
327
    end
328
  end
329
330
  @doc "Revokes an agent's active Box-control grant."
331
  @spec revoke_box_control(User.t(), Agent.t()) :: {:ok, AgentBoxGrant.t()} | {:error, atom()}
332
  def revoke_box_control(%User{id: user_id}, %Agent{id: agent_id}) do
333
    case Repo.one(
334
           from grant in AgentBoxGrant,
335
             where:
336
               grant.agent_id == ^agent_id and grant.user_id == ^user_id and
337
                 grant.scope == "box:control" and is_nil(grant.revoked_at)
338
         ) do
339
      %AgentBoxGrant{} = grant ->
340
        grant |> AgentBoxGrant.changeset(%{revoked_at: DateTime.utc_now()}) |> Repo.update()
341
342
      nil ->
343
        {:error, :grant_not_found}
344
    end
345
  end
346
347
  @doc "Checks whether a linked agent has an active Box-control grant."
348
  @spec box_control_granted?(Agent.t()) :: boolean()
349
  def box_control_granted?(%Agent{id: agent_id}) do
350
    Repo.exists?(
351
      from grant in AgentBoxGrant,
352
        where:
353
          grant.agent_id == ^agent_id and grant.scope == "box:control" and
354
            is_nil(grant.revoked_at),
355
        join: link in AgentUserLink,
356
        on:
357
          link.agent_id == grant.agent_id and link.user_id == grant.user_id and
358
            link.status == "linked"
359
    )
360
  end
361
362
  @doc "Returns the human account that currently grants an agent Box control."
363
  @spec box_control_owner(Agent.t()) :: User.t() | nil
364
  def box_control_owner(%Agent{id: agent_id}) do
365
    Repo.one(
366
      from grant in AgentBoxGrant,
367
        join: link in AgentUserLink,
368
        on:
369
          link.agent_id == grant.agent_id and link.user_id == grant.user_id and
370
            link.status == "linked",
371
        join: user in User,
372
        on: user.id == grant.user_id,
373
        where:
374
          grant.agent_id == ^agent_id and grant.scope == "box:control" and
375
            is_nil(grant.revoked_at)
376
    )
377
  end
378
379
  defp linked?(agent_id, user_id) do
380
    Repo.exists?(
381
      from link in AgentUserLink,
382
        where: link.agent_id == ^agent_id and link.user_id == ^user_id and link.status == "linked"
383
    )
384
  end
385
311 386
  defp fetch_link(id) do
312 387
    with {:ok, uuid} <- Ecto.UUID.cast(id),
313 388
         %AgentUserLink{} = link <- Repo.get(AgentUserLink, uuid) do
lib/openagents/agents/agent.ex modified +1

@@ -19,6 +19,7 @@ defmodule OpenAgents.Agents.Agent do

19 19
20 20
    has_many :tokens, OpenAgents.Agents.AgentToken
21 21
    has_many :user_links, OpenAgents.Agents.AgentUserLink
22
    has_many :box_control_grants, OpenAgents.Agents.AgentBoxGrant
22 23
23 24
    timestamps()
24 25
  end
lib/openagents/agents/agent_box_grant.ex added +40

@@ -0,0 +1,40 @@

1
defmodule OpenAgents.Agents.AgentBoxGrant do
2
  @moduledoc "A revocable human grant of Box control to an agent."
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  @primary_key {:id, :binary_id, autogenerate: true}
8
  @foreign_key_type :binary_id
9
  @timestamps_opts [type: :utc_datetime_usec]
10
11
  schema "agent_box_control_grants" do
12
    belongs_to :agent, OpenAgents.Agents.Agent
13
    belongs_to :user, OpenAgents.Accounts.User
14
    belongs_to :granted_by, OpenAgents.Accounts.User
15
    field :scope, :string, default: "box:control"
16
    field :granted_at, :utc_datetime_usec
17
    field :revoked_at, :utc_datetime_usec
18
    timestamps()
19
  end
20
21
  def changeset(grant, attrs) do
22
    grant
23
    |> cast(attrs, [:scope, :granted_at, :revoked_at])
24
    |> put_programmatic(attrs, :agent_id)
25
    |> put_programmatic(attrs, :user_id)
26
    |> put_programmatic(attrs, :granted_by_id)
27
    |> validate_required([:agent_id, :user_id, :granted_by_id, :scope, :granted_at])
28
    |> validate_inclusion(:scope, ["box:control"])
29
    |> foreign_key_constraint(:agent_id)
30
    |> foreign_key_constraint(:user_id)
31
    |> foreign_key_constraint(:granted_by_id)
32
  end
33
34
  defp put_programmatic(changeset, attrs, field) do
35
    case Map.fetch(attrs, field) do
36
      {:ok, value} -> put_change(changeset, field, value)
37
      :error -> changeset
38
    end
39
  end
40
end
lib/openagents/box/client.ex modified +48 -4

@@ -64,6 +64,25 @@ defmodule OpenAgents.Box.Client do

64 64
    end
65 65
  end
66 66
67
  @spec dispatch_run(String.t(), String.t(), String.t(), String.t(), String.t()) ::
68
          {:ok, integer()} | {:error, term()}
69
  def dispatch_run(box_id, run_id, command, run_directory, nil),
70
    do: dispatch_run(box_id, run_id, command, run_directory)
71
72
  def dispatch_run(box_id, run_id, command, run_directory, credential)
73
      when is_binary(box_id) and is_binary(run_id) and is_binary(command) and
74
             is_binary(run_directory) and is_binary(credential) do
75
    with {:ok, body} <-
76
           command(box_id, %{
77
             "command" => dispatch_command(run_id, command, run_directory, true),
78
             "timeoutSeconds" => 30,
79
             "env" => %{"OPENAGENTS_FORGE_TOKEN" => credential}
80
           }),
81
         {:ok, pid} <- dispatch_pid(body) do
82
      {:ok, pid}
83
    end
84
  end
85
67 86
  @doc "Polls one detached run for output and its exit sentinel."
68 87
  @spec poll_run(String.t(), String.t(), non_neg_integer()) :: {:ok, map()} | {:error, term()}
69 88
  def poll_run(box_id, run_id, offset)

@@ -128,9 +147,32 @@ defmodule OpenAgents.Box.Client do

128 147
  end
129 148
130 149
  defp dispatch_command(run_id, command, run_directory) do
150
    dispatch_command(run_id, command, run_directory, false)
151
  end
152
153
  defp dispatch_command(run_id, command, run_directory, with_credential) do
131 154
    root = run_root(run_id, run_directory)
132 155
    encoded = Base.encode64(command)
133 156
157
    credential_setup =
158
      if with_credential do
159
        """
160
        umask 077
161
        printf 'https://x:%s@openagents.com\\n' "$OPENAGENTS_FORGE_TOKEN" > "$root/forge-credential"
162
        git config --file="$root/gitconfig" credential.helper "store --file=$root/forge-credential"
163
        unset OPENAGENTS_FORGE_TOKEN
164
        """
165
      else
166
        ""
167
      end
168
169
    launch =
170
      if with_credential do
171
        ~s(nohup setsid env GIT_CONFIG_GLOBAL="$root/gitconfig" sh -c 'set +e; sh "$1" > "$2/output.log" 2>&1; status=$?; printf "%s\\n" "$status" > "$2/exit-code"; rm -f "$2/forge-credential" "$2/gitconfig"; exit "$status"' _ "$root/script.sh" "$root" </dev/null >/dev/null 2>&1 &)
172
      else
173
        ~s(nohup setsid sh -c 'set +e; sh "$1" > "$2/output.log" 2>&1; status=$?; printf "%s\\n" "$status" > "$2/exit-code"; rm -f "$2/forge-credential" "$2/gitconfig"; exit "$status"' _ "$root/script.sh" "$root" </dev/null >/dev/null 2>&1 &)
174
      end
175
134 176
    """
135 177
    set -eu
136 178
    root=#{root}

@@ -142,7 +184,7 @@ defmodule OpenAgents.Box.Client do

142 184
    printf '%s' '#{encoded}' | base64 -d > "$root/script.sh"
143 185
    chmod 700 "$root/script.sh"
144 186
    : > "$root/output.log"
145
    nohup setsid sh -c 'set +e; sh "$1" > "$2/output.log" 2>&1; status=$?; printf "%s\\n" "$status" > "$2/exit-code"; exit "$status"' _ "$root/script.sh" "$root" </dev/null >/dev/null 2>&1 &
187
    #{credential_setup}#{launch}
146 188
    pid=$!
147 189
    printf '%s\\n' "$pid" > "$root/pid"
148 190
    printf '%s\\n' "$pid"

@@ -199,12 +241,14 @@ defmodule OpenAgents.Box.Client do

199 241
    if [ -f "$root/pid" ]; then
200 242
      pid=$(cat "$root/pid")
201 243
      kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true
202
      printf 'OA_CANCELLED=1\\n'
244
      cancelled=1
203 245
    elif pkill -TERM -f -- "$root/script.sh" 2>/dev/null; then
204
      printf 'OA_CANCELLED=1\\n'
246
      cancelled=1
205 247
    else
206
      printf 'OA_CANCELLED=0\\n'
248
      cancelled=0
207 249
    fi
250
    rm -f "$root/forge-credential" "$root/gitconfig"
251
    printf 'OA_CANCELLED=%s\\n' "$cancelled"
208 252
    """
209 253
  end
210 254
lib/openagents/box_run_server.ex modified +38 -6

@@ -8,15 +8,17 @@ defmodule OpenAgents.BoxRunServer do

8 8
  alias OpenAgents.BoxRuns
9 9
  alias OpenAgents.Repo
10 10
11
  @spec start_link(String.t()) :: GenServer.on_start()
12
  def start_link(run_id) do
13
    GenServer.start_link(__MODULE__, run_id, name: via(run_id))
11
  @spec start_link(String.t() | {String.t(), keyword()}) :: GenServer.on_start()
12
  def start_link(run_id) when is_binary(run_id), do: start_link({run_id, []})
13
14
  def start_link({run_id, options}) do
15
    GenServer.start_link(__MODULE__, {run_id, options}, name: via(run_id))
14 16
  end
15 17
16 18
  @impl true
17
  def init(run_id) do
19
  def init({run_id, options}) do
18 20
    case Repo.get(Run, run_id) do
19
      %Run{} = run -> {:ok, %{run: run}, {:continue, :drive}}
21
      %Run{} = run -> {:ok, %{run: run, options: options}, {:continue, :drive}}
20 22
      nil -> :ignore
21 23
    end
22 24
  end

@@ -26,6 +28,7 @@ defmodule OpenAgents.BoxRunServer do

26 28
    next_state = drive(state)
27 29
28 30
    if terminal_state?(next_state.run) do
31
      finalize_assignment(next_state.run)
29 32
      {:stop, :normal, next_state}
30 33
    else
31 34
      {:noreply, next_state}

@@ -56,11 +59,13 @@ defmodule OpenAgents.BoxRunServer do

56 59
    case Repo.get(Run, run.id) do
57 60
      %Run{} = refreshed ->
58 61
        if Run.terminal?(refreshed) do
62
          finalize_assignment(refreshed)
59 63
          {:stop, :normal, %{state | run: refreshed}}
60 64
        else
61 65
          next_state = poll(%{state | run: refreshed})
62 66
63 67
          if terminal_state?(next_state.run) do
68
            finalize_assignment(next_state.run)
64 69
            {:stop, :normal, next_state}
65 70
          else
66 71
            {:noreply, next_state}

@@ -100,7 +105,8 @@ defmodule OpenAgents.BoxRunServer do

100 105
               box_id(claimed),
101 106
               claimed.id,
102 107
               claimed.command,
103
               claimed.run_directory
108
               claimed.run_directory,
109
               state.options[:assignment_credential]
104 110
             ) do
105 111
          {:ok, pid} ->
106 112
            {:ok, updated} = BoxRuns.mark_dispatched(claimed.id, pid)

@@ -243,5 +249,31 @@ defmodule OpenAgents.BoxRunServer do

243 249
244 250
  defp terminal_state?(%Run{} = run), do: Run.terminal?(run)
245 251
252
  defp finalize_assignment(
253
         %Run{requesting_principal: %{"type" => "assignment", "id" => id}} = run
254
       ) do
255
    case Repo.get(OpenAgents.Forge.Assignment, id) do
256
      %OpenAgents.Forge.Assignment{} = assignment ->
257
        _ =
258
          OpenAgents.Forge.Assignments.finish(
259
            assignment,
260
            assignment_state(run.state),
261
            nil,
262
            run.failure_reason
263
          )
264
265
        :ok
266
267
      nil ->
268
        :ok
269
    end
270
  end
271
272
  defp finalize_assignment(_run), do: :ok
273
274
  defp assignment_state("completed"), do: "completed"
275
  defp assignment_state("cancelled"), do: "cancelled"
276
  defp assignment_state(_state), do: "failed"
277
246 278
  defp via(run_id), do: {:via, Registry, {OpenAgents.BoxRunRegistry, run_id}}
247 279
end
lib/openagents/box_runs.ex modified +7 -4

@@ -13,7 +13,7 @@ defmodule OpenAgents.BoxRuns do

13 13
14 14
  @spec start_run(String.t(), String.t(), map(), String.t(), String.t()) ::
15 15
          {:ok, Run.t()} | {:error, term()}
16
  def start_run(conversation_id, box_id, principal, command, idempotency_key)
16
  def start_run(conversation_id, box_id, principal, command, idempotency_key, options \\ [])
17 17
      when is_binary(conversation_id) and is_binary(box_id) and is_map(principal) and
18 18
             is_binary(command) and is_binary(idempotency_key) do
19 19
    with {:ok, _box} <- Box.get_box(conversation_id, box_id),

@@ -24,7 +24,7 @@ defmodule OpenAgents.BoxRuns do

24 24
          {:ok, Repo.preload(run, :conversation_box)}
25 25
26 26
        {:new, run} ->
27
          case start_worker(run.id) do
27
          case start_worker(run.id, options) do
28 28
            {:ok, _pid} -> {:ok, Repo.preload(run, :conversation_box)}
29 29
            {:error, reason} -> {:error, reason}
30 30
          end

@@ -116,8 +116,11 @@ defmodule OpenAgents.BoxRuns do

116 116
  end
117 117
118 118
  @spec start_worker(String.t()) :: DynamicSupervisor.on_start_child()
119
  def start_worker(run_id) do
120
    DynamicSupervisor.start_child(OpenAgents.BoxRunSupervisor, {OpenAgents.BoxRunServer, run_id})
119
  def start_worker(run_id, options \\ []) do
120
    DynamicSupervisor.start_child(
121
      OpenAgents.BoxRunSupervisor,
122
      {OpenAgents.BoxRunServer, {run_id, options}}
123
    )
121 124
  end
122 125
123 126
  @spec claim_dispatch(String.t()) :: {:ok, Run.t()} | {:error, term()}
lib/openagents/forge/assignment.ex added +84

@@ -0,0 +1,84 @@

1
defmodule OpenAgents.Forge.Assignment do
2
  @moduledoc "A repository-scoped assignment of one issue to one Box."
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  @states ~w(admitted running completed failed cancelled)
8
  @terminal_states ~w(completed failed cancelled)
9
10
  @primary_key {:id, :binary_id, autogenerate: true}
11
  @foreign_key_type :binary_id
12
  @timestamps_opts [type: :utc_datetime_usec]
13
14
  schema "forge_assignments" do
15
    belongs_to :conversation_box, OpenAgents.Box.ConversationBox
16
    belongs_to :repository, OpenAgents.Repositories.Repository
17
    belongs_to :issue, OpenAgents.Issues.Issue, type: :id
18
    belongs_to :run, OpenAgents.Box.Run
19
    field :requesting_principal, :map
20
    field :branch, :string
21
    field :state, :string, default: "admitted"
22
    field :terminal_branch, :string
23
    field :terminal_commit, :string
24
    field :failure_reason, :string
25
    field :deadline_at, :utc_datetime_usec
26
    field :admitted_at, :utc_datetime_usec
27
    field :started_at, :utc_datetime_usec
28
    field :finished_at, :utc_datetime_usec
29
    timestamps()
30
  end
31
32
  @type t :: %__MODULE__{}
33
34
  def states, do: @states
35
  def terminal_states, do: @terminal_states
36
  def terminal?(%__MODULE__{state: state}), do: state in @terminal_states
37
38
  def changeset(assignment, attrs) do
39
    assignment
40
    |> cast(attrs, [
41
      :requesting_principal,
42
      :branch,
43
      :state,
44
      :terminal_branch,
45
      :terminal_commit,
46
      :failure_reason,
47
      :deadline_at,
48
      :admitted_at,
49
      :started_at,
50
      :finished_at,
51
      :run_id
52
    ])
53
    |> put_programmatic(attrs, :conversation_box_id)
54
    |> put_programmatic(attrs, :repository_id)
55
    |> put_programmatic(attrs, :issue_id)
56
    |> validate_required([
57
      :conversation_box_id,
58
      :repository_id,
59
      :issue_id,
60
      :requesting_principal,
61
      :branch,
62
      :deadline_at,
63
      :admitted_at
64
    ])
65
    |> validate_inclusion(:state, @states)
66
    |> validate_format(
67
      :branch,
68
      ~r/\A(?![.-])(?!.*(?:\.\.|@\{|[ ~^:?*\[\\]))[^\s:]+(?<!\.)(?<!\/)(?<!\.lock)\z/
69
    )
70
    |> foreign_key_constraint(:conversation_box_id)
71
    |> foreign_key_constraint(:repository_id)
72
    |> foreign_key_constraint(:issue_id)
73
    |> foreign_key_constraint(:run_id)
74
    |> unique_constraint(:conversation_box_id, name: :forge_assignments_one_active_box_index)
75
    |> unique_constraint(:issue_id, name: :forge_assignments_one_active_issue_index)
76
  end
77
78
  defp put_programmatic(changeset, attrs, field) do
79
    case Map.fetch(attrs, field) do
80
      {:ok, value} -> put_change(changeset, field, value)
81
      :error -> changeset
82
    end
83
  end
84
end
lib/openagents/forge/assignment_credential.ex added +46

@@ -0,0 +1,46 @@

1
defmodule OpenAgents.Forge.AssignmentCredential do
2
  @moduledoc "Digest-only short-lived credential for one forge assignment."
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  @primary_key {:id, :binary_id, autogenerate: true}
8
  @foreign_key_type :binary_id
9
  @timestamps_opts [type: :utc_datetime_usec]
10
11
  schema "forge_assignment_credentials" do
12
    belongs_to :assignment, OpenAgents.Forge.Assignment
13
    field :token_digest, :binary, redact: true
14
    field :last_four, :string
15
    field :repository_id, :binary_id
16
    field :branch, :string
17
    field :expires_at, :utc_datetime_usec
18
    field :revoked_at, :utc_datetime_usec
19
    timestamps()
20
  end
21
22
  def changeset(credential, attrs) do
23
    credential
24
    |> cast(attrs, [:token_digest, :last_four, :repository_id, :branch, :expires_at, :revoked_at])
25
    |> put_programmatic(attrs, :assignment_id)
26
    |> validate_required([
27
      :assignment_id,
28
      :token_digest,
29
      :last_four,
30
      :repository_id,
31
      :branch,
32
      :expires_at
33
    ])
34
    |> validate_length(:branch, max: 255)
35
    |> validate_length(:last_four, is: 4)
36
    |> foreign_key_constraint(:assignment_id)
37
    |> unique_constraint(:token_digest)
38
  end
39
40
  defp put_programmatic(changeset, attrs, field) do
41
    case Map.fetch(attrs, field) do
42
      {:ok, value} -> put_change(changeset, field, value)
43
      :error -> changeset
44
    end
45
  end
46
end
lib/openagents/forge/assignments.ex added +441

@@ -0,0 +1,441 @@

1
defmodule OpenAgents.Forge.Assignments do
2
  @moduledoc """
3
  Durable issue-to-Box assignments and their least-privilege Git credentials.
4
5
  Assignment credentials are digest-only and authenticate as an `:assignment`
6
  forge principal. Their repository and branch scope is read from the durable
7
  assignment snapshot, never from request metadata.
8
  """
9
10
  import Ecto.Query
11
12
  alias OpenAgents.Agents
13
  alias OpenAgents.Agents.Agent
14
  alias OpenAgents.Box.ConversationBox
15
  alias OpenAgents.BoxRuns
16
  alias OpenAgents.Forge.{Assignment, AssignmentCredential}
17
  alias OpenAgents.Issues
18
  alias OpenAgents.Issues.Issue
19
  alias OpenAgents.Repo
20
  alias OpenAgents.Conversations
21
  alias OpenAgents.Repositories.Repository
22
23
  @prefix "oa_assignment_"
24
  @terminal_states ~w(completed failed cancelled)
25
26
  @doc "Creates an assignment, claims its issue, mints its credential, and starts its run."
27
  @spec create(map()) :: {:ok, Assignment.t(), String.t()} | {:error, term()}
28
  def create(attrs) when is_map(attrs) do
29
    with {:ok, owner} <- owner(attrs),
30
         {:ok, conversation} <- owned_conversation(attrs, owner),
31
         {:ok, box} <- owned_box(attrs, conversation.id),
32
         {:ok, repository} <- repository(attrs),
33
         {:ok, issue} <- issue(repository, attrs),
34
         {:ok, branch} <- branch(repository, attrs),
35
         {:ok, principal} <- principal(attrs),
36
         :ok <-
37
           writable?(repository, attrs[:requesting_user] || attrs["requesting_user"], principal),
38
         {:ok, assignment, plaintext} <-
39
           persist_assignment(box, repository, issue, branch, principal, attrs) do
40
      case report_claim(assignment) do
41
        {:ok, _comment} ->
42
          case BoxRuns.start_run(
43
                 box.conversation_id,
44
                 box.box_id,
45
                 %{"type" => "assignment", "id" => assignment.id},
46
                 command(attrs),
47
                 idempotency_key(attrs),
48
                 assignment_credential: plaintext
49
               ) do
50
            {:ok, run} ->
51
              assignment =
52
                case Repo.get!(Assignment, assignment.id) do
53
                  %Assignment{} = current when current.state in @terminal_states ->
54
                    current
55
56
                  %Assignment{} = current ->
57
                    current
58
                    |> Assignment.changeset(%{
59
                      run_id: run.id,
60
                      state: "running",
61
                      started_at: DateTime.utc_now()
62
                    })
63
                    |> Repo.update!()
64
                end
65
66
              {:ok, assignment, plaintext}
67
68
            {:error, reason} ->
69
              _ = finish(assignment, "failed", nil, inspect(reason))
70
              {:error, reason}
71
          end
72
73
        {:error, reason} ->
74
          _ = finish(assignment, "failed", nil, "claim_event_failed")
75
          {:error, reason}
76
      end
77
    end
78
  end
79
80
  @doc "Authenticates an assignment credential and returns its scoped principal."
81
  @spec authenticate(String.t()) :: {:ok, map()} | {:error, :invalid_assignment_credential}
82
  def authenticate(@prefix <> rest = plaintext) when byte_size(plaintext) < 240 do
83
    with [id, secret] <- String.split(rest, ".", parts: 2),
84
         {:ok, uuid} <- Ecto.UUID.cast(id),
85
         true <- byte_size(secret) in 40..100,
86
         %AssignmentCredential{} = credential <-
87
           Repo.one(
88
             from c in AssignmentCredential,
89
               where: c.id == ^uuid,
90
               preload: [assignment: [:repository]]
91
           ),
92
         true <- Plug.Crypto.secure_compare(credential.token_digest, digest(plaintext)),
93
         true <- usable?(credential),
94
         %Assignment{state: state} = assignment when state in ["admitted", "running"] <-
95
           credential.assignment do
96
      {:ok,
97
       %{
98
         kind: :assignment,
99
         id: assignment.id,
100
         assignment_id: assignment.id,
101
         repository_id: credential.repository_id,
102
         branch: credential.branch,
103
         credential_id: credential.id
104
       }}
105
    else
106
      _ -> {:error, :invalid_assignment_credential}
107
    end
108
  end
109
110
  def authenticate(_), do: {:error, :invalid_assignment_credential}
111
112
  @doc "Revokes the credential and releases the issue claim for a terminal assignment."
113
  @spec finish(Assignment.t(), String.t(), String.t() | nil, String.t() | nil) ::
114
          {:ok, Assignment.t()} | {:error, term()}
115
  def finish(%Assignment{} = assignment, state, commit \\ nil, reason \\ nil)
116
      when state in @terminal_states do
117
    now = DateTime.utc_now()
118
119
    result =
120
      Repo.transaction(fn ->
121
        current =
122
          Repo.one!(from a in Assignment, where: a.id == ^assignment.id, lock: "FOR UPDATE")
123
124
        if Assignment.terminal?(current) do
125
          {:already_finished, current}
126
        else
127
          updated =
128
            current
129
            |> Assignment.changeset(%{
130
              state: state,
131
              terminal_branch: assignment.branch,
132
              terminal_commit: commit,
133
              failure_reason: reason,
134
              finished_at: now
135
            })
136
            |> Repo.update!()
137
138
          Repo.update_all(
139
            from(c in AssignmentCredential, where: c.assignment_id == ^current.id),
140
            set: [revoked_at: now, updated_at: now]
141
          )
142
143
          {:finished, updated}
144
        end
145
      end)
146
147
    case result do
148
      {:ok, {:finished, updated}} ->
149
        _ = report(updated)
150
        if updated.state in ["failed", "cancelled"], do: _ = report_release(updated)
151
        {:ok, updated}
152
153
      {:ok, {:already_finished, current}} ->
154
        {:ok, current}
155
156
      error ->
157
        error
158
    end
159
  end
160
161
  @doc "Returns the assignment credential metadata without exposing its secret."
162
  def credential(%Assignment{id: id}) do
163
    Repo.one(from c in AssignmentCredential, where: c.assignment_id == ^id)
164
  end
165
166
  @doc "Reports the terminal result once on the issue timeline."
167
  def report(%Assignment{} = assignment) do
168
    issue = Repo.get!(Issue, assignment.issue_id)
169
170
    body =
171
      [
172
        "Box assignment finished.",
173
        "Branch: `#{assignment.terminal_branch || assignment.branch}`.",
174
        "Commit: `#{assignment.terminal_commit || "none reported"}`.",
175
        "Result: `#{assignment.state}`.",
176
        if(assignment.failure_reason, do: "Reason: `#{assignment.failure_reason}`.", else: nil)
177
      ]
178
      |> Enum.reject(&is_nil/1)
179
      |> Enum.join("\n")
180
181
    Issues.create_comment(issue, %{body: body}, author(assignment.requesting_principal))
182
  end
183
184
  @doc "Reports the release of a failed or cancelled issue claim."
185
  def report_release(%Assignment{} = assignment) do
186
    issue = Repo.get!(Issue, assignment.issue_id)
187
188
    body =
189
      [
190
        "Box assignment claim released.",
191
        "Branch: `#{assignment.branch}`.",
192
        "Assignment: `#{assignment.id}`."
193
      ]
194
      |> Enum.join("\n")
195
196
    Issues.create_comment(issue, %{body: body}, author(assignment.requesting_principal))
197
  end
198
199
  @doc "Reports the claim before the Box run starts."
200
  def report_claim(%Assignment{} = assignment) do
201
    issue = Repo.get!(Issue, assignment.issue_id)
202
203
    body =
204
      [
205
        "Box assignment claimed.",
206
        "Branch: `#{assignment.branch}`.",
207
        "Assignment: `#{assignment.id}`."
208
      ]
209
      |> Enum.join("\n")
210
211
    Issues.create_comment(issue, %{body: body}, author(assignment.requesting_principal))
212
  end
213
214
  defp persist_assignment(box, repository, issue, branch, principal, attrs) do
215
    Repo.transaction(fn ->
216
      now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
217
      id = Ecto.UUID.generate()
218
      deadline = deadline(attrs, now)
219
      secret = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
220
      plaintext = @prefix <> id <> "." <> secret
221
222
      assignment =
223
        %Assignment{id: id}
224
        |> Assignment.changeset(%{
225
          conversation_box_id: box.id,
226
          repository_id: repository.id,
227
          issue_id: issue.id,
228
          requesting_principal: principal,
229
          branch: branch,
230
          deadline_at: deadline,
231
          admitted_at: now
232
        })
233
        |> Repo.insert!()
234
235
      %AssignmentCredential{}
236
      |> AssignmentCredential.changeset(%{
237
        assignment_id: assignment.id,
238
        token_digest: digest(plaintext),
239
        last_four: String.slice(secret, -4, 4),
240
        repository_id: repository.id,
241
        branch: branch,
242
        expires_at: deadline
243
      })
244
      |> Repo.insert!()
245
246
      {assignment, plaintext}
247
    end)
248
    |> case do
249
      {:ok, {assignment, plaintext}} ->
250
        {:ok, assignment, plaintext}
251
252
      {:error, %Ecto.Changeset{} = changeset} ->
253
        {:error, claim_error(changeset)}
254
255
      {:error, reason} ->
256
        {:error, reason}
257
    end
258
  end
259
260
  defp owned_box(%{"conversation_id" => conversation_id, "box_id" => box_id}),
261
    do: box_record(conversation_id, box_id)
262
263
  defp owned_box(%{conversation_id: conversation_id, box_id: box_id}),
264
    do: box_record(conversation_id, box_id)
265
266
  defp owned_box(_), do: {:error, :box_not_owned}
267
268
  defp owned_box(attrs, conversation_id) do
269
    attrs
270
    |> Map.put("conversation_id", conversation_id)
271
    |> owned_box()
272
  end
273
274
  defp owner(attrs) do
275
    case attrs[:requesting_user] || attrs["requesting_user"] do
276
      %OpenAgents.Accounts.User{} = user ->
277
        {:ok, user}
278
279
      _ ->
280
        case attrs[:requesting_principal] || attrs["requesting_principal"] do
281
          %Agent{} = agent ->
282
            case Agents.box_control_owner(agent) do
283
              %OpenAgents.Accounts.User{} = user -> {:ok, user}
284
              _ -> {:error, :conversation_not_found}
285
            end
286
287
          _ ->
288
            {:error, :conversation_not_found}
289
        end
290
    end
291
  end
292
293
  defp owned_conversation(attrs, owner) do
294
    case Conversations.get_conversation_for_user(
295
           owner,
296
           attrs["conversation_id"] || attrs[:conversation_id]
297
         ) do
298
      nil -> {:error, :conversation_not_found}
299
      conversation -> {:ok, conversation}
300
    end
301
  end
302
303
  defp box_record(conversation_id, box_id) do
304
    case Repo.one(
305
           from b in ConversationBox,
306
             where: b.conversation_id == ^conversation_id and b.box_id == ^box_id
307
         ) do
308
      %ConversationBox{stopped_at: nil} = box -> {:ok, box}
309
      %ConversationBox{} -> {:error, :box_stopped}
310
      nil -> {:error, :box_not_owned}
311
    end
312
  end
313
314
  defp repository(attrs) do
315
    id = attrs[:repository_id] || attrs["repository_id"]
316
317
    case Repo.get(Repository, id) do
318
      %Repository{lifecycle_state: "ready"} = repo -> {:ok, repo}
319
      _ -> {:error, :repository_not_found}
320
    end
321
  end
322
323
  defp issue(%Repository{id: repository_id}, attrs) do
324
    id = attrs[:issue_id] || attrs["issue_id"]
325
    number = attrs[:issue_number] || attrs["issue_number"]
326
327
    issue =
328
      cond do
329
        is_binary(id) ->
330
          Repo.get_by(Issue, id: id, repository_id: repository_id)
331
332
        is_integer(number) ->
333
          Repo.get_by(Issue, number: number, repository_id: repository_id)
334
335
        is_binary(number) ->
336
          with {n, ""} <- Integer.parse(number),
337
               do: Repo.get_by(Issue, number: n, repository_id: repository_id)
338
339
        true ->
340
          nil
341
      end
342
343
    case issue do
344
      %Issue{} = value -> {:ok, value}
345
      nil -> {:error, :issue_not_found}
346
    end
347
  end
348
349
  defp writable?(%Repository{} = repository, %OpenAgents.Accounts.User{} = user, _principal) do
350
    if OpenAgents.Repositories.writable?(repository, user),
351
      do: :ok,
352
      else: {:error, :repository_not_writable}
353
  end
354
355
  defp writable?(repository, _user, %{"type" => "agent", "id" => id}) do
356
    case Repo.get(Agent, id) |> Agents.box_control_owner() do
357
      %OpenAgents.Accounts.User{} = user -> writable?(repository, user, %{"type" => "user"})
358
      _ -> {:error, :repository_not_writable}
359
    end
360
  end
361
362
  defp writable?(_, _, _), do: {:error, :repository_not_writable}
363
364
  defp branch(%Repository{default_branch: default_branch, protected_branches: protected}, attrs) do
365
    branch = attrs[:branch] || attrs["branch"]
366
367
    cond do
368
      not is_binary(branch) or branch == "" ->
369
        {:error, :invalid_assignment_branch}
370
371
      branch == default_branch or branch in ["main", "master"] or branch in (protected || []) ->
372
        {:error, :protected_branch}
373
374
      String.starts_with?(branch, ["-", "."]) or
375
          String.contains?(branch, ["..", "@{", "\\", " ", "~", "^", ":", "?", "*", "["]) ->
376
        {:error, :invalid_assignment_branch}
377
378
      true ->
379
        {:ok, branch}
380
    end
381
  end
382
383
  defp principal(attrs) do
384
    case attrs[:requesting_principal] || attrs["requesting_principal"] do
385
      %Agent{} = agent ->
386
        if Agents.box_control_granted?(agent),
387
          do:
388
            {:ok,
389
             %{
390
               "type" => "agent",
391
               "id" => agent.id,
392
               "actor_type" => "agent",
393
               "actor_id" => agent.id
394
             }},
395
          else: {:error, :agent_box_control_forbidden}
396
397
      %OpenAgents.Accounts.User{id: id} ->
398
        {:ok, %{"type" => "user", "id" => id, "actor_type" => "user", "actor_id" => id}}
399
400
      %{"type" => type, "id" => id} when type in ["user", "agent"] and is_binary(id) ->
401
        {:ok, %{"type" => type, "id" => id, "actor_type" => type, "actor_id" => id}}
402
403
      %{type: type, id: id} when type in [:user, :agent] and is_binary(id) ->
404
        type = Atom.to_string(type)
405
        {:ok, %{"type" => type, "id" => id, "actor_type" => type, "actor_id" => id}}
406
407
      _ ->
408
        {:error, :invalid_principal}
409
    end
410
  end
411
412
  defp command(attrs), do: attrs[:command] || attrs["command"] || "true"
413
414
  defp idempotency_key(attrs),
415
    do: attrs[:idempotency_key] || attrs["idempotency_key"] || Ecto.UUID.generate()
416
417
  defp deadline(attrs, now) do
418
    configured = attrs[:deadline_at] || attrs["deadline_at"]
419
    ttl = Application.get_env(:openagents, :box_api, [])[:ttl_seconds] || 3_600
420
    maximum = DateTime.add(now, ttl, :second)
421
422
    if match?(%DateTime{}, configured) and DateTime.compare(configured, maximum) == :lt,
423
      do: configured,
424
      else: maximum
425
  end
426
427
  defp claim_error(changeset) do
428
    if Enum.any?(changeset.errors, fn {field, _} -> field == :issue_id end),
429
      do: :assignment_issue_claimed,
430
      else: :assignment_box_busy
431
  end
432
433
  defp usable?(%AssignmentCredential{revoked_at: nil, expires_at: expires_at}),
434
    do: DateTime.compare(expires_at, DateTime.utc_now()) == :gt
435
436
  defp usable?(_), do: false
437
438
  defp author(%{"actor_type" => "agent", "actor_id" => id}), do: Repo.get!(Agent, id)
439
  defp author(_), do: nil
440
  defp digest(value), do: :crypto.hash(:sha256, value)
441
end
lib/openagents/forge/git_http.ex modified +56 -6

@@ -101,7 +101,8 @@ defmodule OpenAgents.Forge.GitHTTP do

101 101
  defp receive_pack(conn, owner, name) do
102 102
    with {:ok, repository} <- resolve_repository(conn, owner, name),
103 103
         :ok <- authorize(conn, repository, :write),
104
         {:ok, body, conn} <- read_git_body(conn) do
104
         {:ok, body, conn} <- read_git_body(conn),
105
         :ok <- authorize_receive_pack(conn, repository, body) do
105 106
      case Pushes.handle_receive_pack(
106 107
             repository.storage_key,
107 108
             body,

@@ -223,14 +224,32 @@ defmodule OpenAgents.Forge.GitHTTP do

223 224
      preload: [namespace: namespace]
224 225
  end
225 226
226
  defp authorize(_conn, %{visibility: "public"}, :read), do: :ok
227
  defp authorize(conn, %{visibility: "public"} = repository, :read) do
228
    case conn.assigns[:forge_principal] do
229
      %{kind: :assignment, repository_id: repository_id} ->
230
        if repository.id == repository_id, do: :ok, else: {:error, 404, "unknown repository"}
231
232
      _principal ->
233
        :ok
234
    end
235
  end
227 236
228 237
  defp authorize(conn, repository, :read) do
229 238
    case conn.assigns[:forge_principal] do
230
      nil -> authentication_required()
231
      %{kind: :user, user: user} -> member_read(repository, user)
232
      %{kind: :operator} -> operational_access(repository)
233
      %{kind: :machine, id: machine_id} -> machine_access(repository, machine_id, "read")
239
      nil ->
240
        authentication_required()
241
242
      %{kind: :user, user: user} ->
243
        member_read(repository, user)
244
245
      %{kind: :operator} ->
246
        operational_access(repository)
247
248
      %{kind: :machine, id: machine_id} ->
249
        machine_access(repository, machine_id, "read")
250
251
      %{kind: :assignment, repository_id: repository_id} ->
252
        if repository.id == repository_id, do: :ok, else: {:error, 404, "unknown repository"}
234 253
    end
235 254
  end
236 255

@@ -254,9 +273,40 @@ defmodule OpenAgents.Forge.GitHTTP do

254 273
255 274
      %{kind: :machine, id: machine_id} ->
256 275
        machine_access(repository, machine_id, "write")
276
277
      %{kind: :assignment, repository_id: repository_id} ->
278
        if repository.id == repository_id, do: :ok, else: {:error, 404, "unknown repository"}
279
    end
280
  end
281
282
  defp authorize_receive_pack(conn, repository, body) do
283
    case conn.assigns[:forge_principal] do
284
      %{kind: :assignment, repository_id: repository_id, branch: branch}
285
      when repository.id == repository_id ->
286
        with {:ok, refs} <- OpenAgents.Forge.GitReceivePack.refs(body),
287
             true <-
288
               refs != [] and Enum.all?(refs, &allowed_assignment_ref?(&1, branch, repository)) do
289
          :ok
290
        else
291
          _ -> {:error, 403, "assignment branch is not authorized"}
292
        end
293
294
      %{kind: :assignment} ->
295
        {:error, 404, "unknown repository"}
296
297
      _principal ->
298
        :ok
257 299
    end
258 300
  end
259 301
302
  defp allowed_assignment_ref?(ref, branch, repository) do
303
    ref == "refs/heads/" <> branch and
304
      branch != repository.default_branch and
305
      branch not in (repository.protected_branches || []) and
306
      branch not in ["main", "master"] and
307
      not String.starts_with?(branch, "protected/")
308
  end
309
260 310
  defp member_read(repository, user) do
261 311
    if Repositories.membership_role(repository, user),
262 312
      do: :ok,
lib/openagents/forge/git_receive_pack.ex added +30

@@ -0,0 +1,30 @@

1
defmodule OpenAgents.Forge.GitReceivePack do
2
  @moduledoc false
3
4
  @spec refs(binary()) :: {:ok, [String.t()]} | {:error, :invalid_receive_pack}
5
  def refs(body) when is_binary(body), do: parse(body, [])
6
7
  defp parse(<<"0000", _rest::binary>>, refs), do: {:ok, Enum.reverse(refs)}
8
9
  defp parse(<<length::binary-size(4), rest::binary>>, refs) do
10
    with {size, ""} <- Integer.parse(length, 16),
11
         true <- size >= 4 and byte_size(rest) >= size - 4,
12
         payload = binary_part(rest, 0, size - 4),
13
         tail = binary_part(rest, size - 4, byte_size(rest) - size + 4) do
14
      case payload |> String.split(<<0>>, parts: 2) |> hd() |> String.trim() do
15
        "shallow " <> oid when oid != "" ->
16
          parse(tail, refs)
17
18
        line ->
19
          case String.split(line, " ", parts: 3) do
20
            [_old, _new, ref] when ref != "" -> parse(tail, [ref | refs])
21
            _ -> {:error, :invalid_receive_pack}
22
          end
23
      end
24
    else
25
      _ -> {:error, :invalid_receive_pack}
26
    end
27
  end
28
29
  defp parse(_body, _refs), do: {:error, :invalid_receive_pack}
30
end
lib/openagents/repositories/repository.ex modified +1

@@ -17,6 +17,7 @@ defmodule OpenAgents.Repositories.Repository do

17 17
    field :name_key, :string
18 18
    field :visibility, :string, default: "private"
19 19
    field :default_branch, :string, default: "main"
20
    field :protected_branches, {:array, :string}, default: []
20 21
    field :description, :string
21 22
    field :pull_requests_enabled, :boolean, default: true
22 23
    field :lifecycle_state, :string, default: "provisioning"
lib/openagents/tools/redaction.ex modified +1

@@ -5,6 +5,7 @@ defmodule OpenAgents.Tools.Redaction do

5 5
  @sensitive_fragments ~w(api_key authorization cookie credential password private_key secret token)
6 6
  @secret_patterns [
7 7
    ~r/\bsk-(?:or-v1-)?[A-Za-z0-9_-]{16,}\b/,
8
    ~r/\boa_(?:pat|agent|assignment)_[A-Za-z0-9._-]{16,}\b/,
8 9
    ~r/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+\/-]+=*\b/i,
9 10
    ~r/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/
10 11
  ]
lib/openagents_web/api_route_authority.ex modified +7

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

93 93
      "get /api/v3/conversations/:conversation_id/boxes/:box_id" => :required_bearer,
94 94
      "post /api/v3/conversations/:conversation_id/boxes/:box_id/commands" => :required_bearer,
95 95
      "post /api/v3/conversations/:conversation_id/boxes/:box_id/stop" => :required_bearer,
96
      "post /api/v3/conversations/:conversation_id/boxes/:box_id/assignments" => :required_bearer,
97
      "get /api/v3/conversations/:conversation_id/boxes/:box_id/assignments/:assignment_id" =>
98
        :required_bearer,
99
      "post /api/v3/conversations/:conversation_id/boxes/:box_id/assignments/:assignment_id/cancel" =>
100
        :required_bearer,
96 101
      "post /api/v3/conversations/:conversation_id/boxes/:box_id/runs" => :required_bearer,
97 102
      "get /api/v3/conversations/:conversation_id/boxes/:box_id/runs" => :required_bearer,
98 103
      "get /api/v3/conversations/:conversation_id/boxes/:box_id/runs/:run_id" => :required_bearer,

@@ -152,6 +157,8 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

152 157
      "post /api/v3/repos/:owner/:repo/issues" => :required_bearer,
153 158
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/comments" => :required_bearer,
154 159
      "get /api/v3/agent" => :required_bearer,
160
      "post /api/v3/agents/:handle/box-control" => :required_bearer,
161
      "delete /api/v3/agents/:handle/box-control" => :required_bearer,
155 162
      "post /api/v3/agent/credentials" => :required_bearer,
156 163
      "post /api/v3/agent/links" => :required_bearer,
157 164
      "get /api/v3/agents/links" => :required_bearer,
lib/openagents_web/controllers/agent_controller.ex modified +32

@@ -90,6 +90,26 @@ defmodule OpenAgentsWeb.AgentController do

90 90
  def reject_link(conn, %{"id" => id}), do: review_link(conn, id, :reject)
91 91
  def unlink(conn, %{"id" => id}), do: review_link(conn, id, :unlink)
92 92
93
  def grant_box_control(conn, %{"handle" => handle}) do
94
    with %Agent{} = agent <- Agents.get_by_handle(handle),
95
         {:ok, grant} <- Agents.grant_box_control(conn.assigns.current_user, agent) do
96
      json(conn, %{"grant" => grant_json(grant)})
97
    else
98
      nil -> refusal(conn, :not_found, "agent_not_found")
99
      {:error, reason} -> refusal(conn, :conflict, error_code(reason))
100
    end
101
  end
102
103
  def revoke_box_control(conn, %{"handle" => handle}) do
104
    with %Agent{} = agent <- Agents.get_by_handle(handle),
105
         {:ok, grant} <- Agents.revoke_box_control(conn.assigns.current_user, agent) do
106
      json(conn, %{"grant" => grant_json(grant)})
107
    else
108
      nil -> refusal(conn, :not_found, "agent_not_found")
109
      {:error, reason} -> refusal(conn, :conflict, error_code(reason))
110
    end
111
  end
112
93 113
  def suspend(conn, %{"handle" => handle} = params) do
94 114
    with %Agent{} = agent <- Agents.get_by_handle(handle),
95 115
         {:ok, suspended} <- Agents.suspend(agent, params["reason"] || "operator suspension") do

@@ -161,6 +181,18 @@ defmodule OpenAgentsWeb.AgentController do

161 181
    }
162 182
  end
163 183
184
  defp grant_json(grant) do
185
    %{
186
      "id" => grant.id,
187
      "agent_id" => grant.agent_id,
188
      "user_id" => grant.user_id,
189
      "granted_by_id" => grant.granted_by_id,
190
      "scope" => grant.scope,
191
      "granted_at" => grant.granted_at,
192
      "revoked_at" => grant.revoked_at
193
    }
194
  end
195
164 196
  defp remote_ip(conn), do: conn.remote_ip |> :inet.ntoa() |> to_string()
165 197
166 198
  defp errors(changeset), do: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)
lib/openagents_web/controllers/assignment_controller.ex added +140

@@ -0,0 +1,140 @@

1
defmodule OpenAgentsWeb.AssignmentController do
2
  @moduledoc "API endpoints for assigning one forge issue to one Box."
3
4
  use OpenAgentsWeb, :controller
5
  import Ecto.Query
6
7
  alias OpenAgents.Forge.{Assignment, Assignments}
8
9
  def create(conn, %{"conversation_id" => conversation_id, "box_id" => box_id} = params) do
10
    principal = conn.assigns[:current_agent] || conn.assigns[:current_user]
11
12
    case OpenAgentsWeb.BoxRateLimiter.allow?(principal_id(principal), :run_create) do
13
      {:error, :rate_limited} ->
14
        refusal(conn, :too_many_requests, "rate_limited")
15
16
      :ok ->
17
        create_assignment(conn, conversation_id, box_id, params, principal)
18
    end
19
  end
20
21
  defp create_assignment(conn, conversation_id, box_id, params, principal) do
22
    attrs =
23
      params
24
      |> Map.put("conversation_id", conversation_id)
25
      |> Map.put("box_id", box_id)
26
      |> Map.put("requesting_principal", principal)
27
      |> Map.put("requesting_user", conn.assigns[:current_user])
28
29
    case Assignments.create(attrs) do
30
      {:ok, assignment, _secret} ->
31
        conn |> put_status(:accepted) |> json(%{"assignment" => projection(assignment)})
32
33
      {:error, :agent_box_control_forbidden} ->
34
        refusal(conn, :forbidden, "agent_box_control_forbidden")
35
36
      {:error, :assignment_issue_claimed} ->
37
        refusal(conn, :conflict, "assignment_issue_claimed")
38
39
      {:error, :assignment_box_busy} ->
40
        refusal(conn, :conflict, "assignment_box_busy")
41
42
      {:error, :protected_branch} ->
43
        refusal(conn, :forbidden, "protected_branch")
44
45
      {:error, :conversation_not_found} ->
46
        refusal(conn, :not_found, "conversation_not_found")
47
48
      {:error, :box_not_owned} ->
49
        refusal(conn, :not_found, "box_not_found")
50
51
      {:error, reason} ->
52
        refusal(conn, :unprocessable_entity, error_code(reason))
53
    end
54
  end
55
56
  defp principal_id(%{id: id}), do: id
57
  defp principal_id(_), do: "unknown"
58
59
  def show(conn, %{"assignment_id" => id}) do
60
    case fetch(id, conn) do
61
      %Assignment{} = assignment -> json(conn, %{"assignment" => projection(assignment)})
62
      nil -> refusal(conn, :not_found, "assignment_not_found")
63
    end
64
  end
65
66
  def cancel(conn, %{"assignment_id" => id}) do
67
    case fetch(id, conn) do
68
      %Assignment{} = assignment ->
69
        if assignment.run, do: OpenAgents.BoxRuns.cancel(assignment.run)
70
71
        {:ok, assignment} =
72
          Assignments.finish(assignment, "cancelled", nil, "cancelled_by_request")
73
74
        conn |> put_status(:accepted) |> json(%{"assignment" => projection(assignment)})
75
76
      nil ->
77
        refusal(conn, :not_found, "assignment_not_found")
78
    end
79
  end
80
81
  defp fetch(id, conn) do
82
    owner =
83
      case conn.assigns[:current_user] do
84
        nil -> OpenAgents.Agents.box_control_owner(conn.assigns[:current_agent])
85
        user -> user
86
      end
87
88
    if owner &&
89
         OpenAgents.Conversations.get_conversation_for_user(
90
           owner,
91
           conn.params["conversation_id"]
92
         ) do
93
      case Ecto.UUID.cast(id) do
94
        {:ok, id} ->
95
          OpenAgents.Repo.one(
96
            from assignment in Assignment,
97
              join: box in OpenAgents.Box.ConversationBox,
98
              on: box.id == assignment.conversation_box_id,
99
              where:
100
                assignment.id == ^id and box.conversation_id == ^conn.params["conversation_id"],
101
              preload: [conversation_box: box, run: :conversation_box]
102
          )
103
104
        :error ->
105
          nil
106
      end
107
    else
108
      nil
109
    end
110
  end
111
112
  defp projection(%Assignment{} = assignment) do
113
    %{
114
      "id" => assignment.id,
115
      "repository_id" => assignment.repository_id,
116
      "issue_id" => assignment.issue_id,
117
      "box_id" => assignment_box_id(assignment),
118
      "branch" => assignment.branch,
119
      "state" => assignment.state,
120
      "terminal_branch" => assignment.terminal_branch,
121
      "terminal_commit" => assignment.terminal_commit,
122
      "failure_reason" => assignment.failure_reason,
123
      "deadline_at" => iso(assignment.deadline_at),
124
      "admitted_at" => iso(assignment.admitted_at),
125
      "started_at" => iso(assignment.started_at),
126
      "finished_at" => iso(assignment.finished_at)
127
    }
128
  end
129
130
  defp assignment_box_id(%Assignment{conversation_box: %{box_id: box_id}}), do: box_id
131
  defp assignment_box_id(%Assignment{conversation_box_id: id}), do: id
132
133
  defp iso(nil), do: nil
134
  defp iso(value), do: DateTime.to_iso8601(value)
135
  defp error_code(reason) when is_atom(reason), do: Atom.to_string(reason)
136
  defp error_code(_), do: "assignment_refused"
137
138
  defp refusal(conn, status, code),
139
    do: conn |> put_status(status) |> json(%{"error" => %{"code" => code}})
140
end
lib/openagents_web/plugs/assignment_control_auth.ex added +52

@@ -0,0 +1,52 @@

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

@@ -60,6 +60,13 @@ defmodule OpenAgentsWeb.Plugs.ForgeGitAuth do

60 60
    end
61 61
  end
62 62
63
  defp principal_for("oa_assignment_" <> _ = token) do
64
    case OpenAgents.Forge.Assignments.authenticate(token) do
65
      {:ok, principal} -> {:ok, principal}
66
      _ -> :error
67
    end
68
  end
69
63 70
  defp principal_for(token) when is_binary(token) and token != "" do
64 71
    operator_token = Application.get_env(:openagents, :forge_operator_token)
65 72
lib/openagents_web/router.ex modified +21

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

66 66
    plug OpenAgentsWeb.Plugs.BoxControlAuth, scope: "box:control"
67 67
  end
68 68
69
  pipeline :assignment_control_api do
70
    plug :accepts, ["json"]
71
    plug OpenAgentsWeb.Plugs.RequestOrigin
72
    plug OpenAgentsWeb.Plugs.AssignmentControlAuth, scope: "box:control"
73
  end
74
69 75
  # The deployment control plane authenticates two principals: a human holding
70 76
  # `deployments:write`, and a short-lived workflow grant. Neither carries the
71 77
  # operator-only fleet promotion authority.

@@ -381,6 +387,13 @@ defmodule OpenAgentsWeb.Router do

381 387
    post "/repos/:owner/:repo/issues/:issue_number/comments", CommentController, :create
382 388
  end
383 389
390
  scope "/api/v3", OpenAgentsWeb do
391
    pipe_through :forge_write_api
392
393
    post "/agents/:handle/box-control", AgentController, :grant_box_control
394
    delete "/agents/:handle/box-control", AgentController, :revoke_box_control
395
  end
396
384 397
  scope "/api/v3", OpenAgentsWeb do
385 398
    pipe_through :forge_write_api
386 399

@@ -428,6 +441,14 @@ defmodule OpenAgentsWeb.Router do

428 441
    post "/:box_id/runs/:run_id/cancel", BoxRunController, :cancel
429 442
  end
430 443
444
  scope "/api/v3/conversations/:conversation_id/boxes", OpenAgentsWeb do
445
    pipe_through :assignment_control_api
446
447
    post "/:box_id/assignments", AssignmentController, :create
448
    get "/:box_id/assignments/:assignment_id", AssignmentController, :show
449
    post "/:box_id/assignments/:assignment_id/cancel", AssignmentController, :cancel
450
  end
451
431 452
  scope "/api/v3", OpenAgentsWeb do
432 453
    pipe_through :forge_write_api
433 454
priv/migration_lineages/prior-2026-08-19.json modified +4 -1

@@ -256,7 +256,10 @@

256 256
    20260823122039,
257 257
    20260823122040,
258 258
    20260823131739,
259
    20260823133243
259
    20260823133243,
260
    20260823141005,
261
    20260823141008,
262
    20260823141449
260 263
  ],
261 264
  "required_tables": [
262 265
    "users",
priv/repo/migrations/20260823141005_create_box_assignments_and_scoped_credentials.exs added +69

@@ -0,0 +1,69 @@

1
defmodule OpenAgents.Repo.Migrations.CreateBoxAssignmentsAndScopedCredentials do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:forge_assignments, primary_key: false) do
6
      add :id, :binary_id, primary_key: true
7
8
      add :conversation_box_id,
9
          references(:conversation_boxes, type: :binary_id, on_delete: :restrict), null: false
10
11
      add :repository_id, references(:repositories, type: :binary_id, on_delete: :restrict),
12
        null: false
13
14
      add :issue_id, references(:issues, on_delete: :restrict), null: false
15
      add :run_id, references(:box_runs, type: :binary_id, on_delete: :nilify_all)
16
      add :requesting_principal, :map, null: false
17
      add :branch, :string, null: false
18
      add :state, :string, null: false, default: "admitted"
19
      add :terminal_branch, :string
20
      add :terminal_commit, :string
21
      add :failure_reason, :string
22
      add :deadline_at, :utc_datetime_usec, null: false
23
      add :admitted_at, :utc_datetime_usec, null: false
24
      add :started_at, :utc_datetime_usec
25
      add :finished_at, :utc_datetime_usec
26
      timestamps(type: :utc_datetime_usec)
27
    end
28
29
    create unique_index(:forge_assignments, [:conversation_box_id],
30
             name: :forge_assignments_one_active_box_index,
31
             where: "state IN ('admitted', 'running')"
32
           )
33
34
    create unique_index(:forge_assignments, [:issue_id],
35
             name: :forge_assignments_one_active_issue_index,
36
             where: "state IN ('admitted', 'running')"
37
           )
38
39
    create index(:forge_assignments, [:repository_id, :issue_id])
40
41
    create constraint(:forge_assignments, :forge_assignments_state_check,
42
             check: "state IN ('admitted', 'running', 'completed', 'failed', 'cancelled')"
43
           )
44
45
    create table(:forge_assignment_credentials, primary_key: false) do
46
      add :id, :binary_id, primary_key: true
47
48
      add :assignment_id,
49
          references(:forge_assignments, type: :binary_id, on_delete: :delete_all),
50
          null: false
51
52
      add :token_digest, :binary, null: false
53
      add :last_four, :string, null: false
54
      add :repository_id, :binary_id, null: false
55
      add :branch, :string, null: false
56
      add :expires_at, :utc_datetime_usec, null: false
57
      add :revoked_at, :utc_datetime_usec
58
      timestamps(type: :utc_datetime_usec)
59
    end
60
61
    create unique_index(:forge_assignment_credentials, [:token_digest])
62
    create unique_index(:forge_assignment_credentials, [:assignment_id])
63
    create index(:forge_assignment_credentials, [:expires_at])
64
65
    create constraint(:forge_assignment_credentials, :forge_assignment_credentials_digest_length,
66
             check: "octet_length(token_digest) = 32"
67
           )
68
  end
69
end
priv/repo/migrations/20260823141008_create_agent_box_control_grants.exs added +26

@@ -0,0 +1,26 @@

1
defmodule OpenAgents.Repo.Migrations.CreateAgentBoxControlGrants do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:agent_box_control_grants, primary_key: false) do
6
      add :id, :binary_id, primary_key: true
7
      add :agent_id, references(:agents, type: :binary_id, on_delete: :delete_all), null: false
8
      add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
9
      add :granted_by_id, references(:users, type: :binary_id, on_delete: :restrict), null: false
10
      add :scope, :string, null: false, default: "box:control"
11
      add :granted_at, :utc_datetime_usec, null: false
12
      add :revoked_at, :utc_datetime_usec
13
      timestamps(type: :utc_datetime_usec)
14
    end
15
16
    create unique_index(:agent_box_control_grants, [:agent_id, :scope],
17
             where: "revoked_at IS NULL"
18
           )
19
20
    create index(:agent_box_control_grants, [:user_id])
21
22
    create constraint(:agent_box_control_grants, :agent_box_control_grants_scope_check,
23
             check: "scope = 'box:control'"
24
           )
25
  end
26
end
priv/repo/migrations/20260823141449_add_repository_protected_branches.exs added +9

@@ -0,0 +1,9 @@

1
defmodule OpenAgents.Repo.Migrations.AddRepositoryProtectedBranches do
2
  use Ecto.Migration
3
4
  def change do
5
    alter table(:repositories) do
6
      add :protected_branches, {:array, :string}, null: false, default: []
7
    end
8
  end
9
end
test/openagents/box_client_runs_test.exs modified +41

@@ -24,6 +24,47 @@ defmodule OpenAgents.BoxClientRunsTest do

24 24
25 25
  test "dispatch uses one detached mkdir-and-launch command", do: begin_dispatch()
26 26
27
  test "assignment dispatch keeps credential setup outside the child shell" do
28
    token = "oa_assignment_11111111-1111-4111-8111-111111111111.secret-token"
29
    run_directory = "/home/box-user/.openagents/box-runs"
30
31
    Req.Test.expect(__MODULE__, fn request ->
32
      command = request.body_params["command"]
33
34
      assert request.body_params["env"] == %{"OPENAGENTS_FORGE_TOKEN" => token}
35
      refute command =~ token
36
37
      assert command =~ "root=#{run_directory}"
38
      assert command =~ ~s(> "$root/forge-credential")
39
      assert command =~ ~s(git config --file="$root/gitconfig")
40
      assert command =~ ~s(env GIT_CONFIG_GLOBAL="$root/gitconfig")
41
42
      assert command =~ "unset OPENAGENTS_FORGE_TOKEN"
43
      refute command =~ "credential_setup"
44
45
      script_path =
46
        Path.join(
47
          System.tmp_dir!(),
48
          "assignment-dispatch-#{System.unique_integer([:positive])}.sh"
49
        )
50
51
      on_exit(fn -> File.rm(script_path) end)
52
      assert :ok = File.write(script_path, command)
53
      assert {_output, 0} = System.cmd("sh", ["-n", script_path])
54
55
      Req.Test.json(request, %{"stdout" => "4242\n"})
56
    end)
57
58
    assert {:ok, 4242} =
59
             Client.dispatch_run(
60
               "bx_8bhkse3n",
61
               "11111111-1111-4111-8111-111111111111",
62
               "git push https://openagents.com/repo.git",
63
               run_directory,
64
               token
65
             )
66
  end
67
27 68
  test "poll decodes bounded output from a recorded offset" do
28 69
    encoded = Base.encode64("hello")
29 70
test/openagents/box_runs_test.exs modified +14 -1

@@ -285,6 +285,8 @@ defmodule OpenAgents.BoxRunsTest do

285 285
    assert requested.cancellation_requested_at
286 286
287 287
    Req.Test.expect(__MODULE__, fn request ->
288
      assert request.body_params["command"] =~ "forge-credential"
289
      assert request.body_params["command"] =~ "gitconfig"
288 290
      Req.Test.transport_error(request, :econnrefused)
289 291
    end)
290 292

@@ -292,6 +294,9 @@ defmodule OpenAgents.BoxRunsTest do

292 294
293 295
    Req.Test.expect(__MODULE__, fn request ->
294 296
      assert request.body_params["command"] =~ "kill"
297
      assert request.body_params["command"] =~ "rm -f"
298
      assert request.body_params["command"] =~ "forge-credential"
299
      assert request.body_params["command"] =~ "gitconfig"
295 300
      Req.Test.json(request, %{"stdout" => "OA_CANCELLED=1\n"})
296 301
    end)
297 302

@@ -302,7 +307,12 @@ defmodule OpenAgents.BoxRunsTest do

302 307
    assert cancelled.state == "cancelled"
303 308
    assert cancelled.cancellation_requested_at
304 309
    assert cancelled.cancellation_effective_at
305
    assert cancelled.cancellation_effective_at >= cancelled.cancellation_requested_at
310
311
    assert DateTime.compare(
312
             cancelled.cancellation_effective_at,
313
             cancelled.cancellation_requested_at
314
           ) in [:eq, :gt]
315
306 316
    assert {:ok, later} = BoxRuns.get_run(run.conversation_id, "bx_8bhkse3n", run.id)
307 317
    assert later.state == "cancelled"
308 318
  end

@@ -312,6 +322,9 @@ defmodule OpenAgents.BoxRunsTest do

312 322
313 323
    Req.Test.expect(__MODULE__, fn request ->
314 324
      assert request.body_params["command"] =~ "kill"
325
      assert request.body_params["command"] =~ "rm -f"
326
      assert request.body_params["command"] =~ "forge-credential"
327
      assert request.body_params["command"] =~ "gitconfig"
315 328
      Req.Test.json(request, %{"stdout" => "OA_CANCELLED=1\n"})
316 329
    end)
317 330
test/openagents/forge/assignment_test.exs added +176

@@ -0,0 +1,176 @@

1
defmodule OpenAgents.Forge.AssignmentTest do
2
  use OpenAgents.DataCase, async: true
3
4
  import Ecto.Query
5
  import OpenAgents.AccountsFixtures
6
7
  alias OpenAgents.Forge.{Assignment, AssignmentCredential}
8
  alias OpenAgents.Accounts
9
  alias OpenAgents.Agents
10
  alias OpenAgents.Box.ConversationBox
11
  alias OpenAgents.Conversations
12
  alias OpenAgents.Forge.GitReceivePack
13
  alias OpenAgents.Issues
14
  alias OpenAgents.Issues.Comment
15
  alias OpenAgents.Repo
16
  alias OpenAgents.Repositories
17
18
  test "assignment changesets require a branch and lifecycle timestamps" do
19
    now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
20
21
    changeset =
22
      Assignment.changeset(%Assignment{}, %{
23
        conversation_box_id: Ecto.UUID.generate(),
24
        repository_id: Ecto.UUID.generate(),
25
        issue_id: 1,
26
        requesting_principal: %{"type" => "agent", "id" => Ecto.UUID.generate()},
27
        branch: "agent/issue-1",
28
        deadline_at: now,
29
        admitted_at: now
30
      })
31
32
    assert changeset.valid?
33
    assignment = Ecto.Changeset.apply_changes(changeset)
34
    assert assignment.state == "admitted"
35
    refute Assignment.terminal?(assignment)
36
  end
37
38
  test "assignment credentials keep only a digest and metadata" do
39
    digest = :crypto.hash(:sha256, "oa_assignment_secret")
40
41
    changeset =
42
      AssignmentCredential.changeset(%AssignmentCredential{}, %{
43
        assignment_id: Ecto.UUID.generate(),
44
        token_digest: digest,
45
        last_four: "cret",
46
        repository_id: Ecto.UUID.generate(),
47
        branch: "agent/issue-1",
48
        expires_at: DateTime.add(DateTime.utc_now(), 60, :second)
49
      })
50
51
    assert changeset.valid?
52
    credential = Ecto.Changeset.apply_changes(changeset)
53
    assert credential.token_digest == digest
54
    refute Map.has_key?(Map.from_struct(credential), :token)
55
    refute Map.has_key?(Map.from_struct(credential), :plaintext)
56
  end
57
58
  test "invalid assignment credentials are refused" do
59
    assert {:error, :invalid_assignment_credential} =
60
             OpenAgents.Forge.Assignments.authenticate("oa_assignment_not-a-credential")
61
  end
62
63
  test "Box control grants require a linked human and are revocable" do
64
    {:ok, agent, _token} =
65
      Agents.register(%{
66
        handle: "assignment-grant-agent",
67
        display_name: "Assignment grant agent",
68
        registration_ip: "192.0.2.55"
69
      })
70
71
    {:ok, user} =
72
      Accounts.upsert_github_user(%{
73
        github_id: 9_991_055,
74
        github_login: "assignment-grant-user",
75
        github_avatar_url: "https://avatars.githubusercontent.com/u/9991055?v=4"
76
      })
77
78
    {:ok, link} = Agents.request_link(agent, user)
79
    {:ok, _link} = Agents.accept_link(user, link.id)
80
    refute Agents.box_control_granted?(agent)
81
82
    assert {:ok, grant} = Agents.grant_box_control(user, agent)
83
    assert grant.granted_by_id == user.id
84
    assert Agents.box_control_granted?(agent)
85
86
    assert {:ok, revoked} = Agents.revoke_box_control(user, agent)
87
    assert revoked.revoked_at
88
    refute Agents.box_control_granted?(agent)
89
  end
90
91
  test "receive-pack parsing accepts shallow lines and trailing newlines" do
92
    shallow = pkt_line("shallow " <> String.duplicate("a", 40) <> "\n")
93
94
    update =
95
      pkt_line(
96
        String.duplicate("0", 40) <>
97
          " " <> String.duplicate("b", 40) <> " refs/heads/agent/issue-1\n"
98
      )
99
100
    assert {:ok, ["refs/heads/agent/issue-1"]} =
101
             GitReceivePack.refs(shallow <> update <> "0000")
102
103
    assert {:error, :invalid_receive_pack} =
104
             GitReceivePack.refs(pkt_line("malformed\n") <> "0000")
105
  end
106
107
  test "assignment claim and release are visible on the issue timeline" do
108
    user = repository_user_fixture("assignment-timeline")
109
110
    {:ok, repository} =
111
      Repositories.create_repository(%{
112
        owner: "AssignmentTimeline",
113
        name: "assignment-timeline-#{System.unique_integer([:positive])}",
114
        visibility: "private"
115
      })
116
117
    {:ok, issue} = Issues.create_issue(repository, %{title: "Timeline assignment"})
118
    {:ok, conversation} = Conversations.ensure_conversation("assignment-timeline")
119
120
    {:ok, box} =
121
      %ConversationBox{}
122
      |> ConversationBox.changeset(%{
123
        conversation_id: conversation.id,
124
        box_id: "bx_assignment_timeline",
125
        state: "ready",
126
        setup_status: "done"
127
      })
128
      |> Repo.insert()
129
130
    now = DateTime.utc_now() |> DateTime.truncate(:microsecond)
131
132
    assignment =
133
      %Assignment{}
134
      |> Assignment.changeset(%{
135
        conversation_box_id: box.id,
136
        repository_id: repository.id,
137
        issue_id: issue.id,
138
        requesting_principal: %{
139
          "type" => "user",
140
          "id" => user.id,
141
          "actor_type" => "user",
142
          "actor_id" => user.id
143
        },
144
        branch: "agent/timeline",
145
        deadline_at: DateTime.add(now, 60, :second),
146
        admitted_at: now
147
      })
148
      |> Repo.insert!()
149
150
    assert {:ok, _comment} = OpenAgents.Forge.Assignments.report_claim(assignment)
151
152
    assert {:ok, _finished} =
153
             OpenAgents.Forge.Assignments.finish(assignment, "failed", nil, "test")
154
155
    bodies =
156
      Repo.all(
157
        from comment in Comment,
158
          where: comment.issue_id == ^issue.id,
159
          order_by: [asc: comment.created_at],
160
          select: comment.body
161
      )
162
163
    assert Enum.any?(bodies, &String.contains?(&1, "Box assignment claimed."))
164
    assert Enum.any?(bodies, &String.contains?(&1, "claim released."))
165
    assert Repo.get!(OpenAgents.Issues.Issue, issue.id).state == "open"
166
  end
167
168
  defp pkt_line(line) do
169
    line
170
    |> byte_size()
171
    |> Kernel.+(4)
172
    |> Integer.to_string(16)
173
    |> String.pad_leading(4, "0")
174
    |> Kernel.<>(line)
175
  end
176
end

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