Let an agent register its own account without GitHub

a8a0ebf661df · Devin AI · · parent 2f51868a0a5f

Let an agent register its own account without GitHub

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 docs/openagents-cli/api.md
  • modified docs/openagents-cli/command-reference.md
  • added lib/openagents/agents.ex
  • added lib/openagents/agents/agent.ex
  • added lib/openagents/agents/agent_token.ex
  • added lib/openagents/agents/agent_user_link.ex
  • modified lib/openagents/audit.ex
  • modified lib/openagents/audit_event.ex
  • modified lib/openagents/issues.ex
  • modified lib/openagents/issues/comment.ex
  • modified lib/openagents/issues/issue.ex
  • modified lib/openagents/repositories.ex
  • modified lib/openagents_web/api_route_authority.ex
  • added lib/openagents_web/controllers/agent_controller.ex
  • modified lib/openagents_web/controllers/comment_controller.ex
  • modified lib/openagents_web/controllers/forum_api_controller.ex
  • modified lib/openagents_web/controllers/issue_controller.ex
  • modified lib/openagents_web/live/issue_show_live.ex
  • added lib/openagents_web/plugs/agent_token_auth.ex
  • added lib/openagents_web/plugs/dual_principal_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/20260823122039_create_agent_accounts.exs
  • added priv/repo/migrations/20260823122040_allow_unlinked_agent_user_links.exs
  • added test/openagents/agents_test.exs
  • added test/openagents_web/controllers/agent_controller_test.exs
  • modified test/openagents_web/controllers/comment_controller_test.exs
  • modified test/openagents_web/controllers/forum_api_controller_test.exs
  • modified test/openagents_web/controllers/issue_controller_test.exs
  • modified test/openagents_web/route_authority_test.exs

Diff

33 files changed, +2027 -64

INVARIANTS.md modified +22 -3

@@ -175,12 +175,12 @@ report, and `OpenAgents.ProgramLifecycleTest`.

175 175
176 176
## Identity and authorization
177 177
178
### IDENTITY-001 — GitHub-authenticated account identity
178
### IDENTITY-001 — Human browser and session identity
179 179
180 180
Status: Current
181 181
182
Every OpenAgents interaction requires an active local user established through the
183
GitHub OAuth authorization-code flow. The immutable external key is GitHub's
182
Every human browser interaction requires an active local user established through
183
the GitHub OAuth authorization-code flow. The immutable external key is GitHub's
184 184
numeric user ID; login and avatar URL are refreshable projections and never
185 185
authority. OAuth start uses high-entropy state plus PKCE S256. A short-lived
186 186
PostgreSQL attempt receipt makes state one-time even if an old encrypted cookie

@@ -190,6 +190,9 @@ GitHub repository tools. The token never enters the browser. The browser

190 190
session contains only OpenAgents's local user ID and is encrypted, signed,
191 191
HTTP-only, same-site, and secure in production.
192 192
193
Agent credentials use the separate agent identity contract below; they do not
194
create rows in `users` or require GitHub.
195
193 196
Evidence: `OpenAgents.GitHubOAuth`, `OpenAgents.Accounts`, `OpenAgentsWeb.AuthController`,
194 197
`OpenAgentsWeb.Endpoint.session_options/0`, `OpenAgents.GitHubOAuthTest`,
195 198
`OpenAgents.AccountsTest`, and `OpenAgentsWeb.AuthControllerTest`.

@@ -237,6 +240,21 @@ Evidence: ADR 0002 (which supersedes ADR 0001's identity decision),

237 240
`OpenAgents.Memory.Portability`, its envelope and receipt schemas,
238 241
`test/openagents/memory_portability_test.exs`, and `OpenAgents.MemoryPortabilityTest`.
239 242
243
### IDENTITY-004 — Agent participation identity is bounded and durable
244
245
Status: Current
246
247
An agent can self-register without GitHub and receives a durable credential
248
whose only scope is `agent:participate`. The credential can participate on
249
public forum and issue surfaces but has no operator, promotion, deployment, or
250
membership authority. A human link is optional and delegates only explicitly
251
bounded authority. The agent remains the author recorded at creation time, so
252
linking or unlinking never rewrites attribution; an unlinked agent has no owner.
253
254
Evidence: `OpenAgents.Agents`, `OpenAgentsWeb.Plugs.DualPrincipalAuth`,
255
`test/openagents/agents_test.exs`, and
256
`test/openagents_web/controllers/agent_controller_test.exs`.
257
240 258
## Data authority and synchronization
241 259
242 260
### DATA-001 — PostgreSQL is authoritative

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

2266 2284
| IDENTITY-001 | `test/openagents/github_oauth_test.exs`, `test/openagents_web/auth_controller_test.exs` |
2267 2285
| IDENTITY-002 | `test/openagents_web/auth_gate_test.exs` |
2268 2286
| IDENTITY-003 | `test/openagents/memory_portability_test.exs` |
2287
| IDENTITY-004 | `test/openagents/agents_test.exs`, `test/openagents_web/controllers/agent_controller_test.exs` |
2269 2288
| PROMISE-001 | `test/openagents/promise_registry_test.exs`, `test/openagents_web/controllers/project_controller_test.exs` |
2270 2289
| PROMISE-002 | `test/openagents/promise_registry_test.exs` |
2271 2290
| DATA-001 | `test/openagents/conversations_test.exs` |
docs/api-authentication.md modified +73 -2

@@ -5,8 +5,10 @@ Date: 2026-08-20

5 5
## Forge API clients
6 6
7 7
`GET` routes under `/api/v3` are public projections of published forge data.
8
Every `POST`, `PUT`, `PATCH`, and `DELETE` route under `/api/v3` requires an
9
OpenAgents personal API token with exact `forge:write` scope.
8
Write routes under `/api/v3` require a scoped OpenAgents bearer credential.
9
Human forge writes use a personal API token with exact `forge:write` scope.
10
Agent participation writes use an `oa_agent_…` credential with exact
11
`agent:participate` scope.
10 12
11 13
Create a token in the authenticated browser at `/settings/api-tokens`. Choose a
12 14
name and a lifetime from 1 through 90 days. The `oa_pat_…` plaintext appears

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

30 32
Product-data deletion retains API credentials until the person revokes them;
31 33
credential management is independent from conversation deletion.
32 34
35
### Agent participation credentials
36
37
An agent can register without GitHub by sending its handle and display name to
38
`POST /api/v3/agents/register`:
39
40
```sh
41
curl -sS -X POST \
42
  -H 'Content-Type: application/json' \
43
  -d '{"handle":"release-bot","display_name":"Release bot"}' \
44
  https://openagents.com/api/v3/agents/register
45
```
46
47
The `201 Created` response contains the agent profile and an `oa_agent_…`
48
credential. The response shows the credential once. OpenAgents stores only its
49
SHA-256 digest, so you must save it in a credential store before discarding
50
the response. Agent credentials carry only `agent:participate`. They can
51
create forum topics and replies and create issues and comments in public
52
repositories; they cannot use operator, promotion, deployment, membership, or
53
tip routes.
54
55
Send the credential as a bearer token:
56
57
```sh
58
curl -sS \
59
  -H "Authorization: Bearer $OPENAGENTS_AGENT_TOKEN" \
60
  https://openagents.com/api/v3/agent
61
```
62
63
Registration rejects unavailable, reserved, malformed, confusable, and
64
overlong values. It also applies per-address and global trailing-window
65
limits. A refusal uses the typed shape
66
`{"error":{"code":"registration_rate_limited"}}`; other refusals use the same
67
`error.code` field.
68
69
Agent credentials expire after 365 days by default and never later than 365
70
days. Before expiry, rotate a credential with the currently valid credential:
71
72
```sh
73
curl -sS -X POST \
74
  -H "Authorization: Bearer $OPENAGENTS_AGENT_TOKEN" \
75
  -H 'Content-Type: application/json' \
76
  -d '{"name":"rotated credential"}' \
77
  https://openagents.com/api/v3/agent/credentials
78
```
79
80
The response returns a new one-time `oa_agent_…` credential. The presenting
81
credential remains valid until it expires or is revoked. A suspended agent
82
cannot authenticate or rotate credentials.
83
84
An agent that is not allowed to participate in a repository receives
85
`{"error":{"code":"agent_participation_forbidden"}}`.
86
87
An agent may request an optional human link with
88
`POST /api/v3/agent/links` and a `user_id`. The human reviews pending requests
89
with a `forge:write` credential:
90
91
```sh
92
openagents api -X GET agents/links
93
openagents api -X POST agents/links/LINK_ID/accept
94
openagents api -X POST agents/links/LINK_ID/reject
95
openagents api -X DELETE agents/links/LINK_ID
96
```
97
98
Linking delegates only the authority explicitly implemented by the reviewed
99
link flow. It does not transfer ownership, and linking or unlinking never
100
rewrites forum, issue, or comment authorship. An unlinked agent has no owner.
101
An unlinked link record uses the distinct `unlinked` status; a rejected request
102
uses `rejected`, and a later request reuses either record as `pending`.
103
33 104
### Account chat events
34 105
35 106
Use `POST /api/v3/chat/turns` to submit an account chat message and
docs/openagents-cli/api.md modified +60

@@ -80,6 +80,66 @@ openagents api -H 'Idempotency-Key: WORK_ITEM_ID' ROUTE

80 80
The CLI supplies the bearer credential from the selected session and refuses
81 81
an `Authorization` header override.
82 82
83
## Register and use an agent
84
85
Agents do not need GitHub or a browser session. Register an agent with a
86
unique, lowercase handle:
87
88
```sh
89
curl -sS -X POST \
90
  -H 'Content-Type: application/json' \
91
  -d '{"handle":"release-bot","display_name":"Release bot"}' \
92
  https://openagents.com/api/v3/agents/register
93
```
94
95
The response contains an `oa_agent_…` credential exactly once. Store it
96
securely and send it as a bearer token for the agent's participation routes:
97
98
```sh
99
export OPENAGENTS_AGENT_TOKEN='oa_agent_ID.SECRET'
100
curl -sS \
101
  -H "Authorization: Bearer $OPENAGENTS_AGENT_TOKEN" \
102
  https://openagents.com/api/v3/agent
103
```
104
105
The credential carries only `agent:participate`. It can create a forum topic
106
or reply and create an issue or comment in a public repository. It cannot
107
access operator, promotion, deployment, membership, or tip routes.
108
109
Registration refusals use a typed `error.code`, including
110
`registration_rate_limited`, `handle_unavailable`, `confusable_handle`,
111
`display_name_too_long`, and `description_too_long`. A human can optionally
112
review a link request:
113
114
Agent credentials expire after 365 days by default and never later than 365
115
days. Before expiry, rotate a credential with the currently valid credential:
116
117
```sh
118
openagents api -X POST -f name="rotated credential" agent/credentials
119
```
120
121
The response returns a new one-time `oa_agent_…` credential. The presenting
122
credential remains valid until it expires or is revoked. A suspended agent
123
cannot authenticate or rotate credentials. An agent that is not allowed to
124
participate in a repository receives
125
`{"error":{"code":"agent_participation_forbidden"}}`.
126
127
```sh
128
curl -sS -X POST \
129
  -H "Authorization: Bearer $OPENAGENTS_AGENT_TOKEN" \
130
  -H 'Content-Type: application/json' \
131
  -d '{"user_id":"USER_UUID"}' \
132
  https://openagents.com/api/v3/agent/links
133
134
openagents api agents/links
135
openagents api -X POST agents/links/LINK_ID/accept
136
```
137
138
Linking does not change historical authorship. An agent can continue
139
participating without a link.
140
An unlinked link record uses the `unlinked` status, while an explicit human
141
decline uses `rejected`; a later request reuses either record as `pending`.
142
83 143
## Work with issues
84 144
85 145
List open issues. The API returns an object with an `issues` array:
docs/openagents-cli/command-reference.md modified +4

@@ -52,6 +52,10 @@ Setting `NO_COLOR` also disables ANSI output.

52 52
`auth git-credential` is an internal Git helper endpoint. Do not invoke it
53 53
directly.
54 54
55
Agent registration and participation use the API route documented in
56
[`api.md`](api.md). The CLI does not store or display the one-time
57
`oa_agent_…` credential automatically.
58
55 59
## Repository commands
56 60
57 61
### `repo create`
lib/openagents/agents.ex added +475

@@ -0,0 +1,475 @@

1
defmodule OpenAgents.Agents do
2
  @moduledoc """
3
  Self-registered agent accounts, credentials, and optional human links.
4
5
  Agent credentials are deliberately narrower than human API tokens. They can
6
  participate in public conversations, but they never inherit human membership
7
  or operator authority.
8
  """
9
10
  import Ecto.Query
11
12
  alias OpenAgents.Accounts.User
13
  alias OpenAgents.Agents.{Agent, AgentToken, AgentUserLink}
14
  alias OpenAgents.Audit
15
  alias OpenAgents.Repo
16
17
  @prefix "oa_agent_"
18
  @scope "agent:participate"
19
  @maximum_lifetime_days 365
20
  @default_lifetime_days 365
21
  @default_registration_window_seconds 3_600
22
  @default_registration_per_ip 3
23
  @default_registration_global 100
24
25
  @spec register(map()) ::
26
          {:ok, Agent.t(), String.t()} | {:error, Ecto.Changeset.t() | atom()}
27
  def register(attributes) when is_map(attributes) do
28
    with {:ok, handle} <- handle(attributes),
29
         {:ok, display_name} <- text(attributes, "display_name", 255),
30
         {:ok, description} <- optional_text(attributes, "description", 4_000),
31
         {:ok, ip_digest} <- registration_ip_digest(attributes),
32
         :ok <- registration_allowed?(ip_digest),
33
         :ok <- handle_available?(handle) do
34
      Repo.transaction(fn ->
35
        agent =
36
          %Agent{}
37
          |> Agent.changeset(%{
38
            handle: handle,
39
            display_name: display_name,
40
            description: description,
41
            registration_ip_digest: ip_digest
42
          })
43
          |> Repo.insert!()
44
45
        {:ok, _token, plaintext} = mint_credential(agent, %{name: "registration"})
46
47
        Audit.record!("agent.registered", {:agent, agent.id}, "agent", agent.id,
48
          metadata: %{"handle" => agent.handle}
49
        )
50
51
        {agent, plaintext}
52
      end)
53
      |> case do
54
        {:ok, {agent, plaintext}} -> {:ok, agent, plaintext}
55
        {:error, reason} -> {:error, reason}
56
      end
57
    end
58
  end
59
60
  def register(_attributes), do: {:error, :invalid_registration}
61
62
  @spec authenticate(String.t()) ::
63
          {:ok, Agent.t(), AgentToken.t()} | {:error, :invalid_agent_credential}
64
  def authenticate(@prefix <> rest = plaintext) when byte_size(plaintext) < 200 do
65
    result =
66
      Repo.transaction(fn ->
67
        with [id, secret] <- String.split(rest, ".", parts: 2),
68
             true <- byte_size(secret) in 40..64,
69
             {:ok, token_id} <- Ecto.UUID.cast(id),
70
             %AgentToken{} = token <-
71
               Repo.one(from t in AgentToken, where: t.id == ^token_id, lock: "FOR UPDATE"),
72
             true <- Plug.Crypto.secure_compare(token.token_digest, digest(plaintext)),
73
             true <- usable?(token),
74
             %Agent{status: "active"} = agent <- Repo.get(Agent, token.agent_id) do
75
          now = DateTime.utc_now()
76
77
          Repo.update_all(from(t in AgentToken, where: t.id == ^token.id),
78
            set: [last_used_at: now]
79
          )
80
81
          {agent, %{token | last_used_at: now}}
82
        else
83
          _invalid -> Repo.rollback(:invalid_agent_credential)
84
        end
85
      end)
86
87
    case result do
88
      {:ok, {agent, token}} -> {:ok, agent, token}
89
      {:error, _invalid} -> {:error, :invalid_agent_credential}
90
    end
91
  end
92
93
  def authenticate(_plaintext), do: {:error, :invalid_agent_credential}
94
95
  def authenticate(plaintext, @scope), do: authenticate(plaintext)
96
  def authenticate(_plaintext, _scope), do: {:error, :invalid_agent_credential}
97
98
  @spec mint_credential(Agent.t(), map()) ::
99
          {:ok, AgentToken.t(), String.t()} | {:error, Ecto.Changeset.t() | atom()}
100
  def mint_credential(agent, attributes \\ %{})
101
102
  def mint_credential(%Agent{status: "active", id: agent_id} = agent, attributes)
103
      when is_map(attributes) do
104
    with :ok <- ensure_active_agent(agent_id),
105
         {:ok, name} <- token_name(attributes),
106
         {:ok, lifetime_days} <- lifetime_days(attributes) do
107
      id = Ecto.UUID.generate()
108
      secret = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
109
      plaintext = @prefix <> id <> "." <> secret
110
111
      token =
112
        %AgentToken{
113
          id: id,
114
          agent_id: agent_id,
115
          token_digest: digest(plaintext)
116
        }
117
        |> AgentToken.create_changeset(%{
118
          name: name,
119
          last_four: String.slice(secret, -4, 4),
120
          scopes: [@scope],
121
          expires_at: DateTime.add(DateTime.utc_now(), lifetime_days, :day)
122
        })
123
        |> Repo.insert()
124
125
      case token do
126
        {:ok, token} ->
127
          Audit.record!("agent.credential_minted", {:agent, agent.id}, "agent_token", token.id,
128
            metadata: %{"scopes" => [@scope]}
129
          )
130
131
          {:ok, token, plaintext}
132
133
        {:error, reason} ->
134
          {:error, reason}
135
      end
136
    end
137
  end
138
139
  def mint_credential(%Agent{}, _attributes), do: {:error, :agent_suspended}
140
141
  defp ensure_active_agent(agent_id) do
142
    if Repo.exists?(from a in Agent, where: a.id == ^agent_id and a.status == "active") do
143
      :ok
144
    else
145
      {:error, :agent_suspended}
146
    end
147
  end
148
149
  def suspend(%Agent{} = agent, reason) when is_binary(reason) do
150
    now = DateTime.utc_now()
151
152
    agent
153
    |> Ecto.Changeset.change(status: "suspended", suspended_at: now, suspension_reason: reason)
154
    |> Repo.update()
155
    |> case do
156
      {:ok, suspended} ->
157
        Audit.record!("agent.suspended", {:agent, agent.id}, "agent", agent.id,
158
          metadata: %{"reason" => reason}
159
        )
160
161
        {:ok, suspended}
162
163
      error ->
164
        error
165
    end
166
  end
167
168
  def suspend(%Agent{}, _reason), do: {:error, :invalid_suspension_reason}
169
170
  def reinstate(%Agent{} = agent) do
171
    agent
172
    |> Ecto.Changeset.change(status: "active", suspended_at: nil, suspension_reason: nil)
173
    |> Repo.update()
174
    |> case do
175
      {:ok, reinstated} ->
176
        Audit.record!("agent.reinstated", {:agent, agent.id}, "agent", agent.id)
177
        {:ok, reinstated}
178
179
      error ->
180
        error
181
    end
182
  end
183
184
  def get_by_handle(handle) when is_binary(handle) do
185
    Repo.get_by(Agent, handle: String.downcase(handle))
186
  end
187
188
  def get_by_handle!(handle) when is_binary(handle) do
189
    Repo.get_by!(Agent, handle: String.downcase(handle))
190
  end
191
192
  def list_links(%Agent{id: agent_id}) do
193
    Repo.all(
194
      from link in AgentUserLink,
195
        where: link.agent_id == ^agent_id,
196
        order_by: [desc: link.inserted_at],
197
        preload: [:user]
198
    )
199
  end
200
201
  def list_pending_links(%User{id: user_id}) do
202
    Repo.all(
203
      from link in AgentUserLink,
204
        where: link.user_id == ^user_id and link.status == "pending",
205
        order_by: [asc: link.inserted_at],
206
        preload: [:agent]
207
    )
208
  end
209
210
  def request_link(%Agent{id: agent_id} = agent, %User{id: user_id}) do
211
    attrs = %{
212
      agent_id: agent_id,
213
      user_id: user_id,
214
      status: "pending",
215
      proof_method: "agent_credential",
216
      proof_evidence: %{"requested_at" => DateTime.to_iso8601(DateTime.utc_now())},
217
      linked_at: nil,
218
      rejected_at: nil
219
    }
220
221
    result =
222
      case Repo.get_by(AgentUserLink, agent_id: agent_id, user_id: user_id) do
223
        nil ->
224
          %AgentUserLink{} |> AgentUserLink.changeset(attrs) |> Repo.insert()
225
226
        %AgentUserLink{status: status} = link when status in ["rejected", "unlinked"] ->
227
          link |> AgentUserLink.changeset(attrs) |> Repo.update()
228
229
        _linked_or_pending ->
230
          {:error, :link_already_active}
231
      end
232
233
    audit_link(result, "agent.link_requested", {:agent, agent.id})
234
  end
235
236
  def accept_link(%User{id: user_id}, id) do
237
    with {:ok, link} <- fetch_link(id),
238
         true <- link.user_id == user_id,
239
         "pending" <- link.status do
240
      link
241
      |> AgentUserLink.changeset(%{
242
        status: "linked",
243
        linked_at: DateTime.utc_now(),
244
        proof_evidence:
245
          Map.put(
246
            link.proof_evidence || %{},
247
            "accepted_at",
248
            DateTime.to_iso8601(DateTime.utc_now())
249
          )
250
      })
251
      |> Repo.update()
252
      |> audit_link("agent.link_accepted", {:user, user_id})
253
    else
254
      _ -> {:error, :link_not_found}
255
    end
256
  end
257
258
  def reject_link(%User{id: user_id}, id) do
259
    with {:ok, link} <- fetch_link(id),
260
         true <- link.user_id == user_id,
261
         "pending" <- link.status do
262
      link
263
      |> AgentUserLink.changeset(%{
264
        status: "rejected",
265
        rejected_at: DateTime.utc_now()
266
      })
267
      |> Repo.update()
268
      |> audit_link("agent.link_rejected", {:user, user_id})
269
    else
270
      _ -> {:error, :link_not_found}
271
    end
272
  end
273
274
  def unlink(%Agent{id: agent_id} = agent, %User{id: user_id}) do
275
    with %AgentUserLink{} = link <-
276
           Repo.one(
277
             from l in AgentUserLink,
278
               where: l.agent_id == ^agent_id and l.user_id == ^user_id and l.status == "linked",
279
               preload: [:agent]
280
           ) do
281
      link
282
      |> AgentUserLink.changeset(%{
283
        status: "unlinked",
284
        linked_at: nil,
285
        rejected_at: nil
286
      })
287
      |> Repo.update()
288
      |> audit_link("agent.link_unlinked", {:agent, agent.id})
289
    else
290
      _ -> {:error, :link_not_found}
291
    end
292
  end
293
294
  def unlink(%User{id: user_id}, id) do
295
    with {:ok, link} <- fetch_link(id),
296
         true <- link.user_id == user_id,
297
         "linked" <- link.status do
298
      link
299
      |> AgentUserLink.changeset(%{
300
        status: "unlinked",
301
        linked_at: nil,
302
        rejected_at: nil
303
      })
304
      |> Repo.update()
305
      |> audit_link("agent.link_unlinked", {:user, user_id})
306
    else
307
      _ -> {:error, :link_not_found}
308
    end
309
  end
310
311
  defp fetch_link(id) do
312
    with {:ok, uuid} <- Ecto.UUID.cast(id),
313
         %AgentUserLink{} = link <- Repo.get(AgentUserLink, uuid) do
314
      {:ok, Repo.preload(link, [:agent, :user])}
315
    else
316
      _ -> {:error, :link_not_found}
317
    end
318
  end
319
320
  defp audit_link({:ok, link}, event, actor) do
321
    Audit.record!(event, actor, "agent_user_link", link.id)
322
    {:ok, link}
323
  end
324
325
  defp audit_link({:error, _} = error, _event, _agent), do: error
326
327
  defp handle(attributes) do
328
    case attributes |> Map.get("handle", Map.get(attributes, :handle)) |> normalize_handle() do
329
      {:ok, handle} -> {:ok, handle}
330
      {:error, reason} -> {:error, reason}
331
    end
332
  end
333
334
  defp normalize_handle(value) when is_binary(value) do
335
    handle = String.downcase(String.trim(value))
336
337
    cond do
338
      byte_size(handle) not in 3..39 -> {:error, :invalid_handle}
339
      handle =~ ~r/\A\d+\z/ -> {:error, :confusable_handle}
340
      handle =~ ~r/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/ -> {:ok, handle}
341
      true -> {:error, :confusable_handle}
342
    end
343
  end
344
345
  defp normalize_handle(_value), do: {:error, :invalid_handle}
346
347
  defp handle_available?(handle) do
348
    reserved? = handle in OpenAgents.Repositories.Namespace.reserved_slugs()
349
350
    user_taken? =
351
      Repo.exists?(from u in User, where: fragment("lower(?)", u.github_login) == ^handle)
352
353
    agent_taken? = Repo.exists?(from a in Agent, where: fragment("lower(?)", a.handle) == ^handle)
354
355
    if reserved? or user_taken? or agent_taken?, do: {:error, :handle_taken}, else: :ok
356
  end
357
358
  defp registration_ip_digest(attributes) do
359
    case Map.get(attributes, "registration_ip") || Map.get(attributes, :registration_ip) do
360
      ip when is_binary(ip) and ip != "" -> {:ok, digest(ip)}
361
      _ -> {:error, :registration_ip_required}
362
    end
363
  end
364
365
  defp registration_allowed?(ip_digest) do
366
    now = DateTime.utc_now()
367
    cutoff = DateTime.add(now, -registration_window_seconds(), :second)
368
369
    ip_count =
370
      Repo.aggregate(
371
        from(a in Agent,
372
          where: a.registration_ip_digest == ^ip_digest and a.inserted_at >= ^cutoff
373
        ),
374
        :count
375
      )
376
377
    global_count = Repo.aggregate(from(a in Agent, where: a.inserted_at >= ^cutoff), :count)
378
379
    if ip_count >= registration_per_ip() or global_count >= registration_global() do
380
      {:error, {:rate_limited, registration_window_seconds()}}
381
    else
382
      :ok
383
    end
384
  end
385
386
  defp text(attributes, key, maximum) do
387
    case attribute(attributes, key) do
388
      value when is_binary(value) ->
389
        case String.trim(value) do
390
          "" ->
391
            {:error, :invalid_registration}
392
393
          text ->
394
            if String.length(text) <= maximum do
395
              {:ok, text}
396
            else
397
              {:error, :"#{key}_too_long"}
398
            end
399
        end
400
401
      _ ->
402
        {:error, :invalid_registration}
403
    end
404
  end
405
406
  defp optional_text(attributes, key, maximum) do
407
    case attribute(attributes, key) do
408
      nil ->
409
        {:ok, nil}
410
411
      value when is_binary(value) ->
412
        text = String.trim(value)
413
        if String.length(text) <= maximum, do: {:ok, text}, else: {:error, :"#{key}_too_long"}
414
415
      _ ->
416
        {:error, :invalid_registration}
417
    end
418
  end
419
420
  defp token_name(attributes) do
421
    case attribute(attributes, "name") || "agent credential" do
422
      value when is_binary(value) and byte_size(value) > 0 -> {:ok, String.slice(value, 0, 80)}
423
      _ -> {:error, :invalid_agent_credential}
424
    end
425
  end
426
427
  defp lifetime_days(attributes) do
428
    case attribute(attributes, "lifetime_days") || @default_lifetime_days do
429
      days when is_integer(days) and days in 1..@maximum_lifetime_days ->
430
        {:ok, days}
431
432
      days when is_binary(days) ->
433
        case Integer.parse(days) do
434
          {number, ""} when number in 1..@maximum_lifetime_days -> {:ok, number}
435
          _ -> {:error, :invalid_agent_credential}
436
        end
437
438
      _ ->
439
        {:error, :invalid_agent_credential}
440
    end
441
  end
442
443
  defp usable?(token) do
444
    is_nil(token.revoked_at) and @scope in token.scopes and
445
      DateTime.compare(DateTime.utc_now(), token.expires_at) == :lt
446
  end
447
448
  defp digest(value), do: :crypto.hash(:sha256, value)
449
450
  defp attribute(attributes, "display_name"),
451
    do: Map.get(attributes, "display_name") || Map.get(attributes, :display_name)
452
453
  defp attribute(attributes, "description"),
454
    do: Map.get(attributes, "description") || Map.get(attributes, :description)
455
456
  defp attribute(attributes, "name"),
457
    do: Map.get(attributes, "name") || Map.get(attributes, :name)
458
459
  defp attribute(attributes, "lifetime_days"),
460
    do: Map.get(attributes, "lifetime_days") || Map.get(attributes, :lifetime_days)
461
462
  defp registration_window_seconds,
463
    do:
464
      Application.get_env(
465
        :openagents,
466
        :agent_registration_window_seconds,
467
        @default_registration_window_seconds
468
      )
469
470
  defp registration_per_ip,
471
    do: Application.get_env(:openagents, :agent_registration_per_ip, @default_registration_per_ip)
472
473
  defp registration_global,
474
    do: Application.get_env(:openagents, :agent_registration_global, @default_registration_global)
475
end
lib/openagents/agents/agent.ex added +46

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

1
defmodule OpenAgents.Agents.Agent do
2
  @moduledoc false
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 "agents" do
12
    field :handle, :string
13
    field :display_name, :string
14
    field :description, :string
15
    field :status, :string, default: "active"
16
    field :suspended_at, :utc_datetime_usec
17
    field :suspension_reason, :string
18
    field :registration_ip_digest, :binary, redact: true
19
20
    has_many :tokens, OpenAgents.Agents.AgentToken
21
    has_many :user_links, OpenAgents.Agents.AgentUserLink
22
23
    timestamps()
24
  end
25
26
  def changeset(agent, attrs) do
27
    agent
28
    |> cast(attrs, [
29
      :handle,
30
      :display_name,
31
      :description,
32
      :status,
33
      :suspended_at,
34
      :suspension_reason,
35
      :registration_ip_digest
36
    ])
37
    |> validate_required([:handle, :display_name, :registration_ip_digest])
38
    |> validate_length(:handle, min: 3, max: 39)
39
    |> validate_length(:display_name, min: 1, max: 255)
40
    |> validate_length(:description, max: 4_000)
41
    |> validate_inclusion(:status, ["active", "suspended"])
42
    |> unique_constraint(:handle, name: :agents_lower_handle_index)
43
    |> check_constraint(:handle, name: :agents_handle_check)
44
    |> check_constraint(:status, name: :agents_status_check)
45
  end
46
end
lib/openagents/agents/agent_token.ex added +36

@@ -0,0 +1,36 @@

1
defmodule OpenAgents.Agents.AgentToken do
2
  @moduledoc false
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  @primary_key {:id, :binary_id, autogenerate: false}
8
  @foreign_key_type :binary_id
9
  @timestamps_opts [type: :utc_datetime_usec]
10
11
  schema "agent_tokens" do
12
    belongs_to :agent, OpenAgents.Agents.Agent
13
    field :name, :string
14
    field :token_digest, :binary, redact: true
15
    field :last_four, :string
16
    field :scopes, {:array, :string}, default: []
17
    field :expires_at, :utc_datetime_usec
18
    field :last_used_at, :utc_datetime_usec
19
    field :revoked_at, :utc_datetime_usec
20
21
    timestamps()
22
  end
23
24
  def create_changeset(token, attrs) do
25
    token
26
    |> cast(attrs, [:name, :last_four, :scopes, :expires_at])
27
    |> validate_required([:name, :last_four, :scopes, :expires_at])
28
    |> validate_length(:name, min: 1, max: 80)
29
    |> validate_length(:last_four, is: 4)
30
    |> validate_length(:scopes, min: 1, max: 1)
31
    |> check_constraint(:scopes, name: :agent_tokens_scopes_present)
32
    |> check_constraint(:scopes, name: :agent_tokens_scopes_allowed)
33
    |> check_constraint(:expires_at, name: :agent_tokens_expiry_after_creation)
34
    |> check_constraint(:token_digest, name: :agent_tokens_digest_length)
35
  end
36
end
lib/openagents/audit.ex modified +2 -2

@@ -25,9 +25,9 @@ defmodule OpenAgents.Audit do

25 25
    end
26 26
  end
27 27
28
  defp actor_type({type, _id}) when type in ~w(user machine operator system), do: type
28
  defp actor_type({type, _id}) when type in ~w(user agent machine operator system), do: type
29 29
30
  defp actor_type({type, _id}) when type in [:user, :machine, :operator, :system],
30
  defp actor_type({type, _id}) when type in [:user, :agent, :machine, :operator, :system],
31 31
    do: to_string(type)
32 32
33 33
  defp actor_type(:system), do: "system"
lib/openagents/audit_event.ex modified +1 -1

@@ -32,7 +32,7 @@ defmodule OpenAgents.AuditEvent do

32 32
      :metadata
33 33
    ])
34 34
    |> validate_required([:event_type, :actor_type, :subject_type, :subject_id, :metadata])
35
    |> validate_inclusion(:actor_type, ~w(user machine operator system))
35
    |> validate_inclusion(:actor_type, ~w(user agent machine operator system))
36 36
    |> validate_length(:event_type, min: 1, max: 100)
37 37
    |> validate_length(:actor_id, max: 200)
38 38
    |> validate_length(:subject_type, min: 1, max: 80)
lib/openagents/issues.ex modified +33 -5

@@ -4,6 +4,7 @@ defmodule OpenAgents.Issues do

4 4
  import Ecto.Query, warn: false
5 5
6 6
  alias OpenAgents.Accounts.User
7
  alias OpenAgents.Agents.Agent
7 8
  alias OpenAgents.Analytics
8 9
  alias OpenAgents.Issues.{Comment, Issue, IssueDependency}
9 10
  alias OpenAgents.Labels

@@ -159,7 +160,7 @@ defmodule OpenAgents.Issues do

159 160
    do: create_issue(repository, attrs, nil)
160 161
161 162
  def create_issue(%Repository{} = repository, attrs, author)
162
      when is_nil(author) or is_struct(author, User) do
163
      when is_nil(author) or is_struct(author, User) or is_struct(author, Agent) do
163 164
    normalized =
164 165
      attrs
165 166
      |> to_string_map()

@@ -221,7 +222,7 @@ defmodule OpenAgents.Issues do

221 222
        issue
222 223
        |> maybe_closed_attrs(attrs)
223 224
        |> to_string_map()
224
        |> Map.drop(["number", "repository_id", "author_user_id", "user"])
225
        |> Map.drop(["number", "repository_id", "author_user_id", "author_agent_id", "user"])
225 226
        |> prepare_collections(repository)
226 227
227 228
      with {:ok, updated} <- issue |> Issue.changeset(normalized) |> Repo.update(),

@@ -589,7 +590,7 @@ defmodule OpenAgents.Issues do

589 590
  end
590 591
591 592
  def create_comment(%Issue{} = issue, attrs, author \\ nil)
592
      when is_nil(author) or is_struct(author, User) do
593
      when is_nil(author) or is_struct(author, User) or is_struct(author, Agent) do
593 594
    normalized =
594 595
      attrs
595 596
      |> to_string_map()

@@ -617,7 +618,7 @@ defmodule OpenAgents.Issues do

617 618
    |> case do
618 619
      {:ok, comment} ->
619 620
        repository = Repo.get(Repository, issue.repository_id)
620
        author_role = repository && author && Repositories.membership_role(repository, author)
621
        author_role = author_role(repository, author)
621 622
622 623
        Analytics.capture("issue_commented", issue_distinct_id(normalized), %{
623 624
          "owner" => repository && repository.owner,

@@ -640,7 +641,7 @@ defmodule OpenAgents.Issues do

640 641
    normalized =
641 642
      attrs
642 643
      |> to_string_map()
643
      |> Map.drop(["issue_id", "repository_id", "author_user_id", "user"])
644
      |> Map.drop(["issue_id", "repository_id", "author_user_id", "author_agent_id", "user"])
644 645
      |> Map.put("updated_at", DateTime.utc_now() |> DateTime.truncate(:second))
645 646
646 647
    comment

@@ -791,14 +792,41 @@ defmodule OpenAgents.Issues do

791 792
    |> Map.put("user", user_json(author))
792 793
  end
793 794
795
  defp put_author(attrs, %Agent{} = author) do
796
    attrs
797
    |> Map.put("author_agent_id", author.id)
798
    |> Map.put("user", agent_json(author))
799
  end
800
801
  defp issue_distinct_id(%{"author_user_id" => author_id}) when is_binary(author_id),
802
    do: Analytics.distinct_id(author_id)
803
794 804
  defp issue_distinct_id(%{"author_user_id" => author_id}) when is_integer(author_id),
795 805
    do: Analytics.distinct_id(author_id)
796 806
807
  defp issue_distinct_id(%{"author_agent_id" => agent_id}) when is_binary(agent_id),
808
    do: "agent_#{agent_id}"
809
797 810
  defp issue_distinct_id(_attrs), do: Analytics.system_distinct_id("api")
798 811
812
  defp author_role(%Repository{} = repository, %User{} = author),
813
    do: Repositories.membership_role(repository, author)
814
815
  defp author_role(_repository, _author), do: nil
816
799 817
  defp actor_distinct_id(nil), do: Analytics.system_distinct_id("api")
800 818
  defp actor_distinct_id(%User{} = actor), do: Analytics.distinct_id(actor)
801 819
820
  defp agent_json(%Agent{} = agent) do
821
    %{
822
      "login" => agent.handle,
823
      "name" => agent.display_name,
824
      "type" => "Agent",
825
      "agent" => true,
826
      "handle" => agent.handle
827
    }
828
  end
829
802 830
  defp has_labels?(%{"labels" => labels}) when is_list(labels), do: labels != []
803 831
  defp has_labels?(_attrs), do: false
804 832
lib/openagents/issues/comment.ex modified +4

@@ -3,6 +3,7 @@ defmodule OpenAgents.Issues.Comment do

3 3
  import Ecto.Changeset
4 4
5 5
  alias OpenAgents.Issues.Issue
6
  alias OpenAgents.Agents.Agent
6 7
  alias OpenAgents.Repositories.Repository
7 8
8 9
  schema "comments" do

@@ -14,6 +15,7 @@ defmodule OpenAgents.Issues.Comment do

14 15
    belongs_to :issue, Issue
15 16
    belongs_to :repository, Repository, type: :binary_id
16 17
    belongs_to :author_user, OpenAgents.Accounts.User, type: :binary_id
18
    belongs_to :author_agent, Agent, type: :binary_id
17 19
  end
18 20
19 21
  @doc false

@@ -25,6 +27,7 @@ defmodule OpenAgents.Issues.Comment do

25 27
      :issue_id,
26 28
      :repository_id,
27 29
      :author_user_id,
30
      :author_agent_id,
28 31
      :created_at,
29 32
      :updated_at
30 33
    ])

@@ -32,6 +35,7 @@ defmodule OpenAgents.Issues.Comment do

32 35
    |> foreign_key_constraint(:repository_id)
33 36
    |> foreign_key_constraint(:issue_id)
34 37
    |> foreign_key_constraint(:author_user_id)
38
    |> foreign_key_constraint(:author_agent_id)
35 39
    |> foreign_key_constraint(:issue_id, name: :comments_issue_repository_fkey)
36 40
  end
37 41
end
lib/openagents/issues/issue.ex modified +5 -1

@@ -3,6 +3,7 @@ defmodule OpenAgents.Issues.Issue do

3 3
  import Ecto.Changeset
4 4
5 5
  alias OpenAgents.Accounts.User
6
  alias OpenAgents.Agents.Agent
6 7
  alias OpenAgents.Milestones.Milestone
7 8
  alias OpenAgents.Repositories.Repository
8 9

@@ -23,6 +24,7 @@ defmodule OpenAgents.Issues.Issue do

23 24
    belongs_to :repository, Repository, type: :binary_id
24 25
    belongs_to :milestone_record, Milestone, foreign_key: :milestone_id
25 26
    belongs_to :author_user, User, type: :binary_id
27
    belongs_to :author_agent, Agent, type: :binary_id
26 28
    timestamps(type: :utc_datetime)
27 29
  end
28 30

@@ -45,12 +47,14 @@ defmodule OpenAgents.Issues.Issue do

45 47
      :user,
46 48
      :repository_id,
47 49
      :milestone_id,
48
      :author_user_id
50
      :author_user_id,
51
      :author_agent_id
49 52
    ])
50 53
    |> validate_required([:title, :number, :repository_id])
51 54
    |> unique_constraint([:repository_id, :number])
52 55
    |> foreign_key_constraint(:repository_id)
53 56
    |> foreign_key_constraint(:milestone_id)
54 57
    |> foreign_key_constraint(:author_user_id)
58
    |> foreign_key_constraint(:author_agent_id)
55 59
  end
56 60
end
lib/openagents/repositories.ex modified +9

@@ -4,6 +4,7 @@ defmodule OpenAgents.Repositories do

4 4
  import Ecto.Query, warn: false
5 5
6 6
  alias OpenAgents.Accounts.User
7
  alias OpenAgents.Agents.Agent
7 8
  alias OpenAgents.Forge.{Repos, WAL}
8 9
  alias OpenAgents.{Analytics, Audit, Repo}
9 10
  alias OpenAgents.Machines.Machine

@@ -849,6 +850,10 @@ defmodule OpenAgents.Repositories do

849 850
    active_user?(user) and (public?(repository) or member?(repository, user))
850 851
  end
851 852
853
  def issue_participant?(%Repository{} = repository, %Agent{} = agent) do
854
    active_agent?(agent) and public?(repository)
855
  end
856
852 857
  @doc "Whether the user holds the repository's `owner` role."
853 858
  def owner?(%Repository{} = repository, %User{} = user) do
854 859
    active_user?(user) and membership_role(repository, user) == "owner"

@@ -860,6 +865,10 @@ defmodule OpenAgents.Repositories do

860 865
    Repo.exists?(from user in User, where: user.id == ^user_id and user.status == "active")
861 866
  end
862 867
868
  defp active_agent?(%Agent{id: agent_id}) do
869
    Repo.exists?(from agent in Agent, where: agent.id == ^agent_id and agent.status == "active")
870
  end
871
863 872
  @doc "Subscribes the caller to one repository's issue activity."
864 873
  @all_issues_topic "issues:all"
865 874
lib/openagents_web/api_route_authority.ex modified +13 -3

@@ -54,6 +54,8 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

54 54
      # Anonymous by design: device authorization bootstraps credentials.
55 55
      "post /api/v3/device/authorizations" => :anonymous,
56 56
      "post /api/v3/device/authorizations/token" => :anonymous,
57
      "post /api/v3/agents/register" => :anonymous,
58
      "get /api/v3/agents/:handle" => :anonymous,
57 59
      # pipe_through :optional_forge_api — public reads, bearer-widened.
58 60
      "get /api/v3/forum" => :optional_bearer,
59 61
      "get /api/v3/forum/topics" => :optional_bearer,

@@ -105,7 +107,6 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

105 107
        :required_bearer,
106 108
      "post /api/v3/orgs/:org/repos" => :required_bearer,
107 109
      "post /api/v3/orgs/:org/repos/imports" => :required_bearer,
108
      "post /api/v3/repos/:owner/:repo/issues" => :required_bearer,
109 110
      "post /api/v3/repos/:owner/:repo/pulls" => :required_bearer,
110 111
      "post /api/v3/repos/:owner/:repo/stacks" => :required_bearer,
111 112
      "post /api/v3/repos/:owner/:repo/stacks/:stack_number/append" => :required_bearer,

@@ -132,9 +133,19 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

132 133
      "post /api/v3/repos/:owner/:repo/deployment-checks" => :required_bearer,
133 134
      "post /api/v3/repos/:owner/:repo/deployment-workflow-grants" => :required_bearer,
134 135
      "delete /api/v3/repos/:owner/:repo/deployment-workflow-grants/:id" => :required_bearer,
135
      # pipe_through :forge_write_api — forum writes and identity claims.
136
      # Dual-principal participation writes accept either a human forge token or
137
      # an agent participation credential.
136 138
      "post /api/v3/forum/topics" => :required_bearer,
137 139
      "post /api/v3/forum/topics/:topic_id/posts" => :required_bearer,
140
      "post /api/v3/repos/:owner/:repo/issues" => :required_bearer,
141
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/comments" => :required_bearer,
142
      "get /api/v3/agent" => :required_bearer,
143
      "post /api/v3/agent/credentials" => :required_bearer,
144
      "post /api/v3/agent/links" => :required_bearer,
145
      "get /api/v3/agents/links" => :required_bearer,
146
      "post /api/v3/agents/links/:id/accept" => :required_bearer,
147
      "post /api/v3/agents/links/:id/reject" => :required_bearer,
148
      "delete /api/v3/agents/links/:id" => :required_bearer,
138 149
      "post /api/v3/forum/claims" => :required_bearer,
139 150
      "get /api/v3/forum/claims" => :required_bearer,
140 151
      # Moderation and claim review: a bearer the controller then checks for

@@ -151,7 +162,6 @@ defmodule OpenAgentsWeb.ApiRouteAuthority do

151 162
      "get /api/v3/forum/tips/received" => :required_bearer,
152 163
      "post /api/v3/forum/posts/:post_id/tips" => :required_bearer,
153 164
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/assignees" => :required_bearer,
154
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/comments" => :required_bearer,
155 165
      "post /api/v3/repos/:owner/:repo/issues/:issue_number/dependencies" => :required_bearer,
156 166
      "delete /api/v3/repos/:owner/:repo/issues/:issue_number/dependencies/:blocked_by_number" =>
157 167
        :required_bearer,
lib/openagents_web/controllers/agent_controller.ex added +193

@@ -0,0 +1,193 @@

1
defmodule OpenAgentsWeb.AgentController do
2
  use OpenAgentsWeb, :controller
3
4
  alias OpenAgents.Accounts
5
  alias OpenAgents.Agents
6
  alias OpenAgents.Agents.{Agent, AgentUserLink}
7
  alias OpenAgents.Repo
8
9
  def register(conn, params) do
10
    attributes = Map.put(params, "registration_ip", remote_ip(conn))
11
12
    case Agents.register(attributes) do
13
      {:ok, agent, credential} ->
14
        conn
15
        |> put_status(:created)
16
        |> put_resp_header("cache-control", "no-store")
17
        |> json(%{
18
          "agent" => profile(agent),
19
          "token" => credential,
20
          "warning" => "This credential is shown once. Store it securely."
21
        })
22
23
      {:error, {:rate_limited, window}} ->
24
        refusal(conn, :too_many_requests, "registration_rate_limited", %{
25
          "window_seconds" => window
26
        })
27
28
      {:error, reason} ->
29
        refusal(conn, :unprocessable_entity, error_code(reason))
30
    end
31
  end
32
33
  def show(conn, %{"handle" => handle}) do
34
    case Agents.get_by_handle(handle) do
35
      %Agent{} = agent -> json(conn, %{"agent" => profile(agent)})
36
      nil -> refusal(conn, :not_found, "agent_not_found")
37
    end
38
  end
39
40
  def current(conn, _params) do
41
    json(conn, %{
42
      "agent" => profile(conn.assigns.current_agent),
43
      "credential" => credential_projection(conn.assigns[:agent_token]),
44
      "links" => Enum.map(Agents.list_links(conn.assigns.current_agent), &link_json/1)
45
    })
46
  end
47
48
  def rotate_credential(conn, params) do
49
    case Agents.mint_credential(conn.assigns.current_agent, params) do
50
      {:ok, token, credential} ->
51
        conn
52
        |> put_status(:created)
53
        |> put_resp_header("cache-control", "no-store")
54
        |> json(%{
55
          "credential" => credential,
56
          "token" => credential_projection(token),
57
          "warning" => "This credential is shown once. Store it securely."
58
        })
59
60
      {:error, reason} ->
61
        refusal(conn, :unprocessable_entity, error_code(reason))
62
    end
63
  end
64
65
  def request_link(conn, %{"user_id" => user_id}) do
66
    with {:ok, user} <- Accounts.get_active_user(user_id),
67
         {:ok, link} <- Agents.request_link(conn.assigns.current_agent, user) do
68
      conn |> put_status(:created) |> json(%{"link" => link_json(link)})
69
    else
70
      {:error, :banned} ->
71
        refusal(conn, :forbidden, "user_unavailable")
72
73
      {:error, :not_found} ->
74
        refusal(conn, :not_found, "user_not_found")
75
76
      {:error, %Ecto.Changeset{} = changeset} ->
77
        conn |> put_status(:unprocessable_entity) |> json(%{"errors" => errors(changeset)})
78
    end
79
  end
80
81
  def request_link(conn, _params), do: refusal(conn, :unprocessable_entity, "user_id_required")
82
83
  def links(conn, _params) do
84
    json(conn, %{
85
      "links" => Enum.map(Agents.list_pending_links(conn.assigns.current_user), &link_json/1)
86
    })
87
  end
88
89
  def accept_link(conn, %{"id" => id}), do: review_link(conn, id, :accept)
90
  def reject_link(conn, %{"id" => id}), do: review_link(conn, id, :reject)
91
  def unlink(conn, %{"id" => id}), do: review_link(conn, id, :unlink)
92
93
  def suspend(conn, %{"handle" => handle} = params) do
94
    with %Agent{} = agent <- Agents.get_by_handle(handle),
95
         {:ok, suspended} <- Agents.suspend(agent, params["reason"] || "operator suspension") do
96
      json(conn, %{"agent" => profile(suspended)})
97
    else
98
      nil -> refusal(conn, :not_found, "agent_not_found")
99
      {:error, reason} -> refusal(conn, :unprocessable_entity, error_code(reason))
100
    end
101
  end
102
103
  def reinstate(conn, %{"handle" => handle}) do
104
    with %Agent{} = agent <- Agents.get_by_handle(handle),
105
         {:ok, reinstated} <- Agents.reinstate(agent) do
106
      json(conn, %{"agent" => profile(reinstated)})
107
    else
108
      nil -> refusal(conn, :not_found, "agent_not_found")
109
      {:error, reason} -> refusal(conn, :unprocessable_entity, error_code(reason))
110
    end
111
  end
112
113
  defp review_link(conn, id, action) do
114
    result =
115
      case action do
116
        :accept -> Agents.accept_link(conn.assigns.current_user, id)
117
        :reject -> Agents.reject_link(conn.assigns.current_user, id)
118
        :unlink -> Agents.unlink(conn.assigns.current_user, id)
119
      end
120
121
    case result do
122
      {:ok, link} -> json(conn, %{"link" => link_json(Repo.preload(link, [:agent, :user]))})
123
      {:error, :link_not_found} -> refusal(conn, :not_found, "link_not_found")
124
      {:error, reason} -> refusal(conn, :conflict, error_code(reason))
125
    end
126
  end
127
128
  defp profile(%Agent{} = agent) do
129
    %{
130
      "id" => agent.id,
131
      "handle" => agent.handle,
132
      "display_name" => agent.display_name,
133
      "description" => agent.description,
134
      "status" => agent.status
135
    }
136
  end
137
138
  defp credential_projection(nil), do: nil
139
140
  defp credential_projection(token) do
141
    %{
142
      "id" => token.id,
143
      "name" => token.name,
144
      "last_four" => token.last_four,
145
      "scopes" => token.scopes,
146
      "expires_at" => DateTime.to_iso8601(token.expires_at),
147
      "last_used_at" => token.last_used_at,
148
      "revoked_at" => token.revoked_at
149
    }
150
  end
151
152
  defp link_json(%AgentUserLink{} = link) do
153
    %{
154
      "id" => link.id,
155
      "agent_id" => link.agent_id,
156
      "user_id" => link.user_id,
157
      "status" => link.status,
158
      "proof_method" => link.proof_method,
159
      "linked_at" => link.linked_at,
160
      "rejected_at" => link.rejected_at
161
    }
162
  end
163
164
  defp remote_ip(conn), do: conn.remote_ip |> :inet.ntoa() |> to_string()
165
166
  defp errors(changeset), do: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)
167
168
  defp translate_error({message, options}) do
169
    Regex.replace(~r"%{(\w+)}", message, fn _, key ->
170
      to_string(Keyword.get(options, String.to_existing_atom(key), key))
171
    end)
172
  end
173
174
  defp error_code(:invalid_handle), do: "invalid_handle"
175
  defp error_code(:confusable_handle), do: "confusable_handle"
176
  defp error_code(:handle_taken), do: "handle_unavailable"
177
  defp error_code(:registration_ip_required), do: "registration_unavailable"
178
  defp error_code(:agent_suspended), do: "agent_suspended"
179
  defp error_code(:display_name_too_long), do: "display_name_too_long"
180
  defp error_code(:description_too_long), do: "description_too_long"
181
  defp error_code(:link_already_active), do: "link_already_active"
182
  defp error_code(:invalid_registration), do: "invalid_registration"
183
  defp error_code(:invalid_agent_credential), do: "invalid_agent_credential"
184
  defp error_code(:link_not_found), do: "link_not_found"
185
  defp error_code(:user_unavailable), do: "user_unavailable"
186
  defp error_code(reason) when is_atom(reason), do: Atom.to_string(reason)
187
  defp error_code(_reason), do: "request_refused"
188
189
  defp refusal(conn, status, code, details \\ %{}) do
190
    body = Map.merge(%{"error" => %{"code" => code}}, details)
191
    conn |> put_status(status) |> json(body)
192
  end
193
end
lib/openagents_web/controllers/comment_controller.ex modified +30 -10

@@ -3,6 +3,7 @@ defmodule OpenAgentsWeb.CommentController do

3 3
4 4
  alias OpenAgents.Issues
5 5
  alias OpenAgents.Issues.Comment
6
  alias OpenAgents.Agents.Agent
6 7
  alias OpenAgents.Repositories
7 8
8 9
  def index(conn, %{

@@ -34,7 +35,13 @@ defmodule OpenAgentsWeb.CommentController do

34 35
          "issue_number" => issue_number
35 36
        } = params
36 37
      ) do
37
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
38
    actor = conn.assigns[:current_agent] || conn.assigns[:current_user]
39
40
    repository =
41
      case actor do
42
        %Agent{} -> Repositories.get_public_by_path!(owner, repo)
43
        _ -> Repositories.get_writable_by_path!(owner, repo, actor)
44
      end
38 45
39 46
    issue =
40 47
      Issues.get_issue_by_number!(

@@ -42,16 +49,20 @@ defmodule OpenAgentsWeb.CommentController do

42 49
        OpenAgentsWeb.ControllerHelpers.integer_param!(issue_number)
43 50
      )
44 51
45
    case Issues.create_comment(issue, params, conn.assigns.current_user) do
46
      {:ok, %Comment{} = comment} ->
47
        conn
48
        |> put_status(:created)
49
        |> render(:show, comment: comment)
52
    if Repositories.issue_participant?(repository, actor) do
53
      case Issues.create_comment(issue, params, actor) do
54
        {:ok, %Comment{} = comment} ->
55
          conn
56
          |> put_status(:created)
57
          |> render(:show, comment: comment)
50 58
51
      {:error, %Ecto.Changeset{} = changeset} ->
52
        conn
53
        |> put_status(:unprocessable_entity)
54
        |> render(:error, changeset: changeset)
59
        {:error, %Ecto.Changeset{} = changeset} ->
60
          conn
61
          |> put_status(:unprocessable_entity)
62
          |> render(:error, changeset: changeset)
63
      end
64
    else
65
      participation_forbidden(conn, actor)
55 66
    end
56 67
  rescue
57 68
    Ecto.NoResultsError ->

@@ -60,6 +71,15 @@ defmodule OpenAgentsWeb.CommentController do

60 71
      |> json(%{message: "Not Found"})
61 72
  end
62 73
74
  defp participation_forbidden(conn, %Agent{}),
75
    do:
76
      conn
77
      |> put_status(:forbidden)
78
      |> json(%{"error" => %{"code" => "agent_participation_forbidden"}})
79
80
  defp participation_forbidden(conn, _actor),
81
    do: conn |> put_status(:forbidden) |> json(%{"error" => "forbidden"})
82
63 83
  def show(conn, %{"owner" => owner, "repo" => repo, "id" => id}) do
64 84
    comment =
65 85
      Issues.get_comment_by_path!(owner, repo, OpenAgentsWeb.ControllerHelpers.integer_param!(id))
lib/openagents_web/controllers/forum_api_controller.ex modified +20 -8

@@ -15,6 +15,7 @@ defmodule OpenAgentsWeb.ForumApiController do

15 15
  use OpenAgentsWeb, :controller
16 16
17 17
  alias OpenAgents.Accounts
18
  alias OpenAgents.Agents.Agent
18 19
  alias OpenAgents.Forum
19 20
  alias OpenAgents.Forum.Tips
20 21

@@ -397,14 +398,25 @@ defmodule OpenAgentsWeb.ForumApiController do

397 398
  end
398 399
399 400
  defp actor_attrs(conn) do
400
    user = conn.assigns.current_user
401
402
    %{
403
      actor_ref: "user:#{user.id}",
404
      actor_display_name: user.github_name || user.github_login,
405
      actor_slug: user.github_login,
406
      actor_is_agent: false
407
    }
401
    case conn.assigns[:current_agent] do
402
      %Agent{} = agent ->
403
        %{
404
          actor_ref: "agent:#{agent.id}",
405
          actor_display_name: agent.display_name,
406
          actor_slug: agent.handle,
407
          actor_is_agent: true
408
        }
409
410
      _ ->
411
        user = conn.assigns.current_user
412
413
        %{
414
          actor_ref: "user:#{user.id}",
415
          actor_display_name: user.github_name || user.github_login,
416
          actor_slug: user.github_login,
417
          actor_is_agent: false
418
        }
419
    end
408 420
  end
409 421
410 422
  defp slugify(nil), do: nil
lib/openagents_web/controllers/issue_controller.ex modified +38 -18

@@ -3,6 +3,7 @@ defmodule OpenAgentsWeb.IssueController do

3 3
4 4
  alias OpenAgents.Issues
5 5
  alias OpenAgents.Issues.Issue
6
  alias OpenAgents.Agents.Agent
6 7
  alias OpenAgents.Repositories
7 8
8 9
  def index(conn, %{"owner" => owner, "repo" => repo} = params) do

@@ -88,29 +89,48 @@ defmodule OpenAgentsWeb.IssueController do

88 89
  defp blocked_filter(_value), do: :invalid
89 90
90 91
  def create(conn, %{"owner" => owner, "repo" => repo} = params) do
91
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
92
93
    case Issues.create_issue(repository, params, conn.assigns.current_user) do
94
      {:ok, %Issue{} = issue} ->
95
        conn
96
        |> put_status(:created)
97
        |> put_extensions_header()
98
        |> render(:show,
99
          issue: issue,
100
          owner: owner,
101
          repo: repo,
102
          dependencies: dependencies(issue)
103
        )
104
105
      {:error, %Ecto.Changeset{} = changeset} ->
106
        conn
107
        |> put_status(:unprocessable_entity)
108
        |> render(:error, changeset: changeset)
92
    actor = conn.assigns[:current_agent] || conn.assigns[:current_user]
93
94
    repository =
95
      case actor do
96
        %Agent{} -> Repositories.get_public_by_path!(owner, repo)
97
        _ -> Repositories.get_writable_by_path!(owner, repo, actor)
98
      end
99
100
    if Repositories.issue_participant?(repository, actor) do
101
      case Issues.create_issue(repository, params, actor) do
102
        {:ok, %Issue{} = issue} ->
103
          conn
104
          |> put_status(:created)
105
          |> put_extensions_header()
106
          |> render(:show,
107
            issue: issue,
108
            owner: owner,
109
            repo: repo,
110
            dependencies: dependencies(issue)
111
          )
112
113
        {:error, %Ecto.Changeset{} = changeset} ->
114
          conn
115
          |> put_status(:unprocessable_entity)
116
          |> render(:error, changeset: changeset)
117
      end
118
    else
119
      participation_forbidden(conn, actor)
109 120
    end
110 121
  rescue
111 122
    Ecto.NoResultsError -> not_found(conn)
112 123
  end
113 124
125
  defp participation_forbidden(conn, %Agent{}),
126
    do:
127
      conn
128
      |> put_status(:forbidden)
129
      |> json(%{"error" => %{"code" => "agent_participation_forbidden"}})
130
131
  defp participation_forbidden(conn, _actor),
132
    do: conn |> put_status(:forbidden) |> json(%{"error" => "forbidden"})
133
114 134
  def show(conn, %{
115 135
        "owner" => owner,
116 136
        "repo" => repo,
lib/openagents_web/live/issue_show_live.ex modified +5 -2

@@ -556,7 +556,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

556 556
557 557
    commented =
558 558
      Enum.map(comments, fn comment ->
559
        login = login(comment.user)
559
        login = actor_label(comment.user)
560 560
561 561
        %{
562 562
          kind: :comment,

@@ -632,11 +632,14 @@ defmodule OpenAgentsWeb.IssueShowLive do

632 632
  defp milestoned?(%{milestone: %{"number" => n}}, number), do: n == number
633 633
  defp milestoned?(_issue, _number), do: false
634 634
635
  defp author(issue), do: login(issue.user)
635
  defp author(issue), do: actor_label(issue.user)
636 636
637 637
  defp login(%{} = user), do: user["login"] || user[:login] || "anonymous"
638 638
  defp login(_user), do: "anonymous"
639 639
640
  defp actor_label(%{"agent" => true} = user), do: "#{login(user)} (agent)"
641
  defp actor_label(user), do: login(user)
642
640 643
  defp viewer(%{github_login: login}) when is_binary(login), do: login
641 644
  defp viewer(_user), do: nil
642 645
lib/openagents_web/plugs/agent_token_auth.ex added +30

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

1
defmodule OpenAgentsWeb.Plugs.AgentTokenAuth do
2
  @moduledoc "Authenticates an agent participation credential."
3
4
  import Plug.Conn
5
6
  alias OpenAgents.Agents
7
8
  def init(options), do: Keyword.get(options, :scope, "agent:participate")
9
10
  def call(conn, required_scope) do
11
    with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
12
         {:ok, agent, credential} <- Agents.authenticate(token, required_scope) do
13
      conn
14
      |> put_resp_header("cache-control", "no-store")
15
      |> assign(:current_agent, agent)
16
      |> assign(:agent_token, credential)
17
      |> assign(:agent_scope, required_scope)
18
    else
19
      _ -> refuse(conn)
20
    end
21
  end
22
23
  defp refuse(conn) do
24
    conn
25
    |> put_status(:unauthorized)
26
    |> put_resp_header("cache-control", "no-store")
27
    |> Phoenix.Controller.json(%{"error" => "invalid_agent_token"})
28
    |> halt()
29
  end
30
end
lib/openagents_web/plugs/dual_principal_auth.ex added +58

@@ -0,0 +1,58 @@

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

@@ -230,6 +230,15 @@ defmodule OpenAgentsWeb.RouteAuthority do

230 230
           false
231 231
         )
232 232
233
  defp policy(%{path: "/api/v3/agent/credentials", verb: :post}),
234
    do:
235
      declaration(
236
        :authenticated_api,
237
        "agent bearer token",
238
        "agent:participate",
239
        true
240
      )
241
233 242
  defp policy(%{path: "/api/operator/artifact-listings" <> _path, verb: verb}),
234 243
    do:
235 244
      declaration(

@@ -248,6 +257,15 @@ defmodule OpenAgentsWeb.RouteAuthority do

248 257
        verb not in [:get, :head]
249 258
      )
250 259
260
  defp policy(%{path: "/api/operator/agents/" <> _path, verb: verb}),
261
    do:
262
      declaration(
263
        :operator,
264
        "configured operator GitHub ID",
265
        "agents:moderate",
266
        verb not in [:get, :head]
267
      )
268
251 269
  defp policy(%{path: "/api/v3/device/authorizations" <> _path, verb: :post}),
252 270
    do:
253 271
      declaration(

@@ -316,6 +334,21 @@ defmodule OpenAgentsWeb.RouteAuthority do

316 334
           false
317 335
         )
318 336
337
  defp policy(%{path: path, verb: :post})
338
       when path in [
339
              "/api/v3/forum/topics",
340
              "/api/v3/forum/topics/:topic_id/posts",
341
              "/api/v3/repos/:owner/:repo/issues",
342
              "/api/v3/repos/:owner/:repo/issues/:issue_number/comments"
343
            ],
344
       do:
345
         declaration(
346
           :authenticated_api,
347
           "first-party human or agent bearer token",
348
           "forge:write or agent:participate",
349
           true
350
         )
351
319 352
  defp policy(%{path: "/api/v3/" <> _path, verb: verb}) when verb in [:get, :head],
320 353
    do: declaration(:public_read, "anonymous", "published:forge", false)
321 354
lib/openagents_web/router.ex modified +53 -5

@@ -42,6 +42,18 @@ defmodule OpenAgentsWeb.Router do

42 42
    plug OpenAgentsWeb.Plugs.ApiTokenAuth, scope: "forge:write"
43 43
  end
44 44
45
  pipeline :agent_participation_api do
46
    plug :accepts, ["json"]
47
    plug OpenAgentsWeb.Plugs.RequestOrigin
48
    plug OpenAgentsWeb.Plugs.DualPrincipalAuth, human_scope: "forge:write"
49
  end
50
51
  pipeline :agent_token_api do
52
    plug :accepts, ["json"]
53
    plug OpenAgentsWeb.Plugs.RequestOrigin
54
    plug OpenAgentsWeb.Plugs.AgentTokenAuth, scope: "agent:participate"
55
  end
56
45 57
  pipeline :chat_account_api do
46 58
    plug :accepts, ["json"]
47 59
    plug OpenAgentsWeb.Plugs.RequestOrigin

@@ -229,6 +241,9 @@ defmodule OpenAgentsWeb.Router do

229 241
  scope "/api/operator", OpenAgentsWeb do
230 242
    pipe_through [:authenticated_api, :operator_api]
231 243
244
    post "/agents/:handle/suspend", AgentController, :suspend
245
    post "/agents/:handle/reinstate", AgentController, :reinstate
246
232 247
    post "/artifact-listings", ArtifactListingAdminController, :create
233 248
    delete "/artifact-listings/:id", ArtifactListingAdminController, :delete
234 249
    get "/artifact-listings/:id/export", ArtifactListingAdminController, :export

@@ -345,6 +360,44 @@ defmodule OpenAgentsWeb.Router do

345 360
           :revoke_grant
346 361
  end
347 362
363
  scope "/api/v3", OpenAgentsWeb do
364
    pipe_through :api
365
366
    post "/agents/register", AgentController, :register
367
  end
368
369
  scope "/api/v3", OpenAgentsWeb do
370
    pipe_through :agent_participation_api
371
372
    post "/forum/topics", ForumApiController, :create_topic
373
    post "/forum/topics/:topic_id/posts", ForumApiController, :create_post
374
    post "/repos/:owner/:repo/issues", IssueController, :create
375
    post "/repos/:owner/:repo/issues/:issue_number/comments", CommentController, :create
376
  end
377
378
  scope "/api/v3", OpenAgentsWeb do
379
    pipe_through :forge_write_api
380
381
    get "/agents/links", AgentController, :links
382
    post "/agents/links/:id/accept", AgentController, :accept_link
383
    post "/agents/links/:id/reject", AgentController, :reject_link
384
    delete "/agents/links/:id", AgentController, :unlink
385
  end
386
387
  scope "/api/v3", OpenAgentsWeb do
388
    pipe_through :agent_token_api
389
390
    get "/agent", AgentController, :current
391
    post "/agent/credentials", AgentController, :rotate_credential
392
    post "/agent/links", AgentController, :request_link
393
  end
394
395
  scope "/api/v3", OpenAgentsWeb do
396
    pipe_through :api
397
398
    get "/agents/:handle", AgentController, :show
399
  end
400
348 401
  scope "/api/v3", OpenAgentsWeb do
349 402
    pipe_through :chat_account_api
350 403

@@ -357,9 +410,6 @@ defmodule OpenAgentsWeb.Router do

357 410
  scope "/api/v3", OpenAgentsWeb do
358 411
    pipe_through :forge_write_api
359 412
360
    # Forum writes. Reads are public and live in the optional-auth scope.
361
    post "/forum/topics", ForumApiController, :create_topic
362
    post "/forum/topics/:topic_id/posts", ForumApiController, :create_post
363 413
    post "/forum/claims", ForumApiController, :create_claim
364 414
    get "/forum/claims", ForumApiController, :list_claims
365 415

@@ -445,7 +495,6 @@ defmodule OpenAgentsWeb.Router do

445 495
    post "/orgs/:org/repos/imports", RepositoryImportController, :create_organization
446 496
    get "/repository-imports/:id", RepositoryImportController, :show
447 497
448
    post "/repos/:owner/:repo/issues", IssueController, :create
449 498
    put "/repos/:owner/:repo/issues/:issue_number", IssueController, :update
450 499
    patch "/repos/:owner/:repo/issues/:issue_number", IssueController, :update
451 500
    post "/repos/:owner/:repo/pulls", PullRequestController, :create

@@ -463,7 +512,6 @@ defmodule OpenAgentsWeb.Router do

463 512
         StackController,
464 513
         :abort_operation
465 514
466
    post "/repos/:owner/:repo/issues/:issue_number/comments", CommentController, :create
467 515
    put "/repos/:owner/:repo/issues/comments/:id", CommentController, :update
468 516
    patch "/repos/:owner/:repo/issues/comments/:id", CommentController, :update
469 517
    delete "/repos/:owner/:repo/issues/comments/:id", CommentController, :delete
priv/migration_lineages/prior-2026-08-19.json modified +3 -1

@@ -252,7 +252,9 @@

252 252
    20260823073000,
253 253
    20260823074000,
254 254
    20260823120247,
255
    20260823122038
255
    20260823122038,
256
    20260823122039,
257
    20260823122040
256 258
  ],
257 259
  "required_tables": [
258 260
    "users",
priv/repo/migrations/20260823122039_create_agent_accounts.exs added +106

@@ -0,0 +1,106 @@

1
defmodule OpenAgents.Repo.Migrations.CreateAgentAccounts do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:agents, primary_key: false) do
6
      add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()")
7
      add :handle, :string, null: false
8
      add :display_name, :string, null: false
9
      add :description, :text
10
      add :status, :string, null: false, default: "active"
11
      add :suspended_at, :utc_datetime_usec
12
      add :suspension_reason, :string
13
      add :registration_ip_digest, :binary, null: false
14
      timestamps(type: :utc_datetime_usec)
15
    end
16
17
    create unique_index(:agents, ["lower(handle)"], name: :agents_lower_handle_index)
18
    create index(:agents, [:registration_ip_digest, :inserted_at])
19
20
    create constraint(:agents, :agents_status_check, check: "status IN ('active', 'suspended')")
21
22
    create constraint(:agents, :agents_handle_check,
23
             check: "handle = lower(handle) AND handle ~ '^[a-z0-9]+(-[a-z0-9]+)*$'"
24
           )
25
26
    create table(:agent_tokens, primary_key: false) do
27
      add :id, :binary_id, primary_key: true
28
      add :agent_id, references(:agents, type: :binary_id, on_delete: :delete_all), null: false
29
      add :name, :string, null: false
30
      add :token_digest, :binary, null: false
31
      add :last_four, :string, null: false
32
      add :scopes, {:array, :string}, null: false
33
      add :expires_at, :utc_datetime_usec, null: false
34
      add :last_used_at, :utc_datetime_usec
35
      add :revoked_at, :utc_datetime_usec
36
      timestamps(type: :utc_datetime_usec)
37
    end
38
39
    create unique_index(:agent_tokens, [:token_digest])
40
    create index(:agent_tokens, [:agent_id, :inserted_at])
41
42
    create constraint(:agent_tokens, :agent_tokens_scopes_present,
43
             check: "cardinality(scopes) > 0"
44
           )
45
46
    create constraint(:agent_tokens, :agent_tokens_scopes_allowed,
47
             check: "scopes <@ ARRAY['agent:participate']::varchar[]"
48
           )
49
50
    create constraint(:agent_tokens, :agent_tokens_expiry_after_creation,
51
             check: "expires_at > inserted_at"
52
           )
53
54
    create constraint(:agent_tokens, :agent_tokens_digest_length,
55
             check: "octet_length(token_digest) = 32"
56
           )
57
58
    create table(:agent_user_links, primary_key: false) do
59
      add :id, :binary_id, primary_key: true, default: fragment("gen_random_uuid()")
60
      add :agent_id, references(:agents, type: :binary_id, on_delete: :delete_all), null: false
61
      add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
62
      add :status, :string, null: false, default: "pending"
63
      add :proof_method, :string
64
      add :proof_evidence, :map
65
      add :linked_at, :utc_datetime_usec
66
      add :rejected_at, :utc_datetime_usec
67
      timestamps(type: :utc_datetime_usec)
68
    end
69
70
    create unique_index(:agent_user_links, [:agent_id, :user_id])
71
72
    create unique_index(:agent_user_links, [:agent_id],
73
             where: "status = 'linked'",
74
             name: :agent_user_links_one_linked_agent_index
75
           )
76
77
    create constraint(:agent_user_links, :agent_user_links_status_check,
78
             check: "status IN ('pending', 'linked', 'rejected', 'unlinked')"
79
           )
80
81
    alter table(:issues) do
82
      add :author_agent_id, references(:agents, type: :binary_id, on_delete: :nilify_all)
83
    end
84
85
    alter table(:comments) do
86
      add :author_agent_id, references(:agents, type: :binary_id, on_delete: :nilify_all)
87
    end
88
89
    create index(:issues, [:author_agent_id])
90
    create index(:comments, [:author_agent_id])
91
92
    create constraint(:issues, :issues_one_author_principal_check,
93
             check: "NOT (author_user_id IS NOT NULL AND author_agent_id IS NOT NULL)"
94
           )
95
96
    create constraint(:comments, :comments_one_author_principal_check,
97
             check: "NOT (author_user_id IS NOT NULL AND author_agent_id IS NOT NULL)"
98
           )
99
100
    drop constraint(:audit_events, :audit_events_actor_type_allowed)
101
102
    create constraint(:audit_events, :audit_events_actor_type_allowed,
103
             check: "actor_type IN ('user', 'agent', 'machine', 'operator', 'system')"
104
           )
105
  end
106
end
test/openagents/agents_test.exs added +187

@@ -0,0 +1,187 @@

1
defmodule OpenAgents.AgentsTest do
2
  use OpenAgents.DataCase, async: false
3
4
  alias OpenAgents.Accounts
5
  alias OpenAgents.Agents
6
  alias OpenAgents.Agents.AgentToken
7
  alias OpenAgents.Repo
8
9
  test "registers an agent and exposes its credential only at registration" do
10
    assert {:ok, agent, credential} =
11
             Agents.register(%{
12
               "handle" => "release-bot",
13
               "display_name" => "Release bot",
14
               "registration_ip" => "192.0.2.10"
15
             })
16
17
    assert agent.handle == "release-bot"
18
    assert String.starts_with?(credential, "oa_agent_")
19
    assert {:ok, authenticated, token} = Agents.authenticate(credential)
20
    assert authenticated.id == agent.id
21
    assert token.scopes == ["agent:participate"]
22
    refute Repo.get_by(AgentToken, id: token.id).token_digest == credential
23
  end
24
25
  test "normalizes handles and rejects collisions, reserved, and confusable values" do
26
    assert {:ok, agent, _credential} =
27
             Agents.register(%{
28
               handle: "Build-Bot",
29
               display_name: "Build bot",
30
               registration_ip: "192.0.2.11"
31
             })
32
33
    assert agent.handle == "build-bot"
34
35
    assert {:error, :handle_taken} =
36
             Agents.register(%{
37
               handle: "build-bot",
38
               display_name: "Another bot",
39
               registration_ip: "192.0.2.12"
40
             })
41
42
    assert {:error, :confusable_handle} =
43
             Agents.register(%{
44
               handle: "12345",
45
               display_name: "Numeric bot",
46
               registration_ip: "192.0.2.13"
47
             })
48
49
    assert {:error, :confusable_handle} =
50
             Agents.register(%{
51
               handle: "build--bot",
52
               display_name: "Malformed bot",
53
               registration_ip: "192.0.2.14"
54
             })
55
  end
56
57
  test "suspension blocks authentication and credential minting" do
58
    {:ok, agent, credential} =
59
      Agents.register(%{
60
        handle: "suspendable-bot",
61
        display_name: "Suspendable bot",
62
        registration_ip: "192.0.2.15"
63
      })
64
65
    assert {:ok, suspended} = Agents.suspend(agent, "abuse review")
66
    assert suspended.status == "suspended"
67
    assert {:error, :invalid_agent_credential} = Agents.authenticate(credential)
68
    assert {:error, :agent_suspended} = Agents.mint_credential(suspended)
69
    assert {:ok, reinstated} = Agents.reinstate(suspended)
70
    assert {:ok, _agent, _token} = Agents.authenticate(credential)
71
    assert reinstated.status == "active"
72
  end
73
74
  test "link lifecycle is scoped to the agent and user" do
75
    {:ok, agent, _credential} =
76
      Agents.register(%{
77
        handle: "linkable-bot",
78
        display_name: "Linkable bot",
79
        registration_ip: "192.0.2.16"
80
      })
81
82
    {:ok, user} =
83
      Accounts.upsert_github_user(%{
84
        github_id: 991_016,
85
        github_login: "link-reviewer",
86
        github_avatar_url: "https://avatars.githubusercontent.com/u/991016?v=4"
87
      })
88
89
    assert {:ok, pending} = Agents.request_link(agent, user)
90
    assert pending.status == "pending"
91
    assert [listed] = Agents.list_pending_links(user)
92
    assert listed.id == pending.id
93
94
    assert {:ok, linked} = Agents.accept_link(user, pending.id)
95
    assert linked.status == "linked"
96
    assert {:ok, unlinked} = Agents.unlink(user, linked.id)
97
    assert unlinked.status == "unlinked"
98
    assert unlinked.agent_id == agent.id
99
  end
100
101
  test "agent can unlink a link and request it again after rejection or unlink" do
102
    {:ok, agent, _credential} =
103
      Agents.register(%{
104
        handle: "relinkable-bot",
105
        display_name: "Relinkable bot",
106
        registration_ip: "192.0.2.18"
107
      })
108
109
    {:ok, user} =
110
      Accounts.upsert_github_user(%{
111
        github_id: 991_018,
112
        github_login: "relink-reviewer",
113
        github_avatar_url: "https://avatars.githubusercontent.com/u/991018?v=4"
114
      })
115
116
    assert {:ok, pending} = Agents.request_link(agent, user)
117
    assert {:ok, rejected} = Agents.reject_link(user, pending.id)
118
    assert rejected.status == "rejected"
119
    assert {:ok, requested_again} = Agents.request_link(agent, user)
120
    assert requested_again.id == pending.id
121
    assert requested_again.status == "pending"
122
    assert {:ok, linked} = Agents.accept_link(user, requested_again.id)
123
    assert {:ok, unlinked} = Agents.unlink(agent, user)
124
    assert unlinked.id == linked.id
125
    assert unlinked.status == "unlinked"
126
    assert {:ok, requested_after_unlink} = Agents.request_link(agent, user)
127
    assert requested_after_unlink.id == linked.id
128
    assert requested_after_unlink.status == "pending"
129
  end
130
131
  test "rotates credentials while preserving the old credential" do
132
    {:ok, agent, old_credential} =
133
      Agents.register(%{
134
        handle: "rotating-bot",
135
        display_name: "Rotating bot",
136
        registration_ip: "192.0.2.19"
137
      })
138
139
    assert {:ok, token, new_credential} = Agents.mint_credential(agent, %{"name" => "rotated"})
140
    assert DateTime.compare(token.expires_at, DateTime.utc_now()) == :gt
141
    assert {:ok, old_agent, _old_token} = Agents.authenticate(old_credential)
142
    assert {:ok, new_agent, _new_token} = Agents.authenticate(new_credential)
143
    assert old_agent.id == agent.id
144
    assert new_agent.id == agent.id
145
146
    {:ok, _same_agent, old_token} = Agents.authenticate(old_credential)
147
    expired_at = DateTime.add(DateTime.utc_now(), -1, :second)
148
149
    old_token
150
    |> Ecto.Changeset.change(
151
      inserted_at: DateTime.add(expired_at, -1, :second),
152
      expires_at: expired_at
153
    )
154
    |> Repo.update!()
155
156
    assert {:error, :invalid_agent_credential} = Agents.authenticate(old_credential)
157
    assert {:ok, _same_agent, _token} = Agents.authenticate(new_credential)
158
  end
159
160
  test "refuses overlong registration fields without truncating" do
161
    assert {:error, :display_name_too_long} =
162
             Agents.register(%{
163
               handle: "long-name-bot",
164
               display_name: String.duplicate("x", 256),
165
               registration_ip: "192.0.2.20"
166
             })
167
168
    assert {:error, :description_too_long} =
169
             Agents.register(%{
170
               handle: "long-description-bot",
171
               display_name: "Long description bot",
172
               description: String.duplicate("x", 4_001),
173
               registration_ip: "192.0.2.21"
174
             })
175
  end
176
177
  test "agent credentials cannot authenticate with a human scope" do
178
    {:ok, _agent, credential} =
179
      Agents.register(%{
180
        handle: "scope-limited-bot",
181
        display_name: "Scope-limited bot",
182
        registration_ip: "192.0.2.17"
183
      })
184
185
    assert {:error, :invalid_agent_credential} = Agents.authenticate(credential, "forge:write")
186
  end
187
end
test/openagents_web/controllers/agent_controller_test.exs added +172

@@ -0,0 +1,172 @@

1
defmodule OpenAgentsWeb.AgentControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: false
3
4
  alias OpenAgents.Agents
5
6
  test "registers an agent and returns a one-time credential", %{conn: conn} do
7
    conn =
8
      post(conn, "/api/v3/agents/register", %{
9
        "handle" => "controller-bot",
10
        "display_name" => "Controller bot"
11
      })
12
13
    response = json_response(conn, 201)
14
15
    assert %{
16
             "agent" => %{"handle" => "controller-bot", "status" => "active"},
17
             "token" => "oa_agent_" <> _credential,
18
             "warning" => warning
19
           } = response
20
21
    assert warning =~ "shown once"
22
    refute get_in(response, ["agent", "token"])
23
  end
24
25
  test "rejects duplicate and malformed handles with typed errors", %{conn: conn} do
26
    assert %{"agent" => _agent} =
27
             conn
28
             |> post("/api/v3/agents/register", %{
29
               "handle" => "duplicate-bot",
30
               "display_name" => "Duplicate bot"
31
             })
32
             |> json_response(201)
33
34
    duplicate =
35
      post(conn, "/api/v3/agents/register", %{
36
        "handle" => "duplicate-bot",
37
        "display_name" => "Duplicate bot"
38
      })
39
40
    assert json_response(duplicate, 422)["error"]["code"] == "handle_unavailable"
41
42
    malformed =
43
      post(conn, "/api/v3/agents/register", %{
44
        "handle" => "bot--name",
45
        "display_name" => "Malformed bot"
46
      })
47
48
    assert json_response(malformed, 422)["error"]["code"] == "confusable_handle"
49
  end
50
51
  test "rejects reserved handles through the controller", %{conn: conn} do
52
    conn =
53
      post(conn, "/api/v3/agents/register", %{
54
        "handle" => "admin",
55
        "display_name" => "Admin bot"
56
      })
57
58
    assert json_response(conn, 422)["error"]["code"] == "handle_unavailable"
59
  end
60
61
  test "returns the documented rate-limit envelope from application configuration", %{conn: conn} do
62
    previous = Application.get_env(:openagents, :agent_registration_per_ip)
63
    Application.put_env(:openagents, :agent_registration_per_ip, 0)
64
65
    on_exit(fn ->
66
      if is_nil(previous) do
67
        Application.delete_env(:openagents, :agent_registration_per_ip)
68
      else
69
        Application.put_env(:openagents, :agent_registration_per_ip, previous)
70
      end
71
    end)
72
73
    conn =
74
      post(conn, "/api/v3/agents/register", %{
75
        "handle" => "limited-bot",
76
        "display_name" => "Limited bot"
77
      })
78
79
    assert %{"error" => %{"code" => "registration_rate_limited"}, "window_seconds" => window} =
80
             json_response(conn, 429)
81
82
    assert is_integer(window)
83
  end
84
85
  test "rotates a credential while retaining the old credential", %{conn: conn} do
86
    {:ok, _agent, credential} =
87
      Agents.register(%{
88
        handle: "rotate-controller-bot",
89
        display_name: "Rotate controller bot",
90
        registration_ip: "192.0.2.32"
91
      })
92
93
    rotated =
94
      conn
95
      |> put_req_header("authorization", "Bearer #{credential}")
96
      |> post("/api/v3/agent/credentials", %{"name" => "rotated"})
97
98
    assert %{
99
             "credential" => "oa_agent_" <> new_credential,
100
             "token" => %{"name" => "rotated"}
101
           } = json_response(rotated, 201)
102
103
    assert {:ok, _agent, _token} = Agents.authenticate("oa_agent_" <> new_credential)
104
105
    old_still_works =
106
      conn
107
      |> recycle()
108
      |> put_req_header("authorization", "Bearer #{credential}")
109
      |> get("/api/v3/agent")
110
111
    assert json_response(old_still_works, 200)["agent"]["handle"] == "rotate-controller-bot"
112
  end
113
114
  test "returns a public agent profile and authenticates the current-agent route", %{conn: conn} do
115
    {:ok, agent, credential} =
116
      Agents.register(%{
117
        "handle" => "profile-bot",
118
        "display_name" => "Profile bot",
119
        "registration_ip" => "192.0.2.30"
120
      })
121
122
    profile = get(conn, "/api/v3/agents/profile-bot")
123
    assert json_response(profile, 200)["agent"]["id"] == agent.id
124
125
    current =
126
      conn
127
      |> put_req_header("authorization", "Bearer #{credential}")
128
      |> get("/api/v3/agent")
129
130
    assert json_response(current, 200)["agent"]["handle"] == "profile-bot"
131
  end
132
133
  test "rejects agent credentials on named human-only routes", %{conn: conn} do
134
    {:ok, _agent, credential} =
135
      Agents.register(%{
136
        "handle" => "restricted-bot",
137
        "display_name" => "Restricted bot",
138
        "registration_ip" => "192.0.2.31"
139
      })
140
141
    operator =
142
      conn
143
      |> put_req_header("authorization", "Bearer #{credential}")
144
      |> post("/api/operator/agents/restricted-bot/suspend", %{"reason" => "test"})
145
146
    assert operator.status == 401
147
148
    promotion =
149
      conn
150
      |> recycle()
151
      |> put_req_header("authorization", "Bearer #{credential}")
152
      |> post("/api/v3/repos/test-owner/test-repo/deployments/1/approvals", %{})
153
154
    assert promotion.status == 401
155
156
    membership =
157
      conn
158
      |> recycle()
159
      |> put_req_header("authorization", "Bearer #{credential}")
160
      |> patch("/api/v3/repos/test-owner/test-repo/issues/1", %{"title" => "test"})
161
162
    assert membership.status == 401
163
164
    tip =
165
      conn
166
      |> recycle()
167
      |> put_req_header("authorization", "Bearer #{credential}")
168
      |> post("/api/v3/forum/posts/1/tips", %{"amount" => "1"})
169
170
    assert tip.status == 401
171
  end
172
end
test/openagents_web/controllers/comment_controller_test.exs modified +24

@@ -4,6 +4,7 @@ defmodule OpenAgentsWeb.CommentControllerTest do

4 4
  setup %{conn: conn}, do: {:ok, conn: put_forge_api_token(conn, "comments", repository())}
5 5
6 6
  alias OpenAgents.Issues
7
  alias OpenAgents.Agents
7 8
8 9
  setup do
9 10
    {:ok, issue} = Issues.create_issue(repository(), %{title: "Comment target"})

@@ -39,6 +40,29 @@ defmodule OpenAgentsWeb.CommentControllerTest do

39 40
    assert %{"body" => "New comment"} = json_response(conn, 201)
40 41
  end
41 42
43
  test "an unlinked agent can create a comment with an agent author", %{
44
    conn: conn,
45
    issue: issue
46
  } do
47
    {:ok, _agent, credential} =
48
      Agents.register(%{
49
        handle: "comment-agent",
50
        display_name: "Comment agent",
51
        registration_ip: "192.0.2.51"
52
      })
53
54
    conn =
55
      conn
56
      |> put_req_header("authorization", "Bearer #{credential}")
57
      |> post(
58
        ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{issue.number}/comments",
59
        %{body: "Agent comment"}
60
      )
61
62
    assert %{"body" => "Agent comment", "user" => %{"agent" => true, "handle" => "comment-agent"}} =
63
             json_response(conn, 201)
64
  end
65
42 66
  test "GET /api/v3/repos/:owner/:repo/issues/comments/:id returns a comment", %{
43 67
    conn: conn,
44 68
    issue: issue
test/openagents_web/controllers/forum_api_controller_test.exs modified +112

@@ -2,6 +2,8 @@ defmodule OpenAgentsWeb.ForumApiControllerTest do

2 2
  use OpenAgentsWeb.ConnCase, async: false
3 3
4 4
  alias OpenAgents.Forum
5
  alias OpenAgents.Accounts
6
  alias OpenAgents.Agents
5 7
  alias OpenAgents.Repo
6 8
7 9
  setup %{conn: conn} do

@@ -84,6 +86,46 @@ defmodule OpenAgentsWeb.ForumApiControllerTest do

84 86
    assert json_response(conn, 401)
85 87
  end
86 88
89
  test "an unlinked agent can create a topic and reply with stable attribution", %{
90
    conn: conn,
91
    forum: forum
92
  } do
93
    {:ok, agent, credential} =
94
      Agents.register(%{
95
        handle: "forum-agent",
96
        display_name: "Forum agent",
97
        registration_ip: "192.0.2.40"
98
      })
99
100
    topic_conn =
101
      conn
102
      |> put_req_header("authorization", "Bearer #{credential}")
103
      |> post(~p"/api/v3/forum/topics", %{
104
        forum: forum.slug,
105
        title: "Agent topic",
106
        body_text: "Created without a human link"
107
      })
108
109
    assert %{"topic" => topic, "posts" => [%{"author" => author}]} =
110
             json_response(topic_conn, 201)
111
112
    assert author["is_agent"] == true
113
    assert author["ref"] == "agent:#{agent.id}"
114
    assert author["display_name"] == agent.display_name
115
116
    topic_id = topic["id"]
117
118
    reply_conn =
119
      conn
120
      |> put_req_header("authorization", "Bearer #{credential}")
121
      |> post(~p"/api/v3/forum/topics/#{topic_id}/posts", %{
122
        body_text: "Agent reply"
123
      })
124
125
    assert %{"post" => %{"author" => %{"is_agent" => true, "ref" => "agent:" <> _}}} =
126
             json_response(reply_conn, 201)
127
  end
128
87 129
  test "POST /api/v3/forum/topics/:id/posts replies to a topic", %{conn: conn, forum: forum} do
88 130
    topic = topic(forum)
89 131

@@ -97,6 +139,76 @@ defmodule OpenAgentsWeb.ForumApiControllerTest do

97 139
    assert Forum.count_posts(topic) == 2
98 140
  end
99 141
142
  test "a suspended agent is refused on the forum reply route", %{conn: conn, forum: forum} do
143
    {:ok, topic} =
144
      Forum.create_topic(forum, %{
145
        title: "Suspended target",
146
        slug: "suspended-target",
147
        body_text: "Existing topic",
148
        idempotency_key: Ecto.UUID.generate(),
149
        actor_ref: "agent:suspended",
150
        actor_display_name: "Suspended",
151
        actor_slug: "suspended"
152
      })
153
154
    {:ok, agent, credential} =
155
      Agents.register(%{
156
        handle: "suspended-forum-bot",
157
        display_name: "Suspended forum bot",
158
        registration_ip: "192.0.2.41"
159
      })
160
161
    assert {:ok, _suspended} = Agents.suspend(agent, "test")
162
163
    conn =
164
      conn
165
      |> put_req_header("authorization", "Bearer #{credential}")
166
      |> post(~p"/api/v3/forum/topics/#{topic.id}/posts", %{body_text: "Should fail"})
167
168
    assert conn.status == 401
169
  end
170
171
  test "linking and unlinking do not rewrite forum authorship", %{conn: conn, forum: forum} do
172
    {:ok, agent, credential} =
173
      Agents.register(%{
174
        handle: "stable-forum-bot",
175
        display_name: "Stable forum bot",
176
        registration_ip: "192.0.2.42"
177
      })
178
179
    {:ok, user} =
180
      Accounts.upsert_github_user(%{
181
        github_id: 992_042,
182
        github_login: "stable-forum-reviewer",
183
        github_avatar_url: "https://avatars.githubusercontent.com/u/992042?v=4"
184
      })
185
186
    created =
187
      conn
188
      |> put_req_header("authorization", "Bearer #{credential}")
189
      |> post(~p"/api/v3/forum/topics", %{
190
        forum: forum.slug,
191
        title: "Stable topic",
192
        body_text: "Authorship must remain stable"
193
      })
194
195
    assert %{"topic" => %{"id" => topic_id}} = json_response(created, 201)
196
    before = get(conn, ~p"/api/v3/forum/topics/#{topic_id}") |> json_response(200)
197
198
    assert {:ok, pending} = Agents.request_link(agent, user)
199
    assert {:ok, _linked} = Agents.accept_link(user, pending.id)
200
    assert {:ok, _unlinked} = Agents.unlink(agent, user)
201
202
    after_link = get(conn, ~p"/api/v3/forum/topics/#{topic_id}") |> json_response(200)
203
    assert before["topic"]["actor_ref"] == after_link["topic"]["actor_ref"]
204
    assert before["topic"]["actor_display_name"] == after_link["topic"]["actor_display_name"]
205
    assert before["topic"]["actor_slug"] == after_link["topic"]["actor_slug"]
206
    assert before["posts"] == after_link["posts"]
207
208
    profile = get(conn, ~p"/api/v3/agents/#{agent.handle}") |> json_response(200)
209
    refute Map.has_key?(profile["agent"], "owner")
210
  end
211
100 212
  test "POST /api/v3/forum/claims starts an identity claim", %{conn: conn} do
101 213
    conn =
102 214
      conn
test/openagents_web/controllers/issue_controller_test.exs modified +110

@@ -4,6 +4,9 @@ defmodule OpenAgentsWeb.IssueControllerTest do

4 4
  setup %{conn: conn}, do: {:ok, conn: put_forge_api_token(conn, "issues", repository())}
5 5
6 6
  alias OpenAgents.Issues
7
  alias OpenAgents.Agents
8
  alias OpenAgents.Accounts
9
  alias OpenAgents.Repo
7 10
  alias OpenAgents.Repositories
8 11
9 12
  import OpenAgents.MilestonesFixtures

@@ -60,6 +63,113 @@ defmodule OpenAgentsWeb.IssueControllerTest do

60 63
61 64
      assert json_response(conn, 422)["errors"] != %{}
62 65
    end
66
67
    test "an unlinked agent can create an issue with an agent author", %{conn: conn} do
68
      {:ok, agent, credential} =
69
        Agents.register(%{
70
          handle: "issue-agent",
71
          display_name: "Issue agent",
72
          registration_ip: "192.0.2.50"
73
        })
74
75
      conn =
76
        conn
77
        |> put_req_header("authorization", "Bearer #{credential}")
78
        |> post(~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues", %{
79
          title: "Agent issue",
80
          body: "Filed without a human link"
81
        })
82
83
      assert %{"user" => %{"agent" => true, "handle" => "issue-agent"}} =
84
               json_response(conn, 201)
85
86
      assert Repo.get_by(OpenAgents.Issues.Issue, title: "Agent issue").author_agent_id ==
87
               agent.id
88
    end
89
90
    test "a suspended agent is refused on issue creation", %{conn: conn} do
91
      {:ok, agent, credential} =
92
        Agents.register(%{
93
          handle: "suspended-issue-bot",
94
          display_name: "Suspended issue bot",
95
          registration_ip: "192.0.2.52"
96
        })
97
98
      assert {:ok, _suspended} = Agents.suspend(agent, "test")
99
100
      conn =
101
        conn
102
        |> put_req_header("authorization", "Bearer #{credential}")
103
        |> post(~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues", %{
104
          title: "Should fail",
105
          body: "Suspended"
106
        })
107
108
      assert conn.status == 401
109
    end
110
111
    test "an agent credential is refused on private repository issue creation", %{conn: conn} do
112
      private =
113
        repository_fixture(%{
114
          owner: "PrivateOwner",
115
          name: "private-repository",
116
          visibility: "private"
117
        })
118
119
      {:ok, _agent, credential} =
120
        Agents.register(%{
121
          handle: "private-issue-bot",
122
          display_name: "Private issue bot",
123
          registration_ip: "192.0.2.53"
124
        })
125
126
      conn =
127
        conn
128
        |> put_req_header("authorization", "Bearer #{credential}")
129
        |> post("/api/v3/repos/#{private.owner}/#{private.name}/issues", %{
130
          title: "Should fail",
131
          body: "Private"
132
        })
133
134
      assert conn.status == 404
135
    end
136
137
    test "linking and unlinking do not rewrite issue authorship", %{conn: conn} do
138
      {:ok, agent, credential} =
139
        Agents.register(%{
140
          handle: "stable-issue-bot",
141
          display_name: "Stable issue bot",
142
          registration_ip: "192.0.2.54"
143
        })
144
145
      {:ok, user} =
146
        Accounts.upsert_github_user(%{
147
          github_id: 992_054,
148
          github_login: "stable-issue-reviewer",
149
          github_avatar_url: "https://avatars.githubusercontent.com/u/992054?v=4"
150
        })
151
152
      created =
153
        conn
154
        |> put_req_header("authorization", "Bearer #{credential}")
155
        |> post(~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues", %{
156
          title: "Stable issue",
157
          body: "Authorship must remain stable"
158
        })
159
160
      assert %{"number" => number, "user" => before_author} = json_response(created, 201)
161
      assert {:ok, pending} = Agents.request_link(agent, user)
162
      assert {:ok, _linked} = Agents.accept_link(user, pending.id)
163
      assert {:ok, _unlinked} = Agents.unlink(agent, user)
164
165
      after_link =
166
        get(conn, ~p"/api/v3/repos/OpenAgentsInc/openagents.com/issues/#{number}")
167
        |> json_response(200)
168
169
      assert after_link["user"] == before_author
170
      assert before_author["agent"] == true
171
      refute Map.has_key?(before_author, "owner")
172
    end
63 173
  end
64 174
65 175
  describe "show" do
test/openagents_web/route_authority_test.exs modified +19 -3

@@ -32,8 +32,8 @@ defmodule OpenAgentsWeb.RouteAuthorityTest do

32 32
    assert read.scope == "forge:repository:read"
33 33
    assert read.mutation == false
34 34
    assert write.class == :authenticated_api
35
    assert write.principal == "first-party bearer token"
36
    assert write.scope == "forge:write"
35
    assert write.principal == "first-party human or agent bearer token"
36
    assert write.scope == "forge:write or agent:participate"
37 37
    assert write.mutation
38 38
39 39
    assert Phoenix.Router.route_info(

@@ -48,7 +48,23 @@ defmodule OpenAgentsWeb.RouteAuthorityTest do

48 48
             "POST",
49 49
             "/api/v3/repos/OpenAgentsInc/openagents.com/issues",
50 50
             "stage.openagents.com"
51
           ).pipe_through == [:forge_write_api]
51
           ).pipe_through == [:agent_participation_api]
52
  end
53
54
  test "agent credential rotation is an agent-scoped bearer write" do
55
    route = route!(:post, "/api/v3/agent/credentials")
56
57
    assert route.class == :authenticated_api
58
    assert route.principal == "agent bearer token"
59
    assert route.scope == "agent:participate"
60
    assert route.mutation
61
62
    assert Phoenix.Router.route_info(
63
             OpenAgentsWeb.Router,
64
             "POST",
65
             "/api/v3/agent/credentials",
66
             "stage.openagents.com"
67
           ).pipe_through == [:agent_token_api]
52 68
  end
53 69
54 70
  test "public browser forge surfaces remain separate from authenticated entries" do

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