Harden identity authorization and secret handling

04b2faf47080 · Christopher David · · parent e37d303aabb7

Harden identity authorization and secret handling

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified config/config.exs
  • modified config/dev.exs
  • modified config/runtime.exs
  • modified config/test.exs
  • added docs/api-authentication.md
  • modified docs/beam-hot-deployment-plan.md
  • modified docs/decisions/0004-retain-scoped-github-access-tokens.md
  • modified docs/github-api-issues-projects-assessment.md
  • modified docs/github-auth-plan.md
  • modified docs/issues-projects-work-plan.md
  • modified docs/runtime-configuration.md
  • added docs/security/secrets-and-log-handling.md
  • modified lib/openagents/accounts.ex
  • modified lib/openagents/accounts/token_vault.ex
  • modified lib/openagents/accounts/user.ex
  • added lib/openagents/api_tokens.ex
  • added lib/openagents/api_tokens/api_token.ex
  • modified lib/openagents/changelog/backfill.ex
  • modified lib/openagents/cluster/drain.ex
  • modified lib/openagents/cluster/ra_bootstrap.ex
  • modified lib/openagents/data_rights.ex
  • modified lib/openagents/forge/boot_converge.ex
  • modified lib/openagents/forge/browse.ex
  • modified lib/openagents/forge/build_executor.ex
  • modified lib/openagents/forge/builder.ex
  • modified lib/openagents/forge/hot_loader.ex
  • modified lib/openagents/forge/janitor.ex
  • modified lib/openagents/forge/mirror_watch.ex
  • modified lib/openagents/forge/pushes.ex
  • modified lib/openagents/forge/sync.ex
  • modified lib/openagents/github_oauth.ex
  • modified lib/openagents/incidents.ex
  • modified lib/openagents/incidents/fixer.ex
  • modified lib/openagents/incidents/notifier.ex
  • modified lib/openagents/leaderboard/server.ex
  • added lib/openagents/log_safety.ex
  • modified lib/openagents/machines.ex
  • modified lib/openagents/machines/machine.ex
  • added lib/openagents/operational_log.ex
  • modified lib/openagents/release.ex
  • modified lib/openagents/runtime_config.ex
  • modified lib/openagents/tools/embeddings.ex
  • modified lib/openagents/voice_sessions/session_server.ex
  • modified lib/openagents/work/coding.ex
  • modified lib/openagents_web/channels/computer_channel.ex
  • modified lib/openagents_web/channels/controller_socket.ex
  • modified lib/openagents_web/components/layouts.ex
  • added lib/openagents_web/controllers/api_token_controller.ex
  • modified lib/openagents_web/controllers/auth_controller.ex
  • modified lib/openagents_web/controllers/controller_pairing_controller.ex
  • modified lib/openagents_web/controllers/inference_proxy_controller.ex
  • added lib/openagents_web/live/api_tokens_live.ex
  • modified lib/openagents_web/live/chat_live.ex
  • modified lib/openagents_web/live/home_live.ex
  • added lib/openagents_web/plugs/api_token_auth.ex
  • added lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • modified lib/openagents_web/user_auth.ex
  • added ops/ci/private-log-scan.exs
  • modified ops/ci/release-smoke.sh
  • modified ops/staging/gate-5-profile.sh
  • added priv/repo/migrations/20260820073810_add_github_token_lifecycle_metadata.exs
  • added priv/repo/migrations/20260820074227_create_api_tokens.exs
  • added priv/repo/migrations/20260820074644_add_machine_token_expiry.exs
  • added rel/overlays/bin/rotate-github-tokens
  • modified test/openagents/accounts/token_vault_test.exs
  • modified test/openagents/accounts_test.exs
  • added test/openagents/api_tokens_test.exs
  • modified test/openagents/forge/builder_test.exs
  • modified test/openagents/forge/mirror_watch_test.exs
  • modified test/openagents/github_oauth_test.exs
  • added test/openagents/log_safety_test.exs
  • modified test/openagents/machines_test.exs
  • added test/openagents/operational_log_test.exs
  • modified test/openagents/runtime_config_test.exs
  • modified test/openagents_web/auth_controller_test.exs
  • added test/openagents_web/controllers/api_token_controller_test.exs
  • modified test/openagents_web/controllers/comment_controller_test.exs
  • modified test/openagents_web/controllers/data_controller_test.exs
  • modified test/openagents_web/controllers/issue_assignee_controller_test.exs
  • modified test/openagents_web/controllers/issue_controller_test.exs
  • modified test/openagents_web/controllers/issue_label_controller_test.exs
  • modified test/openagents_web/controllers/label_controller_test.exs
  • modified test/openagents_web/controllers/milestone_controller_test.exs
  • modified test/openagents_web/controllers/project_controller_test.exs
  • modified test/openagents_web/home_controller_test.exs
  • added test/openagents_web/live/api_tokens_live_test.exs
  • added test/openagents_web/route_authority_test.exs
  • modified test/support/conn_case.ex

Diff

89 files changed, +2738 -234

config/config.exs modified +24 -1

@@ -7,6 +7,26 @@

7 7
# General application configuration
8 8
import Config
9 9
10
config :phoenix,
11
  filter_parameters: [
12
    "authorization",
13
    "code",
14
    "content",
15
    "credential",
16
    "memory",
17
    "messages",
18
    "password",
19
    "poll_secret",
20
    "prompt",
21
    "raw_arguments",
22
    "sdp",
23
    "secret",
24
    "state",
25
    "token",
26
    "transcript",
27
    "verifier"
28
  ]
29
10 30
config :openagents,
11 31
  namespace: OpenAgents,
12 32
  ecto_repos: [OpenAgents.Repo],

@@ -22,6 +42,7 @@ config :openagents,

22 42
  turn_rate_limit: 50,
23 43
  admin_github_ids: [],
24 44
  computer_controller_enabled: false,
45
  machine_token_ttl_seconds: 2_592_000,
25 46
  coding_jobs_dir: "/var/lib/openagents/coding-jobs",
26 47
  work_workers_enabled: false,
27 48
  work: [enabled: false],

@@ -115,7 +136,7 @@ config :openagents,

115 136
    base_url: "https://api.github.com",
116 137
    request_options: []
117 138
  ],
118
  github_oauth_scopes: ["read:user", "repo"],
139
  github_oauth_scopes: ["repo"],
119 140
  voice_recording: [
120 141
    enabled: false,
121 142
    timeslice_ms: 5_000,

@@ -152,6 +173,8 @@ config :openagents,

152 173
    request_options: []
153 174
  ],
154 175
  github_token_encryption_key: nil,
176
  github_token_encryption_key_id: nil,
177
  github_token_decryption_keys: %{},
155 178
  voice_recording_encryption_key: nil,
156 179
  inference_proxy_url: nil,
157 180
  inference_grant_max_total_tokens: 2_000_000,
config/dev.exs modified +3

@@ -22,6 +22,9 @@ config :openagents,

22 22
       :github_token_encryption_key,
23 23
       Base.encode64("openagents-dev-token-vault-key32")
24 24
25
config :openagents, :github_token_encryption_key_id, "development-2026-08"
26
config :openagents, :github_token_decryption_keys, %{}
27
25 28
# For development, we disable any cache and enable
26 29
# debugging and code reloading.
27 30
#
config/runtime.exs modified +39 -4

@@ -258,6 +258,8 @@ if config_env() == :prod do

258 258
    shadow_programs: shadow_programs,
259 259
    tool_discovery: tool_discovery,
260 260
    computer_controller_enabled: computers_enabled,
261
    machine_token_ttl_seconds:
262
      parse_integer.("OPENAGENTS_MACHINE_TOKEN_TTL_SECONDS", 300..2_592_000),
261 263
    coding_jobs_dir: required_text.("OPENAGENTS_CODING_JOBS_DIR"),
262 264
    conversation_reset_enabled: conversation_reset_enabled,
263 265
    incident_fixer_enabled: incident_fixer_enabled,

@@ -316,17 +318,50 @@ github_oauth =

316 318
config :openagents, :github_oauth, github_oauth
317 319
318 320
token_encryption_key = optional_text.("GITHUB_TOKEN_ENCRYPTION_KEY")
321
token_encryption_key_id = optional_text.("GITHUB_TOKEN_ENCRYPTION_KEY_ID")
322
323
token_decryption_keys =
324
  case optional_text.("GITHUB_TOKEN_DECRYPTION_KEYS_JSON") do
325
    nil ->
326
      %{}
327
328
    encoded ->
329
      case Jason.decode(encoded) do
330
        {:ok, keys} when is_map(keys) ->
331
          keys
332
333
        _invalid ->
334
          raise "environment variable GITHUB_TOKEN_DECRYPTION_KEYS_JSON must be a JSON object"
335
      end
336
  end
319 337
320 338
valid_token_key? =
321 339
  is_binary(token_encryption_key) and
322 340
    match?({:ok, key} when byte_size(key) == 32, Base.decode64(token_encryption_key))
323 341
324
if config_env() == :prod and not valid_token_key? do
325
  raise "environment variable GITHUB_TOKEN_ENCRYPTION_KEY must be a base64-encoded 32-byte key"
342
valid_token_key_id? =
343
  is_binary(token_encryption_key_id) and
344
    String.match?(token_encryption_key_id, ~r/\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\z/)
345
346
valid_decryption_keys? =
347
  map_size(token_decryption_keys) <= 16 and
348
    not Map.has_key?(token_decryption_keys, token_encryption_key_id) and
349
    Enum.all?(token_decryption_keys, fn {key_id, encoded_key} ->
350
      is_binary(key_id) and String.match?(key_id, ~r/\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\z/) and
351
        is_binary(encoded_key) and
352
        match?({:ok, key} when byte_size(key) == 32, Base.decode64(encoded_key))
353
    end)
354
355
if config_env() == :prod and
356
     not (valid_token_key? and valid_token_key_id? and valid_decryption_keys?) do
357
  raise "GitHub token keyring environment variables are invalid"
326 358
end
327 359
328
if valid_token_key? do
329
  config :openagents, :github_token_encryption_key, token_encryption_key
360
if valid_token_key? and valid_token_key_id? and valid_decryption_keys? do
361
  config :openagents,
362
    github_token_encryption_key: token_encryption_key,
363
    github_token_encryption_key_id: token_encryption_key_id,
364
    github_token_decryption_keys: token_decryption_keys
330 365
end
331 366
332 367
if parse_optional_boolean.("PHX_SERVER") do
config/test.exs modified +3

@@ -25,6 +25,9 @@ config :openagents,

25 25
       :github_token_encryption_key,
26 26
       Base.encode64("openagents-test-token-vault-key3")
27 27
28
config :openagents, :github_token_encryption_key_id, "test-2026-08"
29
config :openagents, :github_token_decryption_keys, %{}
30
28 31
# Test fakes for providers and voice sideband so the suite never reaches the network.
29 32
config :openagents, :provider, OpenAgents.Providers.Test
30 33
config :openagents, :voice_call_provider, OpenAgents.Voice.TestCallProvider
docs/api-authentication.md added +54

@@ -0,0 +1,54 @@

1
# API authentication
2
3
Date: 2026-08-20
4
5
## Forge API clients
6
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.
10
11
Create a token in the authenticated browser at `/settings/api-tokens`. Choose a
12
name and a lifetime from 1 through 90 days. The `oa_pat_…` plaintext appears
13
once; OpenAgents stores only its SHA-256 digest. Send it as a bearer:
14
15
```sh
16
curl \
17
  --header "Authorization: Bearer $OPENAGENTS_API_TOKEN" \
18
  --header "Content-Type: application/json" \
19
  --data '{"title":"Example"}' \
20
  https://stage.openagents.com/api/v3/repos/OpenAgentsInc/openagents.com/issues
21
```
22
23
Do not put the token in a URL, command history, checked-in environment file, or
24
issue. Prefer an environment populated by the caller's credential store. The
25
server returns the same `401 invalid_api_token` response for missing,
26
malformed, expired, revoked, unknown, and wrong-scope credentials.
27
28
The settings page lists non-secret metadata and supports immediate revocation.
29
Account export includes the same metadata with `credential_exported: false`.
30
Product-data deletion retains API credentials until the person revokes them;
31
credential management is independent from conversation deletion.
32
33
## Browser JSON routes
34
35
`/api/tokens`, `/api/computers`, and `/api/computer-agent-jobs` support the
36
first-party browser interface. They require an active encrypted browser session
37
and CSRF protection. They are not a CLI authentication mechanism.
38
39
## Machines and internal inference
40
41
Controller pairing returns a poll secret that expires after 10 minutes and can
42
claim a machine credential once. Machine credentials are scoped to one owner,
43
machine, and tier; expire according to `OPENAGENTS_MACHINE_TOKEN_TTL_SECONDS`;
44
are stored only as digests after claim; allow one active channel registration;
45
and disconnect immediately on revocation or expiry.
46
47
The inference proxy accepts only a server-minted `sig_…` grant. Each grant is
48
scoped to a conversation and optional paired machine, expires, is
49
generation-fenced and revocable, and has call, token, and cost ceilings. It is
50
not an OpenAI credential and cannot select a model outside the grant.
51
52
`OpenAgentsWeb.RouteAuthority.inventory/0` is the executable inventory for
53
HTTP routes and endpoint sockets. The test gate fails when a new route does not
54
resolve to one of the admitted authority classes with a principal and scope.
docs/beam-hot-deployment-plan.md modified +1 -1

@@ -448,7 +448,7 @@ Use runtime configuration for policy and deployment environment values. Use safe

448 448
| `forge_fleet_rpc_timeout_ms` | Bounds each fleet operation | Fifteen seconds |
449 449
| `forge_target_repo` | Selects the deployed repository | Explicit production value |
450 450
| `forge_internal_git_url` | Gives the sidecar a canonical clone URL | Loopback or private network |
451
| `forge_operator_token` | Authenticates promotion and local clone | Required at runtime, never logged |
451
| `forge_operator_token` | Authenticates forge writes; the builder receives it from its runtime identity and uses askpass | Required at runtime, never placed in URLs, argv, or logs |
452 452
| `forge_expected_fleet_size` | Defines revision consistency and readiness | Explicit production value |
453 453
| `forge_artifact_store` | Selects durable artifact storage | Local adapter in development |
454 454
docs/decisions/0004-retain-scoped-github-access-tokens.md modified +14 -7

@@ -2,7 +2,7 @@

2 2
3 3
Date: 2026-08-20
4 4
5
Status: Accepted model; lifecycle hardening required before staging
5
Status: Accepted and implemented
6 6
7 7
## Context
8 8

@@ -19,12 +19,14 @@ operator-managed key, associate it with one active user, and never expose it to

19 19
LiveView assigns, browser payloads, logs, receipts, or telemetry.
20 20
21 21
Keep OpenAgents issue and project data in PostgreSQL; do not use a retained
22
GitHub token as authority for OpenAgents-owned records. The current callback
23
stores encrypted ciphertext and logout clears only the browser session. Gate 6
24
must add explicit disconnect/revocation behavior, define token removal during
25
product-data deletion or account restriction, and verify scopes, rotation,
26
failure behavior, redaction, and user disclosures. Those lifecycle actions are
27
requirements, not claims about the current implementation.
22
GitHub token as authority for OpenAgents-owned records. Logout clears only the
23
browser session. Explicit disconnect revokes the GitHub token before clearing
24
the local envelope. Product-data deletion retains the grant and says so;
25
disconnect is the independent credential-deletion action.
26
27
Use a versioned envelope with an active key ID and a temporary prior-key map.
28
Rewrap all retained grants transactionally before retiring an old key. Export
29
connection metadata but never ciphertext or plaintext.
28 30
29 31
## Consequences
30 32

@@ -32,3 +34,8 @@ requirements, not claims about the current implementation.

32 34
- Token retention becomes an explicit data-handling obligation.
33 35
- Sign-in identity and repository authorization remain separate decisions.
34 36
- Documentation and deletion paths must describe the retained credential.
37
- OAuth App `repo` is broader than the read tools need because GitHub offers no
38
  read-only private-source OAuth scope. Migrate to a fine-grained GitHub App
39
  before expanding the GitHub-backed tool surface.
40
- Public profile identity needs no scope, so do not add `read:user` to the
41
  retained-tools authorization request.
docs/github-api-issues-projects-assessment.md modified +4 -2

@@ -48,8 +48,10 @@ ordering, draft items, and organization projects remain unimplemented.

48 48
49 49
These are current measured behaviors, not hypothetical future concerns:
50 50
51
- `/api/v3` uses the generic API pipeline and does not yet have a deliberate
52
  bearer/PAT authorization model for CLI clients.
51
- `/api/v3` public reads and authenticated writes use separate pipelines.
52
  Writes require an expiring digest-only `oa_pat_…` bearer with exact
53
  `forge:write` scope. An authenticated person creates and revokes credentials
54
  at `/settings/api-tokens`; plaintext is shown once.
53 55
- The route's owner/repository values are not backed by a canonical repository
54 56
  foreign key across the issue/project schema.
55 57
- Project show, item, update-item, and field actions do not consistently enforce
docs/github-auth-plan.md modified +66 -31

@@ -2,7 +2,7 @@

2 2
3 3
Date: 2026-08-20
4 4
5
Status: Authentication implemented; token-lifecycle hardening pending Gate 6
5
Status: Implemented and locally verified for Gate 6
6 6
7 7
Decision: [ADR 0004](decisions/0004-retain-scoped-github-access-tokens.md)
8 8

@@ -15,27 +15,49 @@ GitHub serves two distinct roles:

15 15
2. A retained access token authorizes server-side GitHub repository tools with
16 16
   the user's delegated rights.
17 17
18
The application currently implements the second model. The callback stores the
19
access token as AES-256-GCM ciphertext in the local user row. It does not
20
discard the token after reading the GitHub profile. Documentation and data
21
rights must not claim otherwise.
18
The application implements the second model only after the person chooses the
19
button labeled **Sign in and enable GitHub tools** beside a retention
20
disclosure. The callback stores the access token as versioned AES-256-GCM
21
ciphertext in the local user row. It does not discard the token after reading
22
the GitHub profile.
23
24
The OAuth app requests only `repo`. GitHub exposes public profile identity with
25
no OAuth scope, so the redundant `read:user` scope is not requested. The
26
repository tools need to read repositories the user authorizes, including
27
private repositories. GitHub OAuth Apps do not offer read-only source-code
28
access, so `repo` is the narrowest OAuth App scope that satisfies that feature
29
even though the scope grants broad read/write repository and related project
30
rights. The consent UI says so. OpenAgents exposes only its bounded read tools
31
to this credential. A future GitHub App migration should replace this broad scope with
32
fine-grained, repository-selected read permissions. See GitHub's
33
[OAuth scope reference](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps)
34
and [authorization guidance](https://docs.github.com/en/apps/oauth-apps/using-oauth-apps/authorizing-oauth-apps).
22 35
23 36
## Implemented flow
24 37
25
1. `POST /auth/github` creates a high-entropy state value, a PKCE S256
38
1. `POST /auth/github?github_tools=enabled` refuses a request that does not
39
   carry the explicit tools choice, then creates a high-entropy state value, a PKCE S256
26 40
   challenge, and a short-lived PostgreSQL OAuth-attempt row.
27 41
2. The encrypted browser session carries only the attempt reference and PKCE
28 42
   verifier while GitHub handles authorization.
29 43
3. `GET /auth/github/callback` consumes the attempt exactly once, exchanges the
30
   code server-side, and reads the GitHub `/user` projection server-side.
44
   code server-side, refuses missing or different granted scopes, and reads the
45
   GitHub `/user` projection server-side.
31 46
4. `OpenAgents.Accounts` upserts the local account by numeric GitHub ID and
32 47
   refreshes the mutable login, name, and avatar projection.
33
5. `OpenAgents.Accounts.TokenVault` encrypts the access token with the configured
34
   key before `github_token_ciphertext` is stored.
48
5. `OpenAgents.Accounts.TokenVault` encrypts the access token in a version-2
49
   envelope carrying the non-secret active key ID before the ciphertext is
50
   stored. The row also records scopes and connection/rotation timestamps.
35 51
6. The authenticated session contains only the local user ID. Repository tools
36 52
   unseal the token server-side when an explicit GitHub operation needs it.
37 53
7. `DELETE /logout` clears the browser session but intentionally does not
38 54
   revoke the retained GitHub grant.
55
8. `DELETE /github/connection` authenticates the browser and uses the OAuth
56
   application's Basic-authenticated token-deletion endpoint. Local ciphertext
57
   is cleared only after GitHub returns `204`; a provider or configuration
58
   failure preserves the local record so the revocation can be retried. GitHub
59
   documents the endpoint in its
60
   [OAuth authorization REST API](https://docs.github.com/en/rest/apps/oauth-applications#delete-an-app-token).
39 61
40 62
The token must never enter LiveView assigns, HTML, JSON responses, logs,
41 63
telemetry, receipts, exception messages, build output, or exported account

@@ -49,33 +71,44 @@ Runtime configuration requires:

49 71
- `GITHUB_CLIENT_SECRET`
50 72
- `GITHUB_REDIRECT_URI`
51 73
- `GITHUB_TOKEN_ENCRYPTION_KEY`, a Base64-encoded 32-byte key
74
- `GITHUB_TOKEN_ENCRYPTION_KEY_ID`, a bounded non-secret identifier
75
- `GITHUB_TOKEN_DECRYPTION_KEYS_JSON`, an optional JSON object of at most 16
76
  environment-prefixed prior key IDs to Base64-encoded 32-byte keys during
77
  rotation; it must not repeat the active key ID
52 78
53 79
Production-mode validation requires an HTTPS callback with the configured
54 80
environment host. Tests use deterministic local configuration and fake Req
55 81
responses; they do not require a live GitHub credential.
56 82
57
## Hardening required before staging
58
59
Gate 6 owns the remaining lifecycle and disclosure work:
60
61
- Request and document the minimum scopes needed by the enabled GitHub tools.
62
- Show a clear user disclosure that delegated repository access is retained
63
  encrypted after sign-in.
64
- Add an explicit disconnect operation that deletes the local ciphertext and,
65
  where GitHub supports it for this OAuth application, revokes the grant.
66
- Define what account data deletion does to the retained token. The current
67
  product-data deletion keeps the minimal local account row, so token removal
68
  must be implemented and tested rather than inferred.
69
- Support encryption-key rotation with a versioned envelope and a rehearsed
70
  rewrap path.
71
- Fail closed when the key is missing, malformed, or belongs to the wrong
72
  environment, without printing token or key material.
73
- Normalize revoked/expired token failures and require reauthorization without
74
  exposing GitHub response bodies.
75
- Add log and telemetry scans for token, code, state, verifier, and callback
76
  query leakage.
77
- Document the token's presence as metadata in export/delete disclosures
78
  without exporting the credential itself.
83
Rows migrated from the pre-Gate-6 envelope retain `read:user,repo` metadata
84
because that is the grant they actually received. Before staging admission,
85
revoke those legacy grants and have their owners reconnect under `repo` only;
86
do not rewrite metadata to claim a provider-side scope reduction that did not
87
occur.
88
89
## Rotation and data rights
90
91
Rotate without losing access to existing ciphertext:
92
93
1. Generate a new 32-byte key and a new environment-specific key ID.
94
2. Make the new key active and put the prior ID/key in
95
   `GITHUB_TOKEN_DECRYPTION_KEYS_JSON`.
96
3. Prove release readiness, migrate, and run `bin/rotate-github-tokens`.
97
4. Verify `users.github_token_key_id` contains no prior ID, retain the rotation
98
   receipt/count, and remove the prior key in the following deploy.
99
100
The rewrap is one database transaction and reports only a count. Any
101
unsealable row rolls the transaction back and emits no credential material.
102
103
Account export includes connection status, scopes, connection time, and
104
rotation time plus `credential_exported: false`. Product-data deletion removes
105
conversation, voice, and memory data but deliberately retains the GitHub grant
106
with the minimal account row; the deletion UI and export say so. **Disconnect
107
GitHub tools** is the explicit grant-deletion operation.
108
109
OAuth callback parameter logging is disabled at the router, sensitive
110
parameter names are globally filtered, and the staging log export must pass the
111
scanner documented in [Secrets and log handling](security/secrets-and-log-handling.md).
79 112
80 113
## Executable evidence
81 114

@@ -87,6 +120,8 @@ Gate 6 owns the remaining lifecycle and disclosure work:

87 120
- `test/openagents/tools/github_repo_tools_test.exs`
88 121
- `test/openagents_web/auth_controller_test.exs`
89 122
- `test/openagents_web/auth_gate_test.exs`
123
- `test/openagents/log_safety_test.exs`
124
- `test/openagents_web/route_authority_test.exs`
90 125
91 126
The complete route-authority and secret-handling acceptance criteria remain in
92 127
[the hardening plan](2026-08-20-integration-hardening-and-staging-readiness-recommendations.md).
docs/issues-projects-work-plan.md modified +2 -2

@@ -26,8 +26,8 @@ the paired LiveViews are covered separately. The dated

26 26
The implementation tasks are no longer the readiness bottleneck. The resource
27 27
model must now be hardened rather than expanded from this checklist:
28 28
29
- `/api/v3` currently lacks a deliberate CLI authentication and authorization
30
  model.
29
- `/api/v3` now has a deliberate CLI model: public reads are separate and every
30
  write requires an expiring first-party bearer with `forge:write` scope.
31 31
- Owner/repository URL parameters do not yet map to a canonical repository row
32 32
  enforced throughout PostgreSQL.
33 33
- Some project actions ignore the username in the route.
docs/runtime-configuration.md modified +4 -1

@@ -68,10 +68,13 @@ URLs, receipts, or checked-in environment files.

68 68
| Database | `OPENAGENTS_MIGRATE_ON_BOOT` | `true` in staging and production |
69 69
| GitHub | `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | Staging OAuth application credentials |
70 70
| GitHub | `GITHUB_REDIRECT_URI` | Exact HTTPS callback on `PHX_HOST` |
71
| GitHub | `GITHUB_OAUTH_SCOPES` | Exactly `read:user,repo` for the retained-token tool model |
71
| GitHub | `GITHUB_OAUTH_SCOPES` | Exactly `repo`; profile identity needs no additional scope |
72 72
| GitHub | `GITHUB_TOKEN_ENCRYPTION_KEY` | Base64-encoded 32-byte staging key |
73
| GitHub | `GITHUB_TOKEN_ENCRYPTION_KEY_ID` | Bounded active-key identifier prefixed with `development-`, `test-`, `staging-`, or `production-` to match the runtime |
74
| GitHub | `GITHUB_TOKEN_DECRYPTION_KEYS_JSON` | Optional map of at most 16 same-environment prior keys used only during rewrap; omit the active ID |
73 75
| Providers | `OPENAI_API_KEY` | Staging-only provider secret; required by the current text provider |
74 76
| Providers | `OPENAGENTS_INFERENCE_PROXY_URL` | HTTPS URL without credentials when computers are enabled; empty disables |
77
| Computers | `OPENAGENTS_MACHINE_TOKEN_TTL_SECONDS` | `300` through `2592000`; Gate 5 uses the 30-day maximum |
75 78
| Recording | `VOICE_RECORDING_ENCRYPTION_KEY` | Base64-encoded 32-byte key when recording is enabled; empty disables recording storage |
76 79
77 80
`PHX_SERVER` is optional for evaluation commands. If set, it must be exactly
docs/security/secrets-and-log-handling.md added +108

@@ -0,0 +1,108 @@

1
# Staging secrets and log handling
2
3
Date: 2026-08-20
4
5
Status: Gate 6 application controls implemented; staging rotation and exported-log proof required before admission
6
7
## Runtime identities
8
9
Use separate workload identities even when the first staging topology places
10
more than one role on a node:
11
12
| Runtime identity | Purpose | May read |
13
| --- | --- | --- |
14
| `openagents-staging-web` | Phoenix release and forge Git endpoint | Web, provider, OAuth, vault, recording, forge verification, database, and cluster secrets listed below |
15
| `openagents-staging-migrator` | Release migration and token rewrap job | Database URL, active GitHub vault key, prior GitHub vault keyring |
16
| `openagents-staging-builder` | Isolated BEAM build sidecar | Forge operator token only; use an askpass helper, never a URL or argv value |
17
| `openagents-staging-operator` | Human-triggered staging operations | No application secrets by default; short-lived platform access to invoke jobs and read redacted logs |
18
19
Google Cloud workloads must use workload identity for Secret Manager, Cloud
20
SQL, object storage, and log access. Do not create or mount a service-account
21
JSON key. Grant each identity access to named secrets, not project-wide secret
22
access.
23
24
## Secret inventory
25
26
The names below are the required staging Secret Manager names. Production must
27
use distinct names and values and remains locked.
28
29
| Environment input | Staging secret name | Readers | Rotation trigger |
30
| --- | --- | --- | --- |
31
| `DATABASE_URL` | `openagents-staging-database-url` | web, migrator | Database credential rotation or suspected log/process exposure |
32
| `SECRET_KEY_BASE` | `openagents-staging-secret-key-base` | web | Suspected exposure; rotation invalidates browser sessions |
33
| `GITHUB_CLIENT_SECRET` | `openagents-staging-github-client-secret` | web | OAuth app rotation or suspected exposure |
34
| `GITHUB_TOKEN_ENCRYPTION_KEY` | `openagents-staging-github-vault-active` | web, migrator | Scheduled vault rotation or suspected exposure |
35
| `GITHUB_TOKEN_DECRYPTION_KEYS_JSON` | `openagents-staging-github-vault-previous` | web, migrator, only during rewrap | Delete after every row uses the active key ID |
36
| `OPENAI_API_KEY` | `openagents-staging-openai-api-key` | web | Provider rotation or suspected prompt/log exposure |
37
| `VOICE_RECORDING_ENCRYPTION_KEY` | `openagents-staging-voice-recording-key` | web when recording is admitted | Scheduled recording-key procedure or suspected exposure |
38
| `OPENAGENTS_FORGE_OPERATOR_TOKEN` | `openagents-staging-forge-operator-token` | web, builder | Scheduled rotation, builder replacement, or suspected URL/argv/log exposure |
39
| `RELEASE_COOKIE` | `openagents-staging-release-cookie` | web fleet nodes | Fleet-wide coordinated rotation or suspected exposure |
40
41
`GITHUB_CLIENT_ID` and `GITHUB_TOKEN_ENCRYPTION_KEY_ID` are identifiers, not
42
secrets. `DB_PASSWORD` is not used by the admitted staging profile because it
43
uses `DATABASE_URL`; if socket mode is admitted later, give it its own named
44
secret and update this table first. First-party API tokens, machine tokens,
45
pairing secrets, inference grants, browser cookies, and OAuth codes are minted
46
credentials, never deployment configuration and never Secret Manager values.
47
48
## Handling rules
49
50
- Inject secrets at runtime. Never use Docker build arguments, image layers,
51
  repository files, release receipts, command arguments, or repository URLs.
52
- The build queue contains an uncredentialed internal repository URL. The
53
  builder reads its forge secret through its workload identity and supplies it
54
  through `GIT_ASKPASS` with terminal prompting disabled.
55
- Keep the OAuth callback route's Phoenix dispatch logging disabled. Configure
56
  the external HTTPS load balancer to omit query strings for
57
  `/auth/github/callback`; a path and status are sufficient.
58
- Keep the global Phoenix parameter filter. Do not add ad hoc logging of
59
  connection params, request bodies, LiveView event params, provider payloads,
60
  exception structs, or tool results.
61
- Operational events may contain bounded IDs, status codes, counts, timings,
62
  model IDs, and digests. They may not contain messages, prompts, transcripts,
63
  memory claims, raw tool arguments/results, SDP, headers, cookies, or tokens.
64
- Public status responses follow their documented bounded projections. They do
65
  not expose node names, hosts, paths, environment values, queue contents, or
66
  internal exception text.
67
68
## Rotation procedures
69
70
For the GitHub vault, put the old ID/key into
71
`GITHUB_TOKEN_DECRYPTION_KEYS_JSON`, activate a new key and ID, prove readiness,
72
then run:
73
74
```sh
75
bin/rotate-github-tokens
76
```
77
78
The command is transactional and prints only `github_tokens_rotated=N`. Verify
79
that no user row carries the old key ID before removing the prior-key secret.
80
81
Before the first Gate 15 deployment, treat every pre-gate staging credential as
82
potentially logged. Rotate the database credential, endpoint secret, OAuth
83
client secret, provider key, forge token, release cookie, and any enabled
84
recording/vault key. Record only secret resource version IDs and timestamps in
85
the evidence receipt—never values. A credential is not considered rotated
86
until every old version is disabled and the exact candidate has restarted
87
successfully.
88
89
## Staging log acceptance
90
91
Export application, load-balancer, release-job, builder, and database proxy
92
logs for the complete test window into one access-controlled local file. Scan
93
that file without printing matching lines:
94
95
```sh
96
MIX_ENV=test mix run --no-start ops/ci/private-log-scan.exs /path/to/staging.log
97
```
98
99
The command reports only finding type and line number. It fails on credential
100
prefixes or bearer values, OAuth callback query parameters, credential-bearing
101
URLs, and unfiltered private-content fields. Manually inspect a representative
102
sample for plain-language message or transcript content that has no field key.
103
Delete the local export after recording its SHA-256 digest, bounded time range,
104
source set, scan result, reviewer, and candidate Git SHA.
105
106
Any finding blocks the gate. Rotate the affected credential, remove or bound
107
the log source, redeploy the same corrected candidate, and scan a new clean
108
window. Never copy a leaked value into an issue or evidence receipt.
lib/openagents/accounts.ex modified +143 -5

@@ -37,17 +37,34 @@ defmodule OpenAgents.Accounts do

37 37
    )
38 38
  end
39 39
40
  @doc "Seals and stores the user's GitHub OAuth access token for server-side API calls."
41
  @spec store_github_token(User.t(), String.t()) :: {:ok, User.t()} | {:error, atom()}
42
  def store_github_token(%User{} = user, token) when is_binary(token) do
43
    with {:ok, sealed} <- TokenVault.seal(token) do
40
  @doc "Seals and stores an explicitly authorized GitHub token and its non-secret metadata."
41
  @spec store_github_token(User.t(), String.t(), [String.t()]) ::
42
          {:ok, User.t()} | {:error, atom()}
43
  def store_github_token(%User{} = user, token, scopes \\ configured_github_scopes())
44
      when is_binary(token) and is_list(scopes) do
45
    with true <- valid_scopes?(scopes),
46
         {:ok, sealed, key_id} <- TokenVault.seal_with_metadata(token) do
47
      now = DateTime.utc_now()
48
44 49
      user
45
      |> Ecto.Changeset.change(github_token_ciphertext: sealed)
50
      |> Ecto.Changeset.change(
51
        github_token_ciphertext: sealed,
52
        github_token_key_id: key_id,
53
        github_token_scopes: scopes,
54
        github_token_connected_at: now,
55
        github_token_rotated_at: nil
56
      )
57
      |> Ecto.Changeset.check_constraint(:github_token_ciphertext,
58
        name: :users_github_token_connection_state_check
59
      )
46 60
      |> Repo.update()
47 61
      |> case do
48 62
        {:ok, updated} -> {:ok, updated}
49 63
        {:error, _changeset} -> {:error, :token_storage_failed}
50 64
      end
65
    else
66
      false -> {:error, :invalid_token_scopes}
67
      {:error, reason} -> {:error, reason}
51 68
    end
52 69
  end
53 70

@@ -58,6 +75,66 @@ defmodule OpenAgents.Accounts do

58 75
59 76
  def github_token(%User{}), do: {:error, :github_token_missing}
60 77
78
  @doc "Rewraps one retained token with the active key without exposing plaintext."
79
  @spec rotate_github_token(User.t()) :: {:ok, User.t()} | {:error, atom()}
80
  def rotate_github_token(%User{} = user) do
81
    with {:ok, token} <- github_token(user),
82
         {:ok, sealed, key_id} <- TokenVault.seal_with_metadata(token) do
83
      replace_github_envelope(user, sealed, key_id)
84
    end
85
  end
86
87
  @doc "Revokes the provider grant, then removes all local token material and metadata."
88
  @spec disconnect_github(User.t(), (String.t() -> :ok | {:error, atom()})) ::
89
          {:ok, User.t()} | {:error, atom()}
90
  def disconnect_github(%User{} = user, revoker \\ &OpenAgents.GitHubOAuth.revoke/1)
91
      when is_function(revoker, 1) do
92
    case github_token(user) do
93
      {:ok, token} ->
94
        with :ok <- revoker.(token), do: clear_github_token(user)
95
96
      {:error, :github_token_missing} ->
97
        clear_github_token(user)
98
99
      {:error, reason} ->
100
        {:error, reason}
101
    end
102
  end
103
104
  @doc "Non-secret GitHub connection metadata for UI and data-rights projections."
105
  @spec github_connection(User.t()) :: map()
106
  def github_connection(%User{} = user) do
107
    %{
108
      connected: is_binary(user.github_token_ciphertext),
109
      scopes: user.github_token_scopes,
110
      connected_at: user.github_token_connected_at,
111
      rotated_at: user.github_token_rotated_at
112
    }
113
  end
114
115
  @doc "Rewraps every retained GitHub token atomically; returns only the rotated count."
116
  @spec rotate_github_tokens!() :: non_neg_integer()
117
  def rotate_github_tokens! do
118
    Repo.transaction(fn ->
119
      from(user in User,
120
        where: not is_nil(user.github_token_ciphertext),
121
        order_by: [asc: user.id],
122
        lock: "FOR UPDATE"
123
      )
124
      |> Repo.stream(max_rows: 100)
125
      |> Enum.reduce(0, fn user, count ->
126
        case rotate_github_token(user) do
127
          {:ok, _rotated} -> count + 1
128
          {:error, reason} -> Repo.rollback(reason)
129
        end
130
      end)
131
    end)
132
    |> case do
133
      {:ok, count} -> count
134
      {:error, reason} -> raise "GitHub token rotation failed: #{reason}"
135
    end
136
  end
137
61 138
  def get_user(id) when is_binary(id) do
62 139
    case Ecto.UUID.cast(id) do
63 140
      {:ok, user_id} -> Repo.get(User, user_id)

@@ -151,4 +228,65 @@ defmodule OpenAgents.Accounts do

151 228
  end
152 229
153 230
  defp state_digest(state), do: :crypto.hash(:sha256, state)
231
232
  defp clear_github_token(%User{} = user) do
233
    now = DateTime.utc_now()
234
235
    {updated, _rows} =
236
      user
237
      |> matching_github_token_query()
238
      |> Repo.update_all(
239
        set: [
240
          github_token_ciphertext: nil,
241
          github_token_key_id: nil,
242
          github_token_scopes: [],
243
          github_token_connected_at: nil,
244
          github_token_rotated_at: nil,
245
          updated_at: now
246
        ]
247
      )
248
249
    if updated == 1 do
250
      {:ok, Repo.get!(User, user.id)}
251
    else
252
      {:error, :github_connection_changed}
253
    end
254
  end
255
256
  defp replace_github_envelope(user, sealed, key_id) do
257
    now = DateTime.utc_now()
258
259
    {updated, _rows} =
260
      user
261
      |> matching_github_token_query()
262
      |> Repo.update_all(
263
        set: [
264
          github_token_ciphertext: sealed,
265
          github_token_key_id: key_id,
266
          github_token_rotated_at: now,
267
          updated_at: now
268
        ]
269
      )
270
271
    if updated == 1 do
272
      {:ok, Repo.get!(User, user.id)}
273
    else
274
      {:error, :github_connection_changed}
275
    end
276
  end
277
278
  defp matching_github_token_query(%User{id: id, github_token_ciphertext: nil}) do
279
    from(user in User, where: user.id == ^id and is_nil(user.github_token_ciphertext))
280
  end
281
282
  defp matching_github_token_query(%User{id: id, github_token_ciphertext: ciphertext}) do
283
    from(user in User, where: user.id == ^id and user.github_token_ciphertext == ^ciphertext)
284
  end
285
286
  defp configured_github_scopes,
287
    do: Application.fetch_env!(:openagents, :github_oauth_scopes)
288
289
  defp valid_scopes?(scopes) do
290
    scopes == configured_github_scopes()
291
  end
154 292
end
lib/openagents/accounts/token_vault.ex modified +123 -17

@@ -1,46 +1,152 @@

1 1
defmodule OpenAgents.Accounts.TokenVault do
2
  @moduledoc "AES-256-GCM sealing for provider access tokens at rest."
2
  @moduledoc "Versioned AES-256-GCM sealing and rotation for GitHub access tokens at rest."
3 3
4
  @aad "openagents.github_access_token.v1"
4
  @version 2
5
  @legacy_version 1
6
  @aad_prefix "openagents.github_access_token.v2:"
7
  @legacy_aad "openagents.github_access_token.v1"
5 8
  @nonce_bytes 12
6 9
  @tag_bytes 16
7 10
  @maximum_token_bytes 512
11
  @maximum_key_id_bytes 64
8 12
9 13
  @spec seal(String.t()) :: {:ok, binary()} | {:error, atom()}
10 14
  def seal(token) when is_binary(token) and byte_size(token) in 1..@maximum_token_bytes do
11
    with {:ok, key} <- key() do
15
    with {:ok, sealed, _key_id} <- seal_with_metadata(token), do: {:ok, sealed}
16
  end
17
18
  def seal(_token), do: {:error, :invalid_token}
19
20
  @spec seal_with_metadata(String.t()) ::
21
          {:ok, binary(), String.t()} | {:error, atom()}
22
  def seal_with_metadata(token)
23
      when is_binary(token) and byte_size(token) in 1..@maximum_token_bytes do
24
    with {:ok, key_id, key} <- active_key() do
12 25
      nonce = :crypto.strong_rand_bytes(@nonce_bytes)
26
      aad = @aad_prefix <> key_id
13 27
14 28
      {ciphertext, tag} =
15
        :crypto.crypto_one_time_aead(:aes_256_gcm, key, nonce, token, @aad, true)
29
        :crypto.crypto_one_time_aead(:aes_256_gcm, key, nonce, token, aad, true)
16 30
17
      {:ok, <<1, nonce::binary, tag::binary, ciphertext::binary>>}
31
      {:ok,
32
       <<@version, byte_size(key_id), key_id::binary, nonce::binary, tag::binary,
33
         ciphertext::binary>>, key_id}
18 34
    end
19 35
  end
20 36
21
  def seal(_token), do: {:error, :invalid_token}
37
  def seal_with_metadata(_token), do: {:error, :invalid_token}
22 38
23 39
  @spec open(binary()) :: {:ok, String.t()} | {:error, atom()}
40
  def open(<<@version, key_id_size, rest::binary>>)
41
      when key_id_size in 1..@maximum_key_id_bytes do
42
    with <<key_id::binary-size(key_id_size), nonce::binary-size(@nonce_bytes),
43
           tag::binary-size(@tag_bytes), ciphertext::binary>> <- rest,
44
         {:ok, key} <- key_for(key_id) do
45
      decrypt(key, nonce, ciphertext, @aad_prefix <> key_id, tag)
46
    else
47
      {:error, reason} -> {:error, reason}
48
      _invalid -> {:error, :token_unsealable}
49
    end
50
  end
51
24 52
  def open(
25
        <<1, nonce::binary-size(@nonce_bytes), tag::binary-size(@tag_bytes), ciphertext::binary>>
53
        <<@legacy_version, nonce::binary-size(@nonce_bytes), tag::binary-size(@tag_bytes),
54
          ciphertext::binary>>
26 55
      ) do
27
    with {:ok, key} <- key() do
28
      case :crypto.crypto_one_time_aead(:aes_256_gcm, key, nonce, ciphertext, @aad, tag, false) do
29
        token when is_binary(token) -> {:ok, token}
30
        :error -> {:error, :token_unsealable}
31
      end
56
    case all_keys() do
57
      [] ->
58
        {:error, :token_vault_not_configured}
59
60
      keys ->
61
        case Enum.find_value(keys, fn key ->
62
               case decrypt(key, nonce, ciphertext, @legacy_aad, tag) do
63
                 {:ok, token} -> {:ok, token}
64
                 {:error, :token_unsealable} -> nil
65
               end
66
             end) do
67
          {:ok, token} -> {:ok, token}
68
          nil -> {:error, :token_unsealable}
69
        end
32 70
    end
33 71
  end
34 72
35 73
  def open(_sealed), do: {:error, :token_unsealable}
36 74
37
  defp key do
38
    with encoded when is_binary(encoded) <-
39
           Application.get_env(:openagents, :github_token_encryption_key),
40
         {:ok, key} when byte_size(key) == 32 <- Base.decode64(encoded) do
41
      {:ok, key}
75
  @spec key_id(binary()) :: {:ok, String.t()} | {:error, atom()}
76
  def key_id(<<@version, key_id_size, rest::binary>>)
77
      when key_id_size in 1..@maximum_key_id_bytes do
78
    case rest do
79
      <<key_id::binary-size(key_id_size), _rest::binary>> -> {:ok, key_id}
80
      _malformed -> {:error, :token_unsealable}
81
    end
82
  end
83
84
  def key_id(<<@legacy_version, _rest::binary>>), do: {:ok, "legacy-v1"}
85
  def key_id(_sealed), do: {:error, :token_unsealable}
86
87
  defp active_key do
88
    with key_id when is_binary(key_id) <-
89
           Application.get_env(:openagents, :github_token_encryption_key_id),
90
         true <- valid_key_id?(key_id),
91
         {:ok, key} <- decode_key(Application.get_env(:openagents, :github_token_encryption_key)) do
92
      {:ok, key_id, key}
42 93
    else
43 94
      _missing -> {:error, :token_vault_not_configured}
44 95
    end
45 96
  end
97
98
  defp key_for(key_id) do
99
    with {:ok, active_id, active_key} <- active_key() do
100
      if key_id == active_id do
101
        {:ok, active_key}
102
      else
103
        :openagents
104
        |> Application.get_env(:github_token_decryption_keys, %{})
105
        |> Map.get(key_id)
106
        |> decode_key()
107
      end
108
    end
109
  end
110
111
  defp all_keys do
112
    active =
113
      case active_key() do
114
        {:ok, _key_id, key} -> [key]
115
        {:error, _reason} -> []
116
      end
117
118
    previous =
119
      :openagents
120
      |> Application.get_env(:github_token_decryption_keys, %{})
121
      |> Map.values()
122
      |> Enum.flat_map(fn encoded ->
123
        case decode_key(encoded) do
124
          {:ok, key} -> [key]
125
          {:error, _reason} -> []
126
        end
127
      end)
128
129
    Enum.uniq(active ++ previous)
130
  end
131
132
  defp decrypt(key, nonce, ciphertext, aad, tag) do
133
    case :crypto.crypto_one_time_aead(:aes_256_gcm, key, nonce, ciphertext, aad, tag, false) do
134
      token when is_binary(token) -> {:ok, token}
135
      :error -> {:error, :token_unsealable}
136
    end
137
  end
138
139
  defp decode_key(encoded) when is_binary(encoded) do
140
    case Base.decode64(encoded) do
141
      {:ok, key} when byte_size(key) == 32 -> {:ok, key}
142
      _invalid -> {:error, :token_vault_not_configured}
143
    end
144
  end
145
146
  defp decode_key(_missing), do: {:error, :token_vault_not_configured}
147
148
  defp valid_key_id?(key_id) do
149
    byte_size(key_id) in 1..@maximum_key_id_bytes and
150
      String.match?(key_id, ~r/\A[a-zA-Z0-9][a-zA-Z0-9._-]*\z/)
151
  end
46 152
end
lib/openagents/accounts/user.ex modified +8

@@ -18,6 +18,10 @@ defmodule OpenAgents.Accounts.User do

18 18
    field :ban_reason_code, :string
19 19
    field :last_authenticated_at, :utc_datetime_usec
20 20
    field :github_token_ciphertext, :binary, redact: true
21
    field :github_token_key_id, :string
22
    field :github_token_scopes, {:array, :string}, default: []
23
    field :github_token_connected_at, :utc_datetime_usec
24
    field :github_token_rotated_at, :utc_datetime_usec
21 25
    field :public_leaderboard_opted_out, :boolean, default: false
22 26
    field :browser_key_hash, :binary
23 27

@@ -37,6 +41,10 @@ defmodule OpenAgents.Accounts.User do

37 41
          ban_reason_code: String.t() | nil,
38 42
          last_authenticated_at: DateTime.t() | nil,
39 43
          github_token_ciphertext: binary() | nil,
44
          github_token_key_id: String.t() | nil,
45
          github_token_scopes: [String.t()],
46
          github_token_connected_at: DateTime.t() | nil,
47
          github_token_rotated_at: DateTime.t() | nil,
40 48
          public_leaderboard_opted_out: boolean(),
41 49
          browser_key_hash: binary() | nil,
42 50
          inserted_at: DateTime.t(),
lib/openagents/api_tokens.ex added +154

@@ -0,0 +1,154 @@

1
defmodule OpenAgents.ApiTokens do
2
  @moduledoc "Scoped, expiring first-party credentials for non-browser API clients."
3
4
  import Ecto.Query
5
6
  alias OpenAgents.Accounts.User
7
  alias OpenAgents.ApiTokens.ApiToken
8
  alias OpenAgents.Repo
9
10
  @prefix "oa_pat_"
11
  @allowed_scopes ["forge:write"]
12
  @maximum_lifetime_days 90
13
14
  @spec create(User.t(), map()) ::
15
          {:ok, ApiToken.t(), String.t()} | {:error, Ecto.Changeset.t() | atom()}
16
  def create(%User{id: user_id}, attributes) when is_map(attributes) do
17
    with {:ok, name} <- name(attributes),
18
         {:ok, scopes} <- scopes(attributes),
19
         {:ok, lifetime_days} <- lifetime_days(attributes) do
20
      id = Ecto.UUID.generate()
21
      secret = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
22
      plaintext = @prefix <> id <> "." <> secret
23
24
      %ApiToken{id: id, user_id: user_id, token_digest: digest(plaintext)}
25
      |> ApiToken.create_changeset(%{
26
        name: name,
27
        scopes: scopes,
28
        expires_at: DateTime.add(DateTime.utc_now(), lifetime_days, :day)
29
      })
30
      |> Repo.insert()
31
      |> case do
32
        {:ok, token} -> {:ok, token, plaintext}
33
        {:error, changeset} -> {:error, changeset}
34
      end
35
    end
36
  end
37
38
  def create(%User{}, _attributes), do: {:error, :invalid_api_token}
39
40
  @spec authenticate(String.t(), String.t()) ::
41
          {:ok, User.t(), ApiToken.t()} | {:error, :invalid_api_token}
42
  def authenticate(@prefix <> rest = plaintext, required_scope)
43
      when byte_size(plaintext) < 160 and required_scope in @allowed_scopes do
44
    result =
45
      Repo.transaction(fn ->
46
        with [id, secret] <- String.split(rest, ".", parts: 2),
47
             true <- byte_size(secret) in 40..64,
48
             {:ok, token_id} <- Ecto.UUID.cast(id),
49
             %ApiToken{} = token <-
50
               Repo.one(from(t in ApiToken, where: t.id == ^token_id, lock: "FOR UPDATE")),
51
             true <- Plug.Crypto.secure_compare(token.token_digest, digest(plaintext)),
52
             true <- usable?(token, required_scope),
53
             %User{status: "active"} = user <- Repo.get(User, token.user_id) do
54
          now = DateTime.utc_now()
55
          Repo.update_all(from(t in ApiToken, where: t.id == ^token.id), set: [last_used_at: now])
56
          {user, %{token | last_used_at: now}}
57
        else
58
          _invalid -> Repo.rollback(:invalid_api_token)
59
        end
60
      end)
61
62
    case result do
63
      {:ok, {user, token}} -> {:ok, user, token}
64
      {:error, _invalid} -> {:error, :invalid_api_token}
65
    end
66
  end
67
68
  def authenticate(_plaintext, _required_scope), do: {:error, :invalid_api_token}
69
70
  @spec list(User.t()) :: [ApiToken.t()]
71
  def list(%User{id: user_id}) do
72
    Repo.all(
73
      from(token in ApiToken,
74
        where: token.user_id == ^user_id,
75
        order_by: [desc: token.inserted_at]
76
      )
77
    )
78
  end
79
80
  @spec revoke(User.t(), String.t()) :: {:ok, ApiToken.t()} | {:error, :not_found}
81
  def revoke(%User{id: user_id}, id) when is_binary(id) do
82
    with {:ok, token_id} <- Ecto.UUID.cast(id),
83
         %ApiToken{user_id: ^user_id} = token <- Repo.get(ApiToken, token_id) do
84
      token
85
      |> Ecto.Changeset.change(revoked_at: DateTime.utc_now())
86
      |> Repo.update()
87
    else
88
      _missing -> {:error, :not_found}
89
    end
90
  end
91
92
  def revoke(%User{}, _id), do: {:error, :not_found}
93
94
  @spec metadata(User.t()) :: [map()]
95
  def metadata(%User{} = user) do
96
    Enum.map(list(user), fn token ->
97
      %{
98
        id: token.id,
99
        name: token.name,
100
        scopes: token.scopes,
101
        expires_at: token.expires_at,
102
        last_used_at: token.last_used_at,
103
        revoked_at: token.revoked_at,
104
        inserted_at: token.inserted_at
105
      }
106
    end)
107
  end
108
109
  defp usable?(token, required_scope) do
110
    is_nil(token.revoked_at) and required_scope in token.scopes and
111
      DateTime.compare(DateTime.utc_now(), token.expires_at) == :lt
112
  end
113
114
  defp name(attributes) do
115
    case Map.get(attributes, "name") || Map.get(attributes, :name) do
116
      value when is_binary(value) ->
117
        case String.trim(value) do
118
          "" -> {:error, :invalid_api_token}
119
          trimmed -> {:ok, String.slice(trimmed, 0, 80)}
120
        end
121
122
      _invalid ->
123
        {:error, :invalid_api_token}
124
    end
125
  end
126
127
  defp scopes(attributes) do
128
    requested = Map.get(attributes, "scopes") || Map.get(attributes, :scopes)
129
130
    if is_list(requested) and requested != [] and
131
         Enum.all?(requested, &(&1 in @allowed_scopes)) do
132
      {:ok, Enum.uniq(requested)}
133
    else
134
      {:error, :invalid_api_token}
135
    end
136
  end
137
138
  defp lifetime_days(attributes) do
139
    case Map.get(attributes, "lifetime_days") || Map.get(attributes, :lifetime_days) || 30 do
140
      days when is_integer(days) and days in 1..@maximum_lifetime_days -> {:ok, days}
141
      days when is_binary(days) -> parse_lifetime_days(days)
142
      _invalid -> {:error, :invalid_api_token}
143
    end
144
  end
145
146
  defp parse_lifetime_days(value) do
147
    case Integer.parse(value) do
148
      {days, ""} when days in 1..@maximum_lifetime_days -> {:ok, days}
149
      _invalid -> {:error, :invalid_api_token}
150
    end
151
  end
152
153
  defp digest(value), do: :crypto.hash(:sha256, value)
154
end
lib/openagents/api_tokens/api_token.ex added +36

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

1
defmodule OpenAgents.ApiTokens.ApiToken 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 "api_tokens" do
12
    belongs_to :user, OpenAgents.Accounts.User
13
    field :name, :string
14
    field :token_digest, :binary, redact: true
15
    field :scopes, {:array, :string}, default: []
16
    field :expires_at, :utc_datetime_usec
17
    field :last_used_at, :utc_datetime_usec
18
    field :revoked_at, :utc_datetime_usec
19
20
    timestamps()
21
  end
22
23
  @type t :: %__MODULE__{}
24
25
  def create_changeset(token, attributes) do
26
    token
27
    |> cast(attributes, [:name, :scopes, :expires_at])
28
    |> validate_required([:name, :scopes, :expires_at])
29
    |> validate_length(:name, min: 1, max: 80)
30
    |> validate_length(:scopes, min: 1, max: 8)
31
    |> check_constraint(:scopes, name: :api_tokens_scopes_present)
32
    |> check_constraint(:scopes, name: :api_tokens_scopes_allowed)
33
    |> check_constraint(:expires_at, name: :api_tokens_expiry_after_creation)
34
    |> check_constraint(:token_digest, name: :api_tokens_digest_length)
35
  end
36
end
lib/openagents/changelog/backfill.ex modified +4 -2

@@ -24,9 +24,11 @@ defmodule OpenAgents.Changelog.Backfill do

24 24
25 25
    :ok
26 26
  rescue
27
    error -> Logger.warning("changelog backfill failed: #{Exception.message(error)}")
27
    error ->
28
      Logger.warning("changelog_backfill_failed code=#{OpenAgents.OperationalLog.code(error)}")
28 29
  catch
29
    :exit, reason -> Logger.warning("changelog backfill exited: #{inspect(reason)}")
30
    :exit, reason ->
31
      Logger.warning("changelog_backfill_exited code=#{OpenAgents.OperationalLog.code(reason)}")
30 32
  end
31 33
32 34
  @doc "Insert every seed entry idempotently. Returns the inserted count."
lib/openagents/cluster/drain.ex modified +2 -2

@@ -51,7 +51,7 @@ defmodule OpenAgents.Cluster.Drain do

51 51
          :ok
52 52
53 53
        other ->
54
          Logger.warning("drain: leave_ra returned #{inspect(other)}")
54
          Logger.warning("drain_leave_ra_refused code=#{OpenAgents.OperationalLog.code(other)}")
55 55
          :ok
56 56
      end
57 57
    else

@@ -59,7 +59,7 @@ defmodule OpenAgents.Cluster.Drain do

59 59
    end
60 60
  rescue
61 61
    error ->
62
      Logger.warning("drain: leave_ra error #{inspect(error)}")
62
      Logger.warning("drain_leave_ra_failed code=#{OpenAgents.OperationalLog.code(error)}")
63 63
      :ok
64 64
  end
65 65
lib/openagents/cluster/ra_bootstrap.ex modified +1 -1

@@ -115,7 +115,7 @@ defmodule OpenAgents.Cluster.RaBootstrap do

115 115
    end
116 116
  rescue
117 117
    error ->
118
      Logger.warning("ra_bootstrap: converge error #{inspect(error)}")
118
      Logger.warning("ra_bootstrap_converge_failed code=#{OpenAgents.OperationalLog.code(error)}")
119 119
      :ok
120 120
  end
121 121
lib/openagents/data_rights.ex modified +31 -2

@@ -15,7 +15,7 @@ defmodule OpenAgents.DataRights do

15 15
  }
16 16
17 17
  alias OpenAgents.Memory.SemanticDerivativeReceipt
18
  alias OpenAgents.{Conversations, ProfileMemory, Repo}
18
  alias OpenAgents.{Accounts, ApiTokens, Conversations, ProfileMemory, Repo}
19 19
  alias OpenAgents.Voice.{ResponseContext, ResponseReceipt, Session, TranscriptItem}
20 20
21 21
  @maximum_export_messages 10_000

@@ -30,7 +30,7 @@ defmodule OpenAgents.DataRights do

30 30
31 31
  @spec export(User.t(), Visitor.t(), Conversation.t()) :: {:ok, map()} | {:error, term()}
32 32
  def export(
33
        %User{id: user_id},
33
        %User{id: user_id} = user,
34 34
        %Visitor{id: visitor_id, user_id: user_id} = owner,
35 35
        %Conversation{visitor_id: visitor_id} = conversation
36 36
      ) do

@@ -61,6 +61,8 @@ defmodule OpenAgents.DataRights do

61 61
         "schema" => "sarah.account_data_export.v1",
62 62
         "exported_at" => DateTime.to_iso8601(DateTime.utc_now()),
63 63
         "scope" => "authenticated_github_user",
64
         "github_connection" => github_connection_export(user),
65
         "api_credentials" => Enum.map(ApiTokens.metadata(user), &api_credential_export/1),
64 66
         "messages" =>
65 67
           messages |> Enum.take(@maximum_export_messages) |> Enum.map(&message_export/1),
66 68
         "messages_truncated" => length(messages) > @maximum_export_messages,

@@ -270,4 +272,31 @@ defmodule OpenAgents.DataRights do

270 272
271 273
  defp iso8601(nil), do: nil
272 274
  defp iso8601(timestamp), do: DateTime.to_iso8601(timestamp)
275
276
  defp github_connection_export(user) do
277
    connection = Accounts.github_connection(user)
278
279
    %{
280
      "connected" => connection.connected,
281
      "scopes" => connection.scopes,
282
      "connected_at" => iso8601(connection.connected_at),
283
      "rotated_at" => iso8601(connection.rotated_at),
284
      "credential_exported" => false,
285
      "product_data_deletion" => "retained_until_explicit_disconnect"
286
    }
287
  end
288
289
  defp api_credential_export(credential) do
290
    %{
291
      "id" => credential.id,
292
      "name" => credential.name,
293
      "scopes" => credential.scopes,
294
      "created_at" => iso8601(credential.inserted_at),
295
      "expires_at" => iso8601(credential.expires_at),
296
      "last_used_at" => iso8601(credential.last_used_at),
297
      "revoked_at" => iso8601(credential.revoked_at),
298
      "credential_exported" => false,
299
      "product_data_deletion" => "retained_until_explicit_revocation"
300
    }
301
  end
273 302
end
lib/openagents/forge/boot_converge.ex modified +5 -5

@@ -51,9 +51,11 @@ defmodule OpenAgents.Forge.BootConverge do

51 51
      try do
52 52
        attempt(repo)
53 53
      rescue
54
        error -> %{"state" => "image", "reason" => bounded(Exception.message(error))}
54
        error ->
55
          %{"state" => "image", "reason" => OpenAgents.OperationalLog.code(error)}
55 56
      catch
56
        _kind, reason -> %{"state" => "image", "reason" => bounded(inspect(reason))}
57
        _kind, reason ->
58
          %{"state" => "image", "reason" => OpenAgents.OperationalLog.code(reason)}
57 59
      end
58 60
59 61
    :persistent_term.put(@state_key, outcome)

@@ -135,7 +137,7 @@ defmodule OpenAgents.Forge.BootConverge do

135 137
      if failures == [] do
136 138
        %{"state" => "converged", "sha" => sha, "modules" => length(beams)}
137 139
      else
138
        %{"state" => "image", "reason" => bounded("load_failed: #{inspect(failures)}")}
140
        %{"state" => "image", "reason" => "load_failed"}
139 141
      end
140 142
    end
141 143
  end

@@ -153,6 +155,4 @@ defmodule OpenAgents.Forge.BootConverge do

153 155
  end
154 156
155 157
  defp default_allowlist, do: ["OpenAgents.Scratch.", "OpenAgents.BuildInfo"]
156
157
  defp bounded(text), do: String.slice(to_string(text), 0, 500)
158 158
end
lib/openagents/forge/browse.ex modified +5 -2

@@ -58,13 +58,16 @@ defmodule OpenAgents.Forge.Browse do

58 58
  rescue
59 59
    error ->
60 60
      Logger.warning(
61
        "forge browse: sync failed for #{repo}, serving local cache: #{Exception.message(error)}"
61
        "forge_browse_sync_failed repo=#{repo} code=#{OpenAgents.OperationalLog.code(error)}"
62 62
      )
63 63
64 64
      :ok
65 65
  catch
66 66
    :exit, reason ->
67
      Logger.warning("forge browse: sync exited for #{repo}: #{inspect(reason)}")
67
      Logger.warning(
68
        "forge_browse_sync_exited repo=#{repo} code=#{OpenAgents.OperationalLog.code(reason)}"
69
      )
70
68 71
      :ok
69 72
  end
70 73
lib/openagents/forge/build_executor.ex modified +8 -12

@@ -32,6 +32,8 @@ defmodule OpenAgents.Forge.BuildExecutor do

32 32
  """
33 33
  @spec bound_output(String.t(), pos_integer()) :: String.t()
34 34
  def bound_output(output, max_bytes \\ @max_output_bytes) when is_binary(output) do
35
    output = OpenAgents.LogSafety.redact(output)
36
35 37
    if byte_size(output) <= max_bytes do
36 38
      output
37 39
    else

@@ -50,8 +52,9 @@ defmodule OpenAgents.Forge.BuildExecutor.Sidecar do

50 52
51 53
    * write `<sha>.job.tmp` then rename to `<sha>.job`, containing
52 54
      env-style `SHA=` and `REPO_URL=` lines; the URL points at the
53
      *local* forge (never GitHub) with the operator token embedded as
54
      userinfo
55
      *local* forge (never GitHub) and never contains credentials. The sidecar
56
      receives its forge credential from its own runtime identity and uses an
57
      askpass helper, so no token reaches a URL, argv, or build output
55 58
    * the watcher clones/fetches, checks out the SHA, compiles with
56 59
      `MIX_ENV=prod`, diffs beams against its manifest, writes the
57 60
      changed-beam tar to `<data_dir>/beams/<sha>.tar`, and answers with

@@ -211,16 +214,9 @@ defmodule OpenAgents.Forge.BuildExecutor.Sidecar do

211 214
    )
212 215
  end
213 216
214
  defp repo_url(repo) do
217
  @doc false
218
  def repo_url(repo) do
215 219
    base = Application.get_env(:openagents, :forge_internal_git_url, "http://127.0.0.1:8080/git")
216
    uri = URI.parse(base)
217
218
    uri =
219
      case Application.get_env(:openagents, :forge_operator_token) do
220
        nil -> uri
221
        token -> %{uri | userinfo: "x:" <> token}
222
      end
223
224
    URI.to_string(uri) <> "/" <> repo <> ".git"
220
    URI.to_string(URI.parse(base)) <> "/" <> repo <> ".git"
225 221
  end
226 222
end
lib/openagents/forge/builder.ex modified +22 -8

@@ -74,12 +74,12 @@ defmodule OpenAgents.Forge.Builder do

74 74
        end
75 75
76 76
      {:error, reason} ->
77
        Logger.debug("forge build: not this node's build (#{inspect(reason)})")
77
        Logger.debug("forge_build_not_owner code=#{OpenAgents.OperationalLog.code(reason)}")
78 78
        :ok
79 79
    end
80 80
  rescue
81 81
    error ->
82
      fail(target_id, "builder crashed: " <> Exception.message(error))
82
      fail(target_id, "builder_crashed code=" <> OpenAgents.OperationalLog.code(error))
83 83
  end
84 84
85 85
  defp finish(repo, sha, target_id, result) do

@@ -98,7 +98,9 @@ defmodule OpenAgents.Forge.Builder do

98 98
        )
99 99
100 100
      {:error, reason} ->
101
        Logger.warning("forge build: advance to built failed: #{inspect(reason)}")
101
        Logger.warning(
102
          "forge_build_advance_failed status=built code=#{OpenAgents.OperationalLog.code(reason)}"
103
        )
102 104
    end
103 105
  end
104 106

@@ -108,7 +110,9 @@ defmodule OpenAgents.Forge.Builder do

108 110
        :ok
109 111
110 112
      {:error, reason} ->
111
        Logger.warning("forge build: advance to failed failed: #{inspect(reason)}")
113
        Logger.warning(
114
          "forge_build_advance_failed status=failed code=#{OpenAgents.OperationalLog.code(reason)}"
115
        )
112 116
    end
113 117
  end
114 118

@@ -143,15 +147,25 @@ defmodule OpenAgents.Forge.Builder do

143 147
    case File.read(artifact_abs) do
144 148
      {:ok, payload} ->
145 149
        case OpenAgents.Forge.WAL.put_artifact(repo, sha, payload) do
146
          {:ok, _key} -> :ok
147
          {:error, reason} -> Logger.warning("forge artifact upload failed: #{inspect(reason)}")
150
          {:ok, _key} ->
151
            :ok
152
153
          {:error, reason} ->
154
            Logger.warning(
155
              "forge_artifact_upload_failed code=#{OpenAgents.OperationalLog.code(reason)}"
156
            )
148 157
        end
149 158
150 159
      {:error, reason} ->
151
        Logger.warning("forge artifact read for upload failed: #{inspect(reason)}")
160
        Logger.warning(
161
          "forge_artifact_read_failed code=#{OpenAgents.OperationalLog.code(reason)}"
162
        )
152 163
    end
153 164
  rescue
154
    error -> Logger.warning("forge artifact upload crashed: #{Exception.message(error)}")
165
    error ->
166
      Logger.warning(
167
        "forge_artifact_upload_crashed code=#{OpenAgents.OperationalLog.code(error)}"
168
      )
155 169
  end
156 170
157 171
  defp record_receipt(repo, sha, target_id, modules, artifact_rel, result) do
lib/openagents/forge/hot_loader.ex modified +10 -7

@@ -124,8 +124,8 @@ defmodule OpenAgents.Forge.HotLoader do

124 124
    end
125 125
  rescue
126 126
    error ->
127
      message = bounded(Exception.message(error))
128
      Logger.error("forge hot-load failed for #{inspect(build)}: #{message}")
127
      message = "hot_load_failed code=" <> OpenAgents.OperationalLog.code(error)
128
      Logger.error("forge_hot_load_failed code=#{OpenAgents.OperationalLog.code(error)}")
129 129
      advance(target_id, "failed", %{"error" => message})
130 130
      insert_receipt(repo, sha, target_id, modules, [], "failed", nil, nil)
131 131
      broadcast_deploy(repo, sha, "failed")

@@ -326,7 +326,7 @@ defmodule OpenAgents.Forge.HotLoader do

326 326
    |> Repo.insert()
327 327
  rescue
328 328
    error ->
329
      Logger.error("forge deploy receipt insert failed: #{inspect(error)}")
329
      Logger.error("forge_deploy_receipt_failed code=#{OpenAgents.OperationalLog.code(error)}")
330 330
      :error
331 331
  end
332 332

@@ -337,17 +337,20 @@ defmodule OpenAgents.Forge.HotLoader do

337 337
338 338
      {:error, reason} ->
339 339
        Logger.warning(
340
          "forge hot-load: target #{target_id} advance to #{status} refused: #{inspect(reason)}"
340
          "forge_hot_load_advance_refused target=#{target_id} status=#{status} " <>
341
            "code=#{OpenAgents.OperationalLog.code(reason)}"
341 342
        )
342 343
343 344
        :error
344 345
    end
345 346
  rescue
346 347
    error ->
347
      Logger.error("forge hot-load: target advance raised: #{inspect(error)}")
348
      Logger.error("forge_hot_load_advance_failed code=#{OpenAgents.OperationalLog.code(error)}")
348 349
      :error
349 350
  end
350 351
351
  defp bounded(text) when is_binary(text), do: String.slice(text, 0, 500)
352
  defp bounded(other), do: other |> inspect() |> String.slice(0, 500)
352
  defp bounded(text) when is_binary(text),
353
    do: text |> OpenAgents.LogSafety.redact() |> String.slice(0, 500)
354
355
  defp bounded(other), do: OpenAgents.OperationalLog.code(other)
353 356
end
lib/openagents/forge/janitor.ex modified +1 -1

@@ -56,7 +56,7 @@ defmodule OpenAgents.Forge.Janitor do

56 56
    {sweep_clones(now_ms), sweep_artifacts(now_ms)}
57 57
  rescue
58 58
    error ->
59
      Logger.warning("forge janitor sweep failed: #{Exception.message(error)}")
59
      Logger.warning("forge_janitor_sweep_failed code=#{OpenAgents.OperationalLog.code(error)}")
60 60
      {0, 0}
61 61
  end
62 62
lib/openagents/forge/mirror_watch.ex modified +1 -1

@@ -61,7 +61,7 @@ defmodule OpenAgents.Forge.MirrorWatch do

61 61
    end
62 62
  rescue
63 63
    error ->
64
      Logger.warning("mirror watch failed: #{Exception.message(error)}")
64
      Logger.warning("forge_mirror_watch_failed code=#{OpenAgents.OperationalLog.code(error)}")
65 65
      state
66 66
  end
67 67
lib/openagents/forge/pushes.ex modified +32 -9

@@ -62,7 +62,10 @@ defmodule OpenAgents.Forge.Pushes do

62 62
            {:ok, output}
63 63
64 64
          {:error, reason} ->
65
            Logger.error("forge push: WAL persist failed for #{repo}: #{inspect(reason)}")
65
            Logger.error(
66
              "forge_push_wal_failed repo=#{repo} code=#{OpenAgents.OperationalLog.code(reason)}"
67
            )
68
66 69
            Repos.set_refs!(repo, refs_before)
67 70
            {:error, :wal_persist_failed}
68 71
        end

@@ -176,7 +179,7 @@ defmodule OpenAgents.Forge.Pushes do

176 179
    |> Repo.insert(on_conflict: :nothing, conflict_target: [:repo, :wal_seq])
177 180
  rescue
178 181
    error ->
179
      Logger.error("forge push: receipt insert failed: #{inspect(error)}")
182
      Logger.error("forge_push_receipt_failed code=#{OpenAgents.OperationalLog.code(error)}")
180 183
      :error
181 184
  end
182 185

@@ -204,7 +207,8 @@ defmodule OpenAgents.Forge.Pushes do

204 207
  Push the bare repo to its configured mirror, synchronously (#127). One-way,
205 208
  best-effort, never load-bearing: a failure logs and returns an error for
206 209
  the drift watcher to count — it never blocks or fails a forge push. The
207
  mirror URL may embed a credential and is never included in logs or output.
210
  mirror URL must not embed a credential; authentication belongs to the git
211
  credential helper or workload identity and output is never logged.
208 212
  """
209 213
  def mirror_now(repo) do
210 214
    case mirror_url(repo) do

@@ -218,8 +222,8 @@ defmodule OpenAgents.Forge.Pushes do

218 222
          {_, 0} ->
219 223
            :ok
220 224
221
          {output, _} ->
222
            Logger.warning("forge mirror failed for #{repo}: #{bounded(redact(output, url))}")
225
          {_output, _} ->
226
            Logger.warning("forge_mirror_failed repo=#{repo} code=mirror_push_failed")
223 227
            {:error, :mirror_push_failed}
224 228
        end
225 229
    end

@@ -228,13 +232,32 @@ defmodule OpenAgents.Forge.Pushes do

228 232
  @doc "The configured mirror URL for a repo, or nil (config `:forge_mirror_urls`)."
229 233
  def mirror_url(repo) do
230 234
    case Application.get_env(:openagents, :forge_mirror_urls, %{}) do
231
      %{} = urls -> urls[repo]
235
      %{} = urls -> clean_mirror_url(urls[repo])
232 236
      _ -> nil
233 237
    end
234 238
  end
235 239
236
  # git sometimes prints the remote URL (with credential) in errors.
237
  defp redact(output, url), do: String.replace(output, url, "[mirror]")
240
  defp clean_mirror_url(nil), do: nil
241
242
  defp clean_mirror_url(url) when is_binary(url) do
243
    cond do
244
      String.contains?(url, ["\n", "\r", "\0"]) ->
245
        nil
246
247
      Path.type(url) == :absolute ->
248
        url
249
250
      true ->
251
        case URI.new(url) do
252
          {:ok, %URI{scheme: scheme, host: host, userinfo: nil}}
253
          when scheme in ["http", "https", "git", "ssh"] and is_binary(host) ->
254
            url
255
256
          _credentialed_or_invalid ->
257
            nil
258
        end
259
    end
260
  end
238 261
239
  defp bounded(text), do: String.slice(text, 0, 500)
262
  defp clean_mirror_url(_invalid), do: nil
240 263
end
lib/openagents/forge/sync.ex modified +4 -1

@@ -25,7 +25,10 @@ defmodule OpenAgents.Forge.Sync do

25 25
        replay_missing(repo, index)
26 26
27 27
      {:error, reason} ->
28
        Logger.warning("forge sync: WAL unreachable for #{repo}: #{inspect(reason)}")
28
        Logger.warning(
29
          "forge_sync_wal_unreachable repo=#{repo} code=#{OpenAgents.OperationalLog.code(reason)}"
30
        )
31
29 32
        :ok
30 33
    end
31 34
  end
lib/openagents/github_oauth.ex modified +68 -7

@@ -7,6 +7,7 @@ defmodule OpenAgents.GitHubOAuth do

7 7
  @default_authorize_url "https://github.com/login/oauth/authorize"
8 8
  @default_token_url "https://github.com/login/oauth/access_token"
9 9
  @default_user_url "https://api.github.com/user"
10
  @default_api_url "https://api.github.com"
10 11
  @default_attempt_ttl_seconds 600
11 12
  @github_api_version "2022-11-28"
12 13
  @user_agent "OpenAgents"

@@ -64,14 +65,51 @@ defmodule OpenAgents.GitHubOAuth do

64 65
  def exchange_and_fetch(code, verifier) when is_binary(code) and is_binary(verifier) do
65 66
    with :ok <- validate_code_and_verifier(code, verifier),
66 67
         {:ok, config} <- config(),
67
         {:ok, access_token} <- exchange_code(config, code, verifier),
68
         {:ok, access_token, scopes} <- exchange_code(config, code, verifier),
68 69
         {:ok, profile} <- fetch_profile(config, access_token) do
69
      {:ok, profile, access_token}
70
      {:ok, profile, access_token, scopes}
70 71
    end
71 72
  end
72 73
73 74
  def exchange_and_fetch(_code, _verifier), do: {:error, :invalid_oauth_callback}
74 75
76
  @doc "Revokes one OAuth grant using the OAuth application's own credentials."
77
  @spec revoke(String.t()) :: :ok | {:error, atom()}
78
  def revoke(access_token)
79
      when is_binary(access_token) and byte_size(access_token) in 1..512 do
80
    with {:ok, config} <- config() do
81
      request_options =
82
        [
83
          auth: {:basic, config.client_id <> ":" <> config.client_secret},
84
          json: %{"access_token" => access_token},
85
          headers: api_headers(),
86
          receive_timeout: 10_000,
87
          retry: false
88
        ]
89
        |> Keyword.merge(config.request_options)
90
91
      case Req.delete(config.revoke_url, request_options) do
92
        {:ok, %Req.Response{status: 204}} ->
93
          :ok
94
95
        {:ok, %Req.Response{status: status}} when status in 400..599 ->
96
          {:error, :revocation_rejected}
97
98
        {:ok, %Req.Response{}} ->
99
          {:error, :invalid_revocation_response}
100
101
        {:error, _transport_error} ->
102
          {:error, :github_unavailable}
103
      end
104
    end
105
  end
106
107
  def revoke(_access_token), do: {:error, :invalid_token}
108
109
  @doc "The exact OAuth scopes retained with each connected GitHub grant."
110
  @spec requested_scopes() :: [String.t()]
111
  def requested_scopes, do: Application.fetch_env!(:openagents, :github_oauth_scopes)
112
75 113
  defp exchange_code(config, code, verifier) do
76 114
    request_options =
77 115
      [

@@ -89,9 +127,14 @@ defmodule OpenAgents.GitHubOAuth do

89 127
      |> Keyword.merge(config.request_options)
90 128
91 129
    case Req.post(config.token_url, request_options) do
92
      {:ok, %Req.Response{status: status, body: %{"access_token" => token}}}
93
      when status in 200..299 and is_binary(token) and byte_size(token) > 0 ->
94
        {:ok, token}
130
      {:ok,
131
       %Req.Response{
132
         status: status,
133
         body: %{"access_token" => token, "scope" => granted_scope}
134
       }}
135
      when status in 200..299 and is_binary(token) and byte_size(token) > 0 and
136
             is_binary(granted_scope) ->
137
        with {:ok, scopes} <- validate_granted_scopes(granted_scope), do: {:ok, token, scopes}
95 138
96 139
      {:ok, %Req.Response{status: status}} when status in 400..599 ->
97 140
        {:error, :oauth_code_exchange_rejected}

@@ -183,6 +226,10 @@ defmodule OpenAgents.GitHubOAuth do

183 226
         authorize_url: settings[:authorize_url] || @default_authorize_url,
184 227
         token_url: settings[:token_url] || @default_token_url,
185 228
         user_url: settings[:user_url] || @default_user_url,
229
         revoke_url:
230
           settings[:revoke_url] ||
231
             @default_api_url <>
232
               "/applications/" <> URI.encode_www_form(config_value(client_id)) <> "/token",
186 233
         attempt_ttl_seconds: attempt_ttl_seconds,
187 234
         request_options: request_options
188 235
       }}

@@ -206,11 +253,25 @@ defmodule OpenAgents.GitHubOAuth do

206 253
  end
207 254
208 255
  defp oauth_scope do
209
    :openagents
210
    |> Application.fetch_env!(:github_oauth_scopes)
256
    requested_scopes()
211 257
    |> Enum.join(" ")
212 258
  end
213 259
260
  defp validate_granted_scopes(granted_scope) do
261
    granted =
262
      granted_scope
263
      |> String.split([",", " "], trim: true)
264
      |> Enum.uniq()
265
266
    requested = requested_scopes()
267
268
    if MapSet.new(granted) == MapSet.new(requested),
269
      do: {:ok, requested},
270
      else: {:error, :oauth_scope_mismatch}
271
  end
272
273
  defp config_value(value), do: to_string(value)
274
214 275
  defp api_headers do
215 276
    [
216 277
      {"accept", "application/vnd.github+json"},
lib/openagents/incidents.ex modified +2 -2

@@ -45,7 +45,7 @@ defmodule OpenAgents.Incidents do

45 45
    end
46 46
  rescue
47 47
    error ->
48
      Logger.error("incident_report_failed error=#{Exception.message(error)}")
48
      Logger.error("incident_report_failed code=#{OpenAgents.OperationalLog.code(error)}")
49 49
      {:error, :incident_report_failed}
50 50
  end
51 51

@@ -73,7 +73,7 @@ defmodule OpenAgents.Incidents do

73 73
    |> Repo.insert()
74 74
  rescue
75 75
    error ->
76
      Logger.error("incident_record_failed error=#{Exception.message(error)}")
76
      Logger.error("incident_record_failed code=#{OpenAgents.OperationalLog.code(error)}")
77 77
      {:error, :incident_record_failed}
78 78
  end
79 79
lib/openagents/incidents/fixer.ex modified +1 -1

@@ -40,7 +40,7 @@ defmodule OpenAgents.Incidents.Fixer do

40 40
    end
41 41
  rescue
42 42
    error ->
43
      Logger.error("incident_fixer_failed error=#{Exception.message(error)}")
43
      Logger.error("incident_fixer_failed code=#{OpenAgents.OperationalLog.code(error)}")
44 44
      {:error, :incident_fixer_failed}
45 45
  end
46 46
lib/openagents/incidents/notifier.ex modified +1 -1

@@ -24,7 +24,7 @@ defmodule OpenAgents.Incidents.Notifier do

24 24
    :ok
25 25
  rescue
26 26
    error ->
27
      Logger.error("incident_notify_failed error=#{Exception.message(error)}")
27
      Logger.error("incident_notify_failed code=#{OpenAgents.OperationalLog.code(error)}")
28 28
      :ok
29 29
  end
30 30
lib/openagents/leaderboard/server.ex modified +1 -1

@@ -88,7 +88,7 @@ defmodule OpenAgents.Leaderboard.Server do

88 88
    error ->
89 89
      # The board is a projection. A failed recompute keeps serving the last
90 90
      # good ranking rather than taking a public page down.
91
      Logger.warning("leaderboard recompute failed: #{Exception.message(error)}")
91
      Logger.warning("leaderboard_recompute_failed code=#{OpenAgents.OperationalLog.code(error)}")
92 92
      Map.put(state, :changed?, false)
93 93
  end
94 94
lib/openagents/log_safety.ex added +71

@@ -0,0 +1,71 @@

1
defmodule OpenAgents.LogSafety do
2
  @moduledoc "Content-free acceptance scanner for application and platform log exports."
3
4
  @credential_pattern ~r/(?:Bearer\s+[A-Za-z0-9._~+\/-]{8,}|(?:gh[opusr]_|github_pat_|smct_|sig_|oa_pat_|sk-)[A-Za-z0-9._~-]{8,})/i
5
  @oauth_query_pattern ~r/(?:\?|&)(?:code|state|verifier|access_token)=/i
6
  @userinfo_pattern ~r|[a-z][a-z0-9+.-]*://[^\s/@:]+:[^\s/@]+@|i
7
  @scp_userinfo_pattern ~r{(?:^|\s)[^\s/:@]+:[^\s/@]+@[^\s]+}i
8
  @private_field_pattern ~r/["']?(?:content|messages?|prompt|transcript|memory|raw_arguments|tool_arguments|tool_result|sdp|authorization|cookie|poll_secret|access_token)["']?\s*(?:=>|:|=)\s*([^,\n}]+)/i
9
10
  @patterns [
11
    credential: @credential_pattern,
12
    oauth_query: @oauth_query_pattern,
13
    url_userinfo: @userinfo_pattern,
14
    url_userinfo: @scp_userinfo_pattern
15
  ]
16
17
  @spec scan(Enumerable.t()) :: :ok | {:error, [map()]}
18
  def scan(lines) do
19
    findings =
20
      lines
21
      |> Stream.with_index(1)
22
      |> Enum.flat_map(fn {line, line_number} -> findings(line, line_number) end)
23
24
    if findings == [], do: :ok, else: {:error, findings}
25
  end
26
27
  @spec redact(String.t()) :: String.t()
28
  def redact(text) when is_binary(text) do
29
    text
30
    |> then(&Regex.replace(@credential_pattern, &1, "[REDACTED_CREDENTIAL]"))
31
    |> then(
32
      &Regex.replace(
33
        ~r/([?&](?:code|state|verifier|access_token)=)[^&\s]*/i,
34
        &1,
35
        "\\1[FILTERED]"
36
      )
37
    )
38
    |> then(&Regex.replace(@userinfo_pattern, &1, "https://[REDACTED_CREDENTIAL]@"))
39
    |> then(&Regex.replace(@scp_userinfo_pattern, &1, " [REDACTED_CREDENTIAL_URL]"))
40
    |> then(
41
      &Regex.replace(@private_field_pattern, &1, fn full, value ->
42
        String.replace_suffix(full, value, "[FILTERED]")
43
      end)
44
    )
45
  end
46
47
  @spec scan_file(Path.t()) :: :ok | {:error, [map()] | :unreadable}
48
  def scan_file(path) when is_binary(path) do
49
    path
50
    |> File.stream!([], :line)
51
    |> scan()
52
  rescue
53
    File.Error -> {:error, :unreadable}
54
  end
55
56
  defp findings(line, line_number) do
57
    pattern_findings =
58
      Enum.flat_map(@patterns, fn {kind, pattern} ->
59
        if Regex.match?(pattern, line), do: [%{kind: kind, line: line_number}], else: []
60
      end)
61
62
    private_field? =
63
      @private_field_pattern
64
      |> Regex.scan(line, capture: :all_but_first)
65
      |> Enum.any?(fn [value] -> not String.contains?(value, "[FILTERED]") end)
66
67
    if private_field?,
68
      do: pattern_findings ++ [%{kind: :private_field, line: line_number}],
69
      else: pattern_findings
70
  end
71
end
lib/openagents/machines.ex modified +91 -29

@@ -80,6 +80,9 @@ defmodule OpenAgents.Machines do

80 80
        token = "smct_" <> Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
81 81
        {:ok, sealed} = TokenVault.seal(token)
82 82
83
        token_expires_at =
84
          DateTime.add(DateTime.utc_now(), machine_token_ttl_seconds(), :second)
85
83 86
        machine =
84 87
          %Machine{user_id: user_id}
85 88
          |> Machine.create_changeset(%{

@@ -89,7 +92,10 @@ defmodule OpenAgents.Machines do

89 92
            "agent_version" => pairing.agent_version,
90 93
            "roots" => pairing.roots
91 94
          })
92
          |> Ecto.Changeset.change(token_digest: digest(token))
95
          |> Ecto.Changeset.change(
96
            token_digest: digest(token),
97
            token_expires_at: token_expires_at
98
          )
93 99
          |> Repo.insert!()
94 100
95 101
        pairing

@@ -113,28 +119,22 @@ defmodule OpenAgents.Machines do

113 119
          | {:error, atom()}
114 120
  def claim_pairing(pairing_id, poll_secret)
115 121
      when is_binary(pairing_id) and is_binary(poll_secret) do
116
    with {:ok, _cast} <- Ecto.UUID.cast(pairing_id),
117
         %Pairing{} = pairing <- Repo.get(Pairing, pairing_id),
118
         true <- Plug.Crypto.secure_compare(pairing.poll_secret_digest, digest(poll_secret)) do
119
      case pairing do
120
        %Pairing{status: "pending"} ->
121
          if expired?(pairing), do: {:error, :pairing_expired}, else: {:error, :pairing_pending}
122
123
        %Pairing{status: "approved", token_ciphertext: sealed, machine_id: machine_id}
124
        when is_binary(sealed) ->
125
          with {:ok, token} <- TokenVault.open(sealed) do
126
            pairing
127
            |> Ecto.Changeset.change(status: "claimed", token_ciphertext: nil)
128
            |> Repo.update!()
129
130
            {:ok, %{token: token, machine_id: machine_id, name: pairing.name}}
131
          end
132
133
        %Pairing{} ->
134
          {:error, :pairing_consumed}
135
      end
136
    else
137
      _missing -> {:error, :pairing_not_found}
122
    result =
123
      Repo.transaction(fn ->
124
        with {:ok, pairing_id} <- Ecto.UUID.cast(pairing_id),
125
             %Pairing{} = pairing <-
126
               Repo.one(from(p in Pairing, where: p.id == ^pairing_id, lock: "FOR UPDATE")),
127
             true <-
128
               Plug.Crypto.secure_compare(pairing.poll_secret_digest, digest(poll_secret)) do
129
          claim_locked_pairing(pairing)
130
        else
131
          _missing -> {:error, :pairing_not_found}
132
        end
133
      end)
134
135
    case result do
136
      {:ok, claim_result} -> claim_result
137
      {:error, _transaction_failure} -> {:error, :pairing_not_found}
138 138
    end
139 139
  end
140 140

@@ -143,9 +143,16 @@ defmodule OpenAgents.Machines do

143 143
  @spec authenticate_token(String.t()) :: {:ok, Machine.t()} | {:error, atom()}
144 144
  def authenticate_token("smct_" <> _rest = token) when byte_size(token) < 128 do
145 145
    case Repo.get_by(Machine, token_digest: digest(token)) do
146
      %Machine{status: "active"} = machine -> {:ok, machine}
147
      %Machine{} -> {:error, :machine_revoked}
148
      nil -> {:error, :machine_not_found}
146
      %Machine{status: "active"} = machine ->
147
        if DateTime.compare(DateTime.utc_now(), machine.token_expires_at) == :lt,
148
          do: {:ok, machine},
149
          else: {:error, :machine_expired}
150
151
      %Machine{} ->
152
        {:error, :machine_revoked}
153
154
      nil ->
155
        {:error, :machine_not_found}
149 156
    end
150 157
  end
151 158

@@ -158,7 +165,12 @@ defmodule OpenAgents.Machines do

158 165
159 166
  @spec active_machine?(String.t() | nil) :: boolean()
160 167
  def active_machine?(user_id) when is_binary(user_id) do
161
    Repo.exists?(from m in Machine, where: m.user_id == ^user_id and m.status == "active")
168
    now = DateTime.utc_now()
169
170
    Repo.exists?(
171
      from m in Machine,
172
        where: m.user_id == ^user_id and m.status == "active" and m.token_expires_at > ^now
173
    )
162 174
  end
163 175
164 176
  def active_machine?(_user_id), do: false

@@ -190,8 +202,11 @@ defmodule OpenAgents.Machines do

190 202
  """
191 203
  @spec approval_receipts(String.t() | nil, String.t()) :: [map()]
192 204
  def approval_receipts(user_id, scope_ref) when is_binary(user_id) and is_binary(scope_ref) do
205
    now = DateTime.utc_now()
206
193 207
    for machine <- list_machines(user_id),
194 208
        machine.status == "active",
209
        DateTime.compare(machine.token_expires_at, now) == :gt,
195 210
        {module_id, version} <- @external_effect_modules do
196 211
      %{
197 212
        "schema" => "sarah.module_approval.v1",

@@ -263,9 +278,13 @@ defmodule OpenAgents.Machines do

263 278
  end
264 279
265 280
  defp verify_capacity(user_id) do
281
    now = DateTime.utc_now()
282
266 283
    active =
267 284
      Repo.aggregate(
268
        from(m in Machine, where: m.user_id == ^user_id and m.status == "active"),
285
        from(m in Machine,
286
          where: m.user_id == ^user_id and m.status == "active" and m.token_expires_at > ^now
287
        ),
269 288
        :count
270 289
      )
271 290

@@ -298,8 +317,48 @@ defmodule OpenAgents.Machines do

298 317
299 318
  defp verify_pairing_id(%Pairing{}, _pairing_id), do: {:error, :pairing_not_found}
300 319
320
  defp claim_locked_pairing(%Pairing{} = pairing) do
321
    cond do
322
      expired?(pairing) ->
323
        expire_locked_pairing(pairing)
324
        {:error, :pairing_expired}
325
326
      pairing.status == "pending" ->
327
        {:error, :pairing_pending}
328
329
      pairing.status == "approved" and is_binary(pairing.token_ciphertext) ->
330
        with {:ok, token} <- TokenVault.open(pairing.token_ciphertext) do
331
          pairing
332
          |> Ecto.Changeset.change(status: "claimed", token_ciphertext: nil)
333
          |> Repo.update!()
334
335
          {:ok, %{token: token, machine_id: pairing.machine_id, name: pairing.name}}
336
        end
337
338
      true ->
339
        {:error, :pairing_consumed}
340
    end
341
  end
342
343
  defp expire_locked_pairing(pairing) do
344
    now = DateTime.utc_now()
345
346
    pairing
347
    |> Ecto.Changeset.change(status: "expired", token_ciphertext: nil)
348
    |> Repo.update!()
349
350
    if pairing.machine_id do
351
      from(machine in Machine,
352
        where: machine.id == ^pairing.machine_id and machine.status == "active"
353
      )
354
      |> Repo.update_all(set: [status: "revoked", revoked_at: now, updated_at: now])
355
    end
356
357
    :ok
358
  end
359
301 360
  defp expired?(%Pairing{expires_at: expires_at}),
302
    do: DateTime.compare(DateTime.utc_now(), expires_at) == :gt
361
    do: DateTime.compare(DateTime.utc_now(), expires_at) != :lt
303 362
304 363
  defp normalize_code(code) do
305 364
    code |> String.upcase() |> String.replace(~r/[^A-Z0-9]/, "")

@@ -320,4 +379,7 @@ defmodule OpenAgents.Machines do

320 379
  end
321 380
322 381
  defp digest(value), do: :crypto.hash(:sha256, value)
382
383
  defp machine_token_ttl_seconds,
384
    do: Application.fetch_env!(:openagents, :machine_token_ttl_seconds)
323 385
end
lib/openagents/machines/machine.ex modified +2

@@ -18,6 +18,7 @@ defmodule OpenAgents.Machines.Machine do

18 18
    field :agent_version, :string
19 19
    field :roots, {:array, :string}, default: []
20 20
    field :token_digest, :binary, redact: true
21
    field :token_expires_at, :utc_datetime_usec
21 22
    field :status, :string, default: "active"
22 23
    field :revoked_at, :utc_datetime_usec
23 24
    field :last_seen_at, :utc_datetime_usec

@@ -35,6 +36,7 @@ defmodule OpenAgents.Machines.Machine do

35 36
          agent_version: String.t() | nil,
36 37
          roots: [String.t()],
37 38
          token_digest: binary(),
39
          token_expires_at: DateTime.t(),
38 40
          status: String.t(),
39 41
          revoked_at: DateTime.t() | nil,
40 42
          last_seen_at: DateTime.t() | nil,
lib/openagents/operational_log.ex added +16

@@ -0,0 +1,16 @@

1
defmodule OpenAgents.OperationalLog do
2
  @moduledoc "Reduces failures to bounded, content-free codes before logging or receipting."
3
4
  @spec code(term()) :: String.t()
5
  def code(reason) when is_atom(reason), do: bounded(Atom.to_string(reason))
6
  def code({tag, _detail}) when is_atom(tag), do: bounded(Atom.to_string(tag))
7
  def code({tag, _detail, _more}) when is_atom(tag), do: bounded(Atom.to_string(tag))
8
9
  def code(%{__struct__: module}) when is_atom(module) do
10
    module |> Module.split() |> List.last() |> Macro.underscore() |> bounded()
11
  end
12
13
  def code(_reason), do: "other"
14
15
  defp bounded(value), do: String.slice(value, 0, 64)
16
end
lib/openagents/release.ex modified +12

@@ -20,6 +20,18 @@ defmodule OpenAgents.Release do

20 20
    {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
21 21
  end
22 22
23
  @doc "Rewrap retained GitHub grants with the configured active vault key."
24
  def rotate_github_tokens do
25
    load_app()
26
27
    for repo <- repos() do
28
      {:ok, rotated, _apps} =
29
        Ecto.Migrator.with_repo(repo, fn _repo -> OpenAgents.Accounts.rotate_github_tokens!() end)
30
31
      IO.puts("github_tokens_rotated=#{rotated}")
32
    end
33
  end
34
23 35
  defp run_migrations(repo) do
24 36
    # Acquire a session-level advisory lock so only one release migrates at a
25 37
    # time, then run the standard Ecto migration set.
lib/openagents/runtime_config.ex modified +69 -2

@@ -217,13 +217,15 @@ defmodule OpenAgents.RuntimeConfig do

217 217
    redirect_uri = keyword_value(oauth, :redirect_uri)
218 218
    scopes = Map.get(settings, :github_oauth_scopes)
219 219
    token_key = Map.get(settings, :github_token_encryption_key)
220
    token_key_id = Map.get(settings, :github_token_encryption_key_id)
221
    decryption_keys = Map.get(settings, :github_token_decryption_keys)
220 222
221 223
    with :ok <- ensure(present?(client_id), :github_oauth_client_id, "is required"),
222 224
         :ok <- ensure(present?(client_secret), :github_oauth_client_secret, "is required"),
223 225
         :ok <- validate_redirect(redirect_uri, environment),
224 226
         :ok <-
225 227
           ensure(
226
             scopes == ["read:user", "repo"],
228
             scopes == ["repo"],
227 229
             :github_oauth_scopes,
228 230
             "must match the retained-token tool model"
229 231
           ),

@@ -232,11 +234,41 @@ defmodule OpenAgents.RuntimeConfig do

232 234
             encryption_key?(token_key),
233 235
             :github_token_encryption_key,
234 236
             "must be a base64-encoded 32-byte key"
237
           ),
238
         :ok <-
239
           ensure(
240
             token_key_id_for_environment?(token_key_id, environment),
241
             :github_token_encryption_key_id,
242
             "must be a bounded key identifier prefixed for the runtime environment"
243
           ),
244
         :ok <-
245
           ensure(
246
             decryption_keyring?(decryption_keys, token_key_id, environment),
247
             :github_token_decryption_keys,
248
             "must contain only bounded identifiers and base64-encoded 32-byte keys"
235 249
           ) do
236 250
      :ok
237 251
    end
238 252
  end
239 253
254
  defp token_key_id?(key_id) when is_binary(key_id),
255
    do: String.match?(key_id, ~r/\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\z/)
256
257
  defp token_key_id?(_key_id), do: false
258
259
  defp token_key_id_for_environment?(key_id, environment) do
260
    token_key_id?(key_id) and String.starts_with?(key_id, Atom.to_string(environment) <> "-")
261
  end
262
263
  defp decryption_keyring?(keys, active_key_id, environment) when is_map(keys) do
264
    map_size(keys) <= 16 and not Map.has_key?(keys, active_key_id) and
265
      Enum.all?(keys, fn {key_id, key} ->
266
        token_key_id_for_environment?(key_id, environment) and encryption_key?(key)
267
      end)
268
  end
269
270
  defp decryption_keyring?(_keys, _active_key_id, _environment), do: false
271
240 272
  defp validate_features(settings, environment, staging_gate) do
241 273
    with {:ok, tools?} <- required_boolean(settings, :tools_enabled),
242 274
         {:ok, voice?} <- nested_boolean(settings, :voice, :enabled),

@@ -418,6 +450,7 @@ defmodule OpenAgents.RuntimeConfig do

418 450
    artifact_store = Map.get(settings, :forge_artifact_store)
419 451
    build_executor = Map.get(settings, :forge_build_executor)
420 452
    operator_token = Map.get(settings, :forge_operator_token)
453
    mirror_urls = Map.get(settings, :forge_mirror_urls)
421 454
    durable_required? = environment in [:staging, :production] and features.forge
422 455
423 456
    with :ok <-

@@ -434,6 +467,12 @@ defmodule OpenAgents.RuntimeConfig do

434 467
             :forge_internal_git_url,
435 468
             "must be an HTTP URL without credentials"
436 469
           ),
470
         :ok <-
471
           ensure(
472
             clean_mirror_urls?(mirror_urls),
473
             :forge_mirror_urls,
474
             "must contain only credential-free git remote URLs or paths"
475
           ),
437 476
         :ok <-
438 477
           ensure(
439 478
             valid_allowlist?(allowlist),

@@ -725,7 +764,8 @@ defmodule OpenAgents.RuntimeConfig do

725 764
      integer_setting_in?(settings, :inference_grant_max_total_tokens, 1..100_000_000) and
726 765
      integer_setting_in?(settings, :inference_grant_max_calls, 1..10_000) and
727 766
      integer_setting_in?(settings, :inference_grant_max_cost_microusd, 1..1_000_000_000) and
728
      integer_setting_in?(settings, :inference_grant_ttl_seconds, 1..86_400)
767
      integer_setting_in?(settings, :inference_grant_ttl_seconds, 1..86_400) and
768
      integer_setting_in?(settings, :machine_token_ttl_seconds, 300..2_592_000)
729 769
  end
730 770
731 771
  defp keyword_integer_in?(settings, key, range) do

@@ -764,6 +804,33 @@ defmodule OpenAgents.RuntimeConfig do

764 804
    end
765 805
  end
766 806
807
  defp clean_mirror_urls?(urls) when is_map(urls) do
808
    Enum.all?(urls, fn {repo, url} ->
809
      is_binary(repo) and clean_mirror_url?(url)
810
    end)
811
  end
812
813
  defp clean_mirror_urls?(_urls), do: false
814
815
  defp clean_mirror_url?(url) when is_binary(url) and byte_size(url) in 1..2_048 do
816
    cond do
817
      String.contains?(url, ["\n", "\r", "\0"]) ->
818
        false
819
820
      Path.type(url) == :absolute ->
821
        true
822
823
      true ->
824
        match?(
825
          {:ok, %URI{scheme: scheme, host: host, userinfo: nil}}
826
          when scheme in ["http", "https", "git", "ssh"] and is_binary(host),
827
          URI.new(url)
828
        )
829
    end
830
  end
831
832
  defp clean_mirror_url?(_url), do: false
833
767 834
  defp clean_service_url?(url) do
768 835
    case URI.new(url) do
769 836
      {:ok, %URI{scheme: "https", host: host, userinfo: nil}} when is_binary(host) -> true
lib/openagents/tools/embeddings.ex modified +1 -1

@@ -41,7 +41,7 @@ defmodule OpenAgents.Tools.Embeddings do

41 41
    end
42 42
  rescue
43 43
    error ->
44
      Logger.warning("tool_embeddings_warm_failed error=#{Exception.message(error)}")
44
      Logger.warning("tool_embeddings_warm_failed code=#{OpenAgents.OperationalLog.code(error)}")
45 45
      :error
46 46
  end
47 47
lib/openagents/voice_sessions/session_server.ex modified +2 -2

@@ -284,7 +284,7 @@ defmodule OpenAgents.VoiceSessions.SessionServer do

284 284
      {:error, reason} ->
285 285
        Logger.error(
286 286
          "voice event persistence failed session=#{state.session.id} " <>
287
            "kind=#{provider_event.kind} reason=#{inspect(reason)}"
287
            "kind=#{provider_event.kind} code=#{OpenAgents.OperationalLog.code(reason)}"
288 288
        )
289 289
290 290
        fail_and_stop(state, :event_persistence_failed)

@@ -543,7 +543,7 @@ defmodule OpenAgents.VoiceSessions.SessionServer do

543 543
      {:error, reason} ->
544 544
        Logger.warning(
545 545
          "voice compaction summary persistence failed session=#{state.session.id} " <>
546
            "reason=#{inspect(reason)}"
546
            "code=#{OpenAgents.OperationalLog.code(reason)}"
547 547
        )
548 548
549 549
        {:noreply,
lib/openagents/work/coding.ex modified +8 -2

@@ -74,7 +74,10 @@ defmodule OpenAgents.Work.Coding do

74 74
        |> Repo.update()
75 75
76 76
      {:error, reason} ->
77
        Logger.warning("coding job grant mint failed: #{inspect(reason)}")
77
        Logger.warning(
78
          "coding_job_grant_mint_failed code=#{OpenAgents.OperationalLog.code(reason)}"
79
        )
80
78 81
        {:ok, job}
79 82
    end
80 83
  end

@@ -114,7 +117,10 @@ defmodule OpenAgents.Work.Coding do

114 117
    end
115 118
  rescue
116 119
    error ->
117
      Logger.warning("coding job grant usage record failed: #{Exception.message(error)}")
120
      Logger.warning(
121
        "coding_job_grant_usage_failed code=#{OpenAgents.OperationalLog.code(error)}"
122
      )
123
118 124
      :ok
119 125
  end
120 126
lib/openagents_web/channels/computer_channel.ex modified +13

@@ -27,6 +27,7 @@ defmodule OpenAgentsWeb.ComputerChannel do

27 27
            {:ok, _owner} ->
28 28
              Phoenix.PubSub.subscribe(OpenAgents.PubSub, "machine:#{machine.id}")
29 29
              Machines.record_seen(machine)
30
              schedule_token_expiry(socket.assigns.token_expires_at, machine.id)
30 31
              {:ok, %{"protocol" => "openagents.computer.v1"}, assign(socket, :machine, machine)}
31 32
32 33
            # A prior registration (usually from a node that just died) hasn't

@@ -155,6 +156,13 @@ defmodule OpenAgentsWeb.ComputerChannel do

155 156
    {:stop, :normal, socket}
156 157
  end
157 158
159
  def handle_info(
160
        {:machine_token_expired, machine_id},
161
        %{assigns: %{machine_id: machine_id}} = socket
162
      ) do
163
    {:stop, :normal, socket}
164
  end
165
158 166
  def handle_info(_message, socket), do: {:noreply, socket}
159 167
160 168
  @impl true

@@ -219,6 +227,11 @@ defmodule OpenAgentsWeb.ComputerChannel do

219 227
220 228
  defp bounded_text(_value, _maximum, fallback), do: fallback
221 229
230
  defp schedule_token_expiry(expires_at, machine_id) do
231
    delay_ms = max(DateTime.diff(expires_at, DateTime.utc_now(), :millisecond), 0)
232
    Process.send_after(self(), {:machine_token_expired, machine_id}, delay_ms)
233
  end
234
222 235
  defp store_report(socket, report) when is_map(report),
223 236
    do: Machines.store_probe(socket.assigns.machine, report)
224 237
lib/openagents_web/channels/controller_socket.ex modified +2 -1

@@ -21,7 +21,8 @@ defmodule OpenAgentsWeb.ControllerSocket do

21 21
      {:ok,
22 22
       socket
23 23
       |> assign(:machine_id, machine.id)
24
       |> assign(:user_id, machine.user_id)}
24
       |> assign(:user_id, machine.user_id)
25
       |> assign(:token_expires_at, machine.token_expires_at)}
25 26
    else
26 27
      _denied -> :error
27 28
    end
lib/openagents_web/components/layouts.ex modified +50 -3

@@ -99,9 +99,9 @@ defmodule OpenAgentsWeb.Layouts do

99 99
        <%= if @current_scope do %>
100 100
          <.account_dropdown current_scope={@current_scope} />
101 101
        <% else %>
102
          <.form for={%{}} as={:auth} action={~p"/auth/github"} method="post" class="m-0">
103
            <.button type="submit" variant={:primary} size={:sm}>Sign in with GitHub</.button>
104
          </.form>
102
          <.button navigate={~p"/#github-tools"} variant={:primary} size={:sm}>
103
            Sign in with GitHub
104
          </.button>
105 105
        <% end %>
106 106
      </div>
107 107
    </header>

@@ -183,6 +183,26 @@ defmodule OpenAgentsWeb.Layouts do

183 183
          <UI.icon name="logout" /> Log out
184 184
        </UI.button>
185 185
      </.form>
186
      <.link navigate={~p"/settings/api-tokens"} role="menuitem" class="account-menu__logout">
187
        API tokens
188
      </.link>
189
      <.form
190
        :if={github_tools_connected?(@current_user)}
191
        for={%{}}
192
        id="github-disconnect-form"
193
        action={~p"/github/connection"}
194
        method="delete"
195
      >
196
        <UI.button
197
          id="github-disconnect"
198
          variant={:ghost}
199
          type="submit"
200
          role="menuitem"
201
          class="account-menu__logout"
202
        >
203
          Disconnect GitHub tools
204
        </UI.button>
205
      </.form>
186 206
    </UI.menu>
187 207
    """
188 208
  end

@@ -207,6 +227,28 @@ defmodule OpenAgentsWeb.Layouts do

207 227
            @{@current_scope.github_login}
208 228
          </span>
209 229
        </li>
230
        <li>
231
          <.link
232
            navigate={~p"/settings/api-tokens"}
233
            class="btn w-full justify-start"
234
            data-variant="ghost"
235
          >
236
            API tokens
237
          </.link>
238
        </li>
239
        <li>
240
          <.form
241
            :if={github_tools_connected?(@current_scope)}
242
            for={%{}}
243
            action={~p"/github/connection"}
244
            method="delete"
245
            class="m-0 w-full"
246
          >
247
            <.button type="submit" variant={:ghost} class="w-full justify-start">
248
              Disconnect GitHub tools
249
            </.button>
250
          </.form>
251
        </li>
210 252
        <li>
211 253
          <.form for={%{}} as={:logout} action={~p"/logout"} method="post" class="m-0 w-full">
212 254
            <input type="hidden" name="_method" value="delete" />

@@ -421,6 +463,11 @@ defmodule OpenAgentsWeb.Layouts do

421 463
    )
422 464
  end
423 465
466
  defp github_tools_connected?(user) when is_map(user),
467
    do: is_binary(Map.get(user, :github_token_ciphertext))
468
469
  defp github_tools_connected?(_user), do: false
470
424 471
  defp hide(js \\ %JS{}, selector) do
425 472
    JS.hide(js,
426 473
      to: selector,
lib/openagents_web/controllers/api_token_controller.ex added +61

@@ -0,0 +1,61 @@

1
defmodule OpenAgentsWeb.ApiTokenController do
2
  @moduledoc "Browser-authenticated issuance and revocation of first-party API credentials."
3
4
  use OpenAgentsWeb, :controller
5
6
  alias OpenAgents.ApiTokens
7
8
  def index(conn, _params) do
9
    tokens = conn.assigns.current_user |> ApiTokens.metadata() |> Enum.map(&projection/1)
10
    conn |> put_resp_header("cache-control", "no-store") |> json(%{"tokens" => tokens})
11
  end
12
13
  def create(conn, params) do
14
    case ApiTokens.create(conn.assigns.current_user, params) do
15
      {:ok, token, plaintext} ->
16
        conn
17
        |> put_status(:created)
18
        |> put_resp_header("cache-control", "no-store")
19
        |> json(%{
20
          "token" => plaintext,
21
          "credential" => projection(token),
22
          "warning" => "This token is shown once. Store it securely."
23
        })
24
25
      {:error, _invalid} ->
26
        conn
27
        |> put_status(:unprocessable_entity)
28
        |> put_resp_header("cache-control", "no-store")
29
        |> json(%{"error" => "invalid_api_token"})
30
    end
31
  end
32
33
  def delete(conn, %{"id" => id}) do
34
    case ApiTokens.revoke(conn.assigns.current_user, id) do
35
      {:ok, token} ->
36
        conn
37
        |> put_resp_header("cache-control", "no-store")
38
        |> json(%{"credential" => projection(token)})
39
40
      {:error, :not_found} ->
41
        conn
42
        |> put_status(:not_found)
43
        |> put_resp_header("cache-control", "no-store")
44
        |> json(%{"error" => "not_found"})
45
    end
46
  end
47
48
  defp projection(token) do
49
    %{
50
      "id" => token.id,
51
      "name" => token.name,
52
      "scopes" => token.scopes,
53
      "expires_at" => iso8601(token.expires_at),
54
      "last_used_at" => iso8601(token.last_used_at),
55
      "revoked_at" => iso8601(token.revoked_at)
56
    }
57
  end
58
59
  defp iso8601(nil), do: nil
60
  defp iso8601(value), do: DateTime.to_iso8601(value)
61
end
lib/openagents_web/controllers/auth_controller.ex modified +23 -3

@@ -5,7 +5,7 @@ defmodule OpenAgentsWeb.AuthController do

5 5
6 6
  @attempt_session_key "github_oauth_attempt"
7 7
8
  def start(conn, _params) do
8
  def start(conn, %{"github_tools" => "enabled"}) do
9 9
    case GitHubOAuth.begin_authorization() do
10 10
      {:ok, attempt, authorization_url} ->
11 11
        conn

@@ -18,16 +18,20 @@ defmodule OpenAgentsWeb.AuthController do

18 18
    end
19 19
  end
20 20
21
  def start(conn, _params), do: auth_failure(conn, "consent_required")
22
21 23
  def callback(conn, %{"code" => code, "state" => state}) do
22 24
    attempt = get_session(conn, @attempt_session_key)
23 25
    verifier = if is_map(attempt), do: attempt["verifier"]
24 26
    conn = delete_session(conn, @attempt_session_key)
25 27
26 28
    with :ok <- GitHubOAuth.consume_attempt(attempt, state),
27
         {:ok, profile, access_token} <- GitHubOAuth.exchange_and_fetch(code, verifier),
29
         {:ok, profile, access_token, granted_scopes} <-
30
           GitHubOAuth.exchange_and_fetch(code, verifier),
28 31
         {:ok, user} <- Accounts.upsert_github_user(profile),
29 32
         {:ok, active_user} <- Accounts.get_active_user(user.id),
30
         {:ok, _stored} <- Accounts.store_github_token(active_user, access_token) do
33
         {:ok, _stored} <-
34
           Accounts.store_github_token(active_user, access_token, granted_scopes) do
31 35
      conn
32 36
      |> clear_session()
33 37
      |> configure_session(renew: true)

@@ -60,6 +64,22 @@ defmodule OpenAgentsWeb.AuthController do

60 64
    |> redirect(to: ~p"/")
61 65
  end
62 66
67
  def disconnect(conn, _params) do
68
    case Accounts.disconnect_github(conn.assigns.current_user) do
69
      {:ok, _user} ->
70
        conn
71
        |> put_flash(:info, "GitHub tools disconnected and the retained grant was revoked.")
72
        |> put_resp_header("cache-control", "no-store")
73
        |> redirect(to: ~p"/chat")
74
75
      {:error, _reason} ->
76
        conn
77
        |> put_flash(:error, "GitHub tools could not be disconnected. Try again.")
78
        |> put_resp_header("cache-control", "no-store")
79
        |> redirect(to: ~p"/chat")
80
    end
81
  end
82
63 83
  defp auth_failure(conn, code) do
64 84
    conn
65 85
    |> clear_session()
lib/openagents_web/controllers/controller_pairing_controller.ex modified +3

@@ -13,6 +13,7 @@ defmodule OpenAgentsWeb.ControllerPairingController do

13 13
  alias OpenAgents.Machines
14 14
15 15
  plug :verify_enabled
16
  plug :put_no_store
16 17
17 18
  def create(conn, params) do
18 19
    attributes = %{

@@ -73,6 +74,8 @@ defmodule OpenAgentsWeb.ControllerPairingController do

73 74
    end
74 75
  end
75 76
77
  defp put_no_store(conn, _options), do: put_resp_header(conn, "cache-control", "no-store")
78
76 79
  defp bounded(value) when is_binary(value), do: String.slice(value, 0, 40)
77 80
  defp bounded(_value), do: nil
78 81
lib/openagents_web/controllers/inference_proxy_controller.ex modified +2 -1

@@ -124,7 +124,7 @@ defmodule OpenAgentsWeb.InferenceProxyController do

124 124
        # sees a provider error, never raw provider detail.
125 125
        usage = usage_of(events)
126 126
        if usage != %{}, do: meter(grant, usage)
127
        Logger.warning("inference proxy provider failure: #{inspect(reason)}")
127
        Logger.warning("inference_proxy_failed code=#{OpenAgents.OperationalLog.code(reason)}")
128 128
        refuse(conn, :provider_failed)
129 129
    end
130 130
  end

@@ -236,6 +236,7 @@ defmodule OpenAgentsWeb.InferenceProxyController do

236 236
237 237
    conn
238 238
    |> put_resp_content_type("application/json")
239
    |> put_resp_header("cache-control", "no-store")
239 240
    |> send_resp(status, Jason.encode!(%{"error" => %{"code" => code}}))
240 241
  end
241 242
lib/openagents_web/live/api_tokens_live.ex added +132

@@ -0,0 +1,132 @@

1
defmodule OpenAgentsWeb.ApiTokensLive do
2
  @moduledoc "Owner settings for issuing and revoking scoped API credentials."
3
4
  use OpenAgentsWeb, :live_view
5
6
  alias OpenAgents.ApiTokens
7
8
  @impl true
9
  def mount(_params, _session, socket) do
10
    tokens = ApiTokens.list(socket.assigns.current_user)
11
12
    {:ok,
13
     socket
14
     |> assign(:page_title, "API tokens · OpenAgents")
15
     |> assign(:issued_token, nil)
16
     |> assign(:form, token_form())
17
     |> stream(:tokens, tokens)}
18
  end
19
20
  @impl true
21
  def handle_event("create", %{"api_token" => params}, socket) do
22
    case ApiTokens.create(socket.assigns.current_user, %{
23
           "name" => params["name"],
24
           "scopes" => ["forge:write"],
25
           "lifetime_days" => params["lifetime_days"]
26
         }) do
27
      {:ok, token, plaintext} ->
28
        {:noreply,
29
         socket
30
         |> assign(:issued_token, plaintext)
31
         |> assign(:form, token_form())
32
         |> stream_insert(:tokens, token, at: 0)}
33
34
      {:error, _invalid} ->
35
        {:noreply, put_flash(socket, :error, "Choose a name and a lifetime from 1 to 90 days.")}
36
    end
37
  end
38
39
  def handle_event("revoke", %{"id" => id}, socket) do
40
    case ApiTokens.revoke(socket.assigns.current_user, id) do
41
      {:ok, token} -> {:noreply, stream_insert(socket, :tokens, token)}
42
      {:error, :not_found} -> {:noreply, put_flash(socket, :error, "API token not found.")}
43
    end
44
  end
45
46
  @impl true
47
  def render(assigns) do
48
    ~H"""
49
    <Layouts.app flash={@flash} current_scope={@current_scope}>
50
      <main id="api-token-settings" class="mx-auto w-full max-w-4xl space-y-8 px-4 py-10">
51
        <header class="space-y-2">
52
          <h1 class="text-3xl font-semibold tracking-tight">API tokens</h1>
53
          <p class="text-muted-foreground">
54
            Create an expiring credential for CLI forge writes. Tokens carry only <code>forge:write</code>, are stored as digests, and are shown once.
55
          </p>
56
        </header>
57
58
        <.alert
59
          :if={@issued_token}
60
          id="issued-api-token"
61
          variant={:warning}
62
          title="Copy this token now"
63
        >
64
          <p>It cannot be retrieved after this page is refreshed.</p>
65
          <code class="mt-3 block break-all rounded-md bg-muted p-3 font-mono text-sm">
66
            {@issued_token}
67
          </code>
68
        </.alert>
69
70
        <.card>
71
          <.form for={@form} id="api-token-form" phx-submit="create" class="space-y-4">
72
            <.field>
73
              <.label for={@form[:name].id}>Name</.label>
74
              <.input field={@form[:name]} placeholder="Release CLI" required />
75
            </.field>
76
            <.field>
77
              <.label for={@form[:lifetime_days].id}>Lifetime in days</.label>
78
              <.input
79
                field={@form[:lifetime_days]}
80
                type="number"
81
                min="1"
82
                max="90"
83
                required
84
              />
85
            </.field>
86
            <.button id="create-api-token" type="submit" variant={:primary}>
87
              Create token
88
            </.button>
89
          </.form>
90
        </.card>
91
92
        <section class="space-y-3" aria-labelledby="api-token-list-heading">
93
          <h2 id="api-token-list-heading" class="text-xl font-semibold">Credentials</h2>
94
          <div id="api-tokens" phx-update="stream" class="space-y-3">
95
            <.empty id="api-tokens-empty" title="No API tokens" class="hidden only:block">
96
              Create one when a CLI needs forge write access.
97
            </.empty>
98
            <.card :for={{id, token} <- @streams.tokens} id={id}>
99
              <div class="flex flex-wrap items-center justify-between gap-4">
100
                <div>
101
                  <strong>{token.name}</strong>
102
                  <p class="text-sm text-muted-foreground">
103
                    {Enum.join(token.scopes, ", ")} · expires {format_time(token.expires_at)}
104
                  </p>
105
                  <.badge :if={token.revoked_at} variant={:danger}>REVOKED</.badge>
106
                </div>
107
                <.button
108
                  :if={is_nil(token.revoked_at)}
109
                  id={"revoke-api-token-#{token.id}"}
110
                  phx-click="revoke"
111
                  phx-value-id={token.id}
112
                  variant={:destructive}
113
                  size={:sm}
114
                >
115
                  Revoke
116
                </.button>
117
              </div>
118
            </.card>
119
          </div>
120
        </section>
121
      </main>
122
    </Layouts.app>
123
    """
124
  end
125
126
  defp token_form do
127
    to_form(%{"name" => "", "lifetime_days" => "30"}, as: :api_token)
128
  end
129
130
  defp format_time(%DateTime{} = value),
131
    do: value |> DateTime.truncate(:second) |> DateTime.to_iso8601()
132
end
lib/openagents_web/live/chat_live.ex modified +4 -1

@@ -2370,7 +2370,10 @@ defmodule OpenAgentsWeb.ChatLive do

2370 2370
              <.label for={@privacy_delete_form[:confirmation].id}>
2371 2371
                Type DELETE MY SARAH DATA to delete this account's Sarah conversation,
2372 2372
                transcripts, memory, receipts, and voice records. Minimal GitHub identity and
2373
                access-status data remains so bans and access controls cannot be bypassed.
2373
                access-status data remains so bans and access controls cannot be bypassed. A
2374
                retained GitHub tools grant remains until you use Disconnect GitHub tools in
2375
                the account menu. API tokens remain until you revoke them from API token
2376
                settings.
2374 2377
              </.label>
2375 2378
              <div class="control-row">
2376 2379
                <.input
lib/openagents_web/live/home_live.ex modified +15 -3

@@ -20,7 +20,7 @@ defmodule OpenAgentsWeb.HomeLive do

20 20
            <p class="text-xl md:text-2xl text-muted-foreground">
21 21
              Purpose-built for planning and shipping issues. Designed for the agent era.
22 22
            </p>
23
            <div class="flex flex-wrap justify-center gap-4">
23
            <div id="github-tools" class="flex flex-wrap justify-center gap-4">
24 24
              <%= if @current_user do %>
25 25
                <.button
26 26
                  id="home-cta-create"

@@ -33,9 +33,21 @@ defmodule OpenAgentsWeb.HomeLive do

33 33
                  View issues
34 34
                </.button>
35 35
              <% else %>
36
                <.form for={%{}} as={:auth} action={~p"/auth/github"} method="post" class="m-0">
36
                <.form
37
                  for={%{}}
38
                  as={:auth}
39
                  action={~p"/auth/github?github_tools=enabled"}
40
                  method="post"
41
                  class="m-0 max-w-xl space-y-3"
42
                >
43
                  <p id="github-tools-disclosure" class="text-sm text-muted-foreground">
44
                    OpenAgents will retain an encrypted GitHub grant with the <code>repo</code>
45
                    scope. GitHub makes that scope read/write even though OpenAgents currently
46
                    exposes it only to bounded repository-reading tools. You can revoke the
47
                    grant from the account menu at any time.
48
                  </p>
37 49
                  <.button type="submit" variant={:primary} id="home-cta-signin">
38
                    Sign in with GitHub
50
                    Sign in and enable GitHub tools
39 51
                  </.button>
40 52
                </.form>
41 53
              <% end %>
lib/openagents_web/plugs/api_token_auth.ex added +37

@@ -0,0 +1,37 @@

1
defmodule OpenAgentsWeb.Plugs.ApiTokenAuth do
2
  @moduledoc "Authenticates a scoped first-party bearer credential for JSON APIs."
3
4
  import Plug.Conn
5
6
  alias OpenAgents.ApiTokens
7
8
  def init(options), do: Keyword.fetch!(options, :scope)
9
10
  def call(conn, required_scope) do
11
    with {:ok, plaintext} <- bearer(conn),
12
         {:ok, user, token} <- ApiTokens.authenticate(plaintext, required_scope) do
13
      conn
14
      |> put_resp_header("cache-control", "no-store")
15
      |> assign(:current_user, user)
16
      |> assign(:api_token, token)
17
      |> assign(:api_scope, required_scope)
18
    else
19
      _denied -> refuse(conn)
20
    end
21
  end
22
23
  defp bearer(conn) do
24
    case get_req_header(conn, "authorization") do
25
      ["Bearer " <> token] when token != "" -> {:ok, token}
26
      _missing_or_ambiguous -> {:error, :missing_api_token}
27
    end
28
  end
29
30
  defp refuse(conn) do
31
    conn
32
    |> put_status(:unauthorized)
33
    |> put_resp_header("cache-control", "no-store")
34
    |> Phoenix.Controller.json(%{"error" => "invalid_api_token"})
35
    |> halt()
36
  end
37
end
lib/openagents_web/route_authority.ex added +194

@@ -0,0 +1,194 @@

1
defmodule OpenAgentsWeb.RouteAuthority do
2
  @moduledoc """
3
  Executable authority inventory for every Phoenix route and endpoint socket.
4
5
  The classifier intentionally has no catch-all policy. A route outside one of
6
  these bounded surfaces is `:unclassified`, which fails the inventory test
7
  until its principal and scope are chosen deliberately.
8
  """
9
10
  @classes [
11
    :public_read,
12
    :authenticated_browser,
13
    :authenticated_api,
14
    :operator,
15
    :machine,
16
    :internal_service,
17
    :git_transport
18
  ]
19
20
  @public_browser_paths [
21
    "/",
22
    "/status",
23
    "/changelog",
24
    "/leaderboard",
25
    "/components",
26
    "/components/icons",
27
    "/components/:slug",
28
    "/docs",
29
    "/healthz"
30
  ]
31
32
  @authenticated_browser_prefixes [
33
    "/chat",
34
    "/computers",
35
    "/voice/",
36
    "/data",
37
    "/machines",
38
    "/memory/",
39
    "/settings/api-tokens",
40
    "/github/connection",
41
    "/api/tokens",
42
    "/api/computers",
43
    "/api/computer-agent-jobs/"
44
  ]
45
46
  @spec classes() :: [atom()]
47
  def classes, do: @classes
48
49
  @spec inventory() :: [map()]
50
  def inventory do
51
    routes = Enum.map(OpenAgentsWeb.Router.__routes__(), &classify/1)
52
    routes ++ socket_inventory()
53
  end
54
55
  @spec classify(map()) :: map()
56
  def classify(route) do
57
    base = %{
58
      transport: :http,
59
      verb: to_string(route.verb),
60
      path: route.path,
61
      handler: inspect(route.plug),
62
      action: inspect(route.plug_opts)
63
    }
64
65
    Map.merge(base, policy(route))
66
  end
67
68
  @spec socket_inventory() :: [map()]
69
  def socket_inventory do
70
    [
71
      %{
72
        transport: :websocket,
73
        verb: "connect",
74
        path: "/live",
75
        handler: "Phoenix.LiveView.Socket",
76
        action: "connect",
77
        class: :authenticated_browser,
78
        principal: "encrypted browser session",
79
        scope: "liveview:session",
80
        mutation: true
81
      },
82
      %{
83
        transport: :websocket,
84
        verb: "connect",
85
        path: "/controller",
86
        handler: "OpenAgentsWeb.ControllerSocket",
87
        action: "connect",
88
        class: :machine,
89
        principal: "active paired-machine bearer",
90
        scope: "machine:channel",
91
        mutation: true
92
      }
93
    ]
94
  end
95
96
  defp policy(%{path: path, verb: verb})
97
       when path in @public_browser_paths and verb in [:get, :head],
98
       do: declaration(:public_read, "anonymous", "published:web", false)
99
100
  defp policy(%{path: "/OpenAgentsInc/" <> _path, verb: verb}) when verb in [:get, :head],
101
    do: declaration(:public_read, "anonymous", "published:source", false)
102
103
  defp policy(%{path: "/auth/github", verb: :post}),
104
    do: declaration(:authenticated_browser, "explicit OAuth applicant", "identity:connect", true)
105
106
  defp policy(%{path: "/auth/github/callback"}),
107
    do: declaration(:authenticated_browser, "one-time OAuth attempt", "identity:connect", true)
108
109
  defp policy(%{path: "/logout"}),
110
    do: declaration(:authenticated_browser, "encrypted browser session", "session:delete", true)
111
112
  defp policy(%{path: "/admin/forge"}),
113
    do: declaration(:operator, "configured operator GitHub ID", "forge:promote", true)
114
115
  defp policy(%{path: "/admin"}),
116
    do: declaration(:operator, "configured operator GitHub ID", "voice:metadata:read", false)
117
118
  defp policy(%{path: "/git"}),
119
    do:
120
      declaration(
121
        :git_transport,
122
        "operator or active paired-machine HTTP credential",
123
        "git:repository",
124
        true
125
      )
126
127
  defp policy(%{path: "/api/status", verb: verb}) when verb in [:get, :head],
128
    do: declaration(:public_read, "anonymous", "published:status", false)
129
130
  defp policy(%{path: "/api/changelog", verb: verb}) when verb in [:get, :head],
131
    do: declaration(:public_read, "anonymous", "published:changelog", false)
132
133
  defp policy(%{path: "/controller/pairings", verb: :post}),
134
    do: declaration(:machine, "unpaired machine", "machine:pairing:create", true)
135
136
  defp policy(%{path: "/controller/pairings/:id"}),
137
    do: declaration(:machine, "expiring one-time poll secret", "machine:pairing:claim", true)
138
139
  defp policy(%{path: "/api/inference/proxy"}),
140
    do: declaration(:internal_service, "scoped inference grant", "inference:invoke", true)
141
142
  defp policy(%{path: "/api/v3/" <> _path, verb: verb}) when verb in [:get, :head],
143
    do: declaration(:public_read, "anonymous", "published:forge", false)
144
145
  defp policy(%{path: "/api/v3/" <> _path}),
146
    do: declaration(:authenticated_api, "first-party bearer token", "forge:write", true)
147
148
  defp policy(%{path: "/dev/" <> _path}),
149
    do:
150
      declaration(:internal_service, "development-only browser", "development:diagnostics", false)
151
152
  defp policy(%{path: path, verb: verb}) do
153
    cond do
154
      Enum.any?(@authenticated_browser_prefixes, &String.starts_with?(path, &1)) ->
155
        declaration(
156
          :authenticated_browser,
157
          "active encrypted browser session",
158
          browser_scope(path),
159
          browser_mutation?(path, verb)
160
        )
161
162
      tracker_browser_path?(path) ->
163
        declaration(:authenticated_browser, "active encrypted browser session", "forge:web", true)
164
165
      true ->
166
        %{class: :unclassified, principal: nil, scope: nil, mutation: mutation_verb?(path)}
167
    end
168
  end
169
170
  defp declaration(class, principal, scope, mutation) do
171
    %{class: class, principal: principal, scope: scope, mutation: mutation}
172
  end
173
174
  defp browser_scope("/api/tokens" <> _path), do: "api-token:self"
175
  defp browser_scope("/api/computers" <> _path), do: "computer:self"
176
  defp browser_scope("/api/computer-agent-jobs/" <> _path), do: "computer-job:self"
177
  defp browser_scope("/voice/" <> _path), do: "voice:self"
178
  defp browser_scope("/data" <> _path), do: "data:self"
179
  defp browser_scope("/memory/" <> _path), do: "memory:self"
180
  defp browser_scope("/github/connection"), do: "github-tools:self"
181
  defp browser_scope("/settings/api-tokens"), do: "api-token:self"
182
  defp browser_scope(_path), do: "product:self"
183
184
  defp browser_mutation?(path, :get),
185
    do: path in ["/chat", "/computers", "/settings/api-tokens"]
186
187
  defp browser_mutation?(_path, _verb), do: true
188
189
  defp tracker_browser_path?(path) do
190
    String.match?(path, ~r{\A/:owner/:repo/(issues|labels|milestones|assignees|projects)})
191
  end
192
193
  defp mutation_verb?(_path), do: true
194
end
lib/openagents_web/router.ex modified +50 -37

@@ -30,15 +30,22 @@ defmodule OpenAgentsWeb.Router do

30 30
    plug :accepts, ["json"]
31 31
    plug :fetch_session
32 32
    plug :fetch_current_user
33
    plug :put_no_store
33 34
    plug :protect_from_forgery
34 35
    plug :require_authenticated_api_user
35 36
  end
36 37
38
  pipeline :forge_write_api do
39
    plug :accepts, ["json"]
40
    plug OpenAgentsWeb.Plugs.ApiTokenAuth, scope: "forge:write"
41
  end
42
37 43
  pipeline :status_probe_compat do
38 44
    plug OpenAgentsWeb.Plugs.StatusProbeCompat
39 45
  end
40 46
41 47
  pipeline :authenticated do
48
    plug :put_no_store
42 49
    plug :require_authenticated_user
43 50
  end
44 51

@@ -70,7 +77,7 @@ defmodule OpenAgentsWeb.Router do

70 77
    end
71 78
72 79
    post "/auth/github", AuthController, :start
73
    get "/auth/github/callback", AuthController, :callback
80
    get "/auth/github/callback", AuthController, :callback, log: false
74 81
    delete "/logout", AuthController, :logout
75 82
    get "/healthz", HealthController, :show
76 83
  end

@@ -101,6 +108,7 @@ defmodule OpenAgentsWeb.Router do

101 108
      on_mount: [{OpenAgentsWeb.UserAuth, :ensure_authenticated}] do
102 109
      live "/chat", ChatLive, :index
103 110
      live "/computers", ComputersLive, :index
111
      live "/settings/api-tokens", ApiTokensLive, :index
104 112
      live "/admin", AdminLive, :index
105 113
      live "/admin/forge", AdminForgeLive, :index
106 114

@@ -134,11 +142,16 @@ defmodule OpenAgentsWeb.Router do

134 142
    get "/machines", LegacyMachinesController, :show
135 143
136 144
    get "/memory/export", MemoryExportController, :show
145
    delete "/github/connection", AuthController, :disconnect
137 146
  end
138 147
139 148
  scope "/api", OpenAgentsWeb do
140 149
    pipe_through :authenticated_api
141 150
151
    get "/tokens", ApiTokenController, :index
152
    post "/tokens", ApiTokenController, :create
153
    delete "/tokens/:id", ApiTokenController, :delete
154
142 155
    get "/computers", ComputersController, :index
143 156
    post "/computers/pairings/:id/approve", ComputersController, :approve_pairing
144 157
    delete "/computers/:id", ComputersController, :delete

@@ -163,52 +176,52 @@ defmodule OpenAgentsWeb.Router do

163 176
  scope "/api/v3", OpenAgentsWeb do
164 177
    pipe_through :api
165 178
166
    resources "/repos/:owner/:repo/issues", IssueController,
167
      only: [:index, :create, :show, :update],
168
      param: "issue_number"
169
170
    resources "/repos/:owner/:repo/issues/:issue_number/comments", CommentController,
171
      only: [:index, :create]
172
173
    resources "/repos/:owner/:repo/issues/comments", CommentController,
174
      only: [:show, :update, :delete]
175
176
    resources "/repos/:owner/:repo/issues/:issue_number/labels", IssueLabelController,
177
      only: [:index, :create]
178
179
    delete "/repos/:owner/:repo/issues/:issue_number/labels/:name",
180
           IssueLabelController,
181
           :delete
182
183
    resources "/repos/:owner/:repo/issues/:issue_number/assignees", IssueAssigneeController,
184
      only: [:index, :create]
185
186
    delete "/repos/:owner/:repo/issues/:issue_number/assignees",
187
           IssueAssigneeController,
188
           :delete
189
190
    resources "/repos/:owner/:repo/labels", LabelController,
191
      only: [:index, :create, :show, :update, :delete],
192
      param: "name"
193
194
    resources "/repos/:owner/:repo/milestones", MilestoneController,
195
      only: [:index, :create, :show, :update, :delete],
196
      param: "milestone_number"
197
179
    get "/repos/:owner/:repo/issues", IssueController, :index
180
    get "/repos/:owner/:repo/issues/:issue_number", IssueController, :show
181
    get "/repos/:owner/:repo/issues/:issue_number/comments", CommentController, :index
182
    get "/repos/:owner/:repo/issues/comments/:id", CommentController, :show
183
    get "/repos/:owner/:repo/issues/:issue_number/labels", IssueLabelController, :index
184
    get "/repos/:owner/:repo/issues/:issue_number/assignees", IssueAssigneeController, :index
185
    get "/repos/:owner/:repo/labels", LabelController, :index
186
    get "/repos/:owner/:repo/labels/:name", LabelController, :show
187
    get "/repos/:owner/:repo/milestones", MilestoneController, :index
188
    get "/repos/:owner/:repo/milestones/:milestone_number", MilestoneController, :show
198 189
    get "/repos/:owner/:repo/assignees", AssigneeController, :index
199 190
    get "/repos/:owner/:repo/assignees/:assignee", AssigneeController, :show
200
201 191
    get "/users/:username/projectsV2", ProjectController, :index
202
    post "/:owner/projectsV2", ProjectController, :create
203 192
    get "/users/:username/projectsV2/:project_number", ProjectController, :show
204 193
    get "/users/:username/projectsV2/:project_number/items", ProjectController, :items
194
    get "/users/:username/projectsV2/:project_number/fields", ProjectController, :fields
195
  end
196
197
  scope "/api/v3", OpenAgentsWeb do
198
    pipe_through :forge_write_api
199
200
    post "/repos/:owner/:repo/issues", IssueController, :create
201
    put "/repos/:owner/:repo/issues/:issue_number", IssueController, :update
202
    patch "/repos/:owner/:repo/issues/:issue_number", IssueController, :update
203
    post "/repos/:owner/:repo/issues/:issue_number/comments", CommentController, :create
204
    put "/repos/:owner/:repo/issues/comments/:id", CommentController, :update
205
    patch "/repos/:owner/:repo/issues/comments/:id", CommentController, :update
206
    delete "/repos/:owner/:repo/issues/comments/:id", CommentController, :delete
207
    post "/repos/:owner/:repo/issues/:issue_number/labels", IssueLabelController, :create
208
    delete "/repos/:owner/:repo/issues/:issue_number/labels/:name", IssueLabelController, :delete
209
    post "/repos/:owner/:repo/issues/:issue_number/assignees", IssueAssigneeController, :create
210
    delete "/repos/:owner/:repo/issues/:issue_number/assignees", IssueAssigneeController, :delete
211
    post "/repos/:owner/:repo/labels", LabelController, :create
212
    put "/repos/:owner/:repo/labels/:name", LabelController, :update
213
    patch "/repos/:owner/:repo/labels/:name", LabelController, :update
214
    delete "/repos/:owner/:repo/labels/:name", LabelController, :delete
215
    post "/repos/:owner/:repo/milestones", MilestoneController, :create
216
    put "/repos/:owner/:repo/milestones/:milestone_number", MilestoneController, :update
217
    patch "/repos/:owner/:repo/milestones/:milestone_number", MilestoneController, :update
218
    delete "/repos/:owner/:repo/milestones/:milestone_number", MilestoneController, :delete
219
    post "/:owner/projectsV2", ProjectController, :create
205 220
    post "/users/:username/projectsV2/:project_number/items", ProjectController, :create_item
206 221
207 222
    patch "/users/:username/projectsV2/:project_number/items/:item_id",
208 223
          ProjectController,
209 224
          :update_item
210
211
    get "/users/:username/projectsV2/:project_number/fields", ProjectController, :fields
212 225
  end
213 226
214 227
  # Enable LiveDashboard and Swoosh mailbox preview in development
lib/openagents_web/user_auth.ex modified +2

@@ -9,6 +9,8 @@ defmodule OpenAgentsWeb.UserAuth do

9 9
10 10
  @session_key "user_id"
11 11
12
  def put_no_store(conn, _options), do: put_resp_header(conn, "cache-control", "no-store")
13
12 14
  def fetch_current_user(conn, _options) do
13 15
    with user_id when is_binary(user_id) <- get_session(conn, @session_key),
14 16
         {:ok, user} <- Accounts.get_active_user(user_id) do
ops/ci/private-log-scan.exs added +24

@@ -0,0 +1,24 @@

1
#!/usr/bin/env elixir
2
3
case System.argv() do
4
  [path] ->
5
    case OpenAgents.LogSafety.scan_file(path) do
6
      :ok ->
7
        IO.puts("private_log_scan=clean")
8
9
      {:error, findings} when is_list(findings) ->
10
        Enum.each(findings, fn finding ->
11
          IO.puts(:stderr, "private_log_scan=#{finding.kind} line=#{finding.line}")
12
        end)
13
14
        System.halt(1)
15
16
      {:error, :unreadable} ->
17
        IO.puts(:stderr, "private_log_scan=unreadable")
18
        System.halt(1)
19
    end
20
21
  _arguments ->
22
    IO.puts(:stderr, "usage: mix run --no-start ops/ci/private-log-scan.exs LOG_FILE")
23
    System.halt(64)
24
end
ops/ci/release-smoke.sh modified +2

@@ -67,6 +67,7 @@ readiness_report=$(env \

67 67
  GITHUB_CLIENT_ID="release-smoke-client" \
68 68
  GITHUB_CLIENT_SECRET="release-smoke-secret" \
69 69
  GITHUB_TOKEN_ENCRYPTION_KEY="$github_token_key" \
70
  GITHUB_TOKEN_ENCRYPTION_KEY_ID="staging-release-smoke-2026-08" \
70 71
  OPENAI_API_KEY="release-smoke-openai-key" \
71 72
  POOL_SIZE="2" \
72 73
  PORT="$port" \

@@ -86,6 +87,7 @@ env \

86 87
  GITHUB_CLIENT_ID="release-smoke-client" \
87 88
  GITHUB_CLIENT_SECRET="release-smoke-secret" \
88 89
  GITHUB_TOKEN_ENCRYPTION_KEY="$github_token_key" \
90
  GITHUB_TOKEN_ENCRYPTION_KEY_ID="staging-release-smoke-2026-08" \
89 91
  OPENAI_API_KEY="release-smoke-openai-key" \
90 92
  PHX_SERVER="true" \
91 93
  POOL_SIZE="2" \
ops/staging/gate-5-profile.sh modified +4 -1

@@ -7,11 +7,13 @@ set -eu

7 7
: "${GITHUB_CLIENT_ID:?GITHUB_CLIENT_ID is required}"
8 8
: "${GITHUB_CLIENT_SECRET:?GITHUB_CLIENT_SECRET is required}"
9 9
: "${GITHUB_TOKEN_ENCRYPTION_KEY:?GITHUB_TOKEN_ENCRYPTION_KEY is required}"
10
: "${GITHUB_TOKEN_ENCRYPTION_KEY_ID:?GITHUB_TOKEN_ENCRYPTION_KEY_ID is required}"
10 11
: "${OPENAI_API_KEY:?OPENAI_API_KEY is required}"
11 12
: "${SECRET_KEY_BASE:?SECRET_KEY_BASE is required}"
12 13
13
export GITHUB_OAUTH_SCOPES="read:user,repo"
14
export GITHUB_OAUTH_SCOPES="repo"
14 15
export GITHUB_REDIRECT_URI="https://stage.openagents.com/auth/github/callback"
16
export GITHUB_TOKEN_DECRYPTION_KEYS_JSON="${GITHUB_TOKEN_DECRYPTION_KEYS_JSON:-{}}"
15 17
export OPENAGENTS_ALLOWED_ORIGINS="https://stage.openagents.com"
16 18
export OPENAGENTS_CODING_JOBS_DIR="/var/lib/openagents/coding-jobs"
17 19
export OPENAGENTS_DATABASE_IPV6="false"

@@ -55,6 +57,7 @@ export OPENAGENTS_FORGE_WAL_BUCKET=""

55 57
export OPENAGENTS_FORGE_WAL_DIR="/var/lib/openagents/forge-wal"
56 58
export OPENAGENTS_HTTPS_ALIASES=""
57 59
export OPENAGENTS_INFERENCE_PROXY_URL=""
60
export OPENAGENTS_MACHINE_TOKEN_TTL_SECONDS="2592000"
58 61
export OPENAGENTS_MIGRATE_ON_BOOT="true"
59 62
export OPENAGENTS_PRODUCTION_DEPLOY_ENABLED="false"
60 63
export OPENAGENTS_RA_DATA_DIR="/var/lib/openagents/ra"
priv/repo/migrations/20260820073810_add_github_token_lifecycle_metadata.exs added +40

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

1
defmodule OpenAgents.Repo.Migrations.AddGithubTokenLifecycleMetadata do
2
  use Ecto.Migration
3
4
  def up do
5
    alter table(:users) do
6
      add :github_token_key_id, :string
7
      add :github_token_scopes, {:array, :string}, null: false, default: []
8
      add :github_token_connected_at, :utc_datetime_usec
9
      add :github_token_rotated_at, :utc_datetime_usec
10
    end
11
12
    execute("""
13
    UPDATE users
14
    SET github_token_key_id = 'legacy-v1',
15
        github_token_scopes = ARRAY['read:user', 'repo'],
16
        github_token_connected_at = COALESCE(updated_at, inserted_at)
17
    WHERE github_token_ciphertext IS NOT NULL
18
    """)
19
20
    create constraint(:users, :users_github_token_connection_state_check,
21
             check: """
22
             (github_token_ciphertext IS NULL AND github_token_key_id IS NULL AND
23
              github_token_connected_at IS NULL AND github_token_scopes = '{}') OR
24
             (github_token_ciphertext IS NOT NULL AND github_token_key_id IS NOT NULL AND
25
              github_token_connected_at IS NOT NULL AND cardinality(github_token_scopes) > 0)
26
             """
27
           )
28
  end
29
30
  def down do
31
    drop constraint(:users, :users_github_token_connection_state_check)
32
33
    alter table(:users) do
34
      remove :github_token_key_id
35
      remove :github_token_scopes
36
      remove :github_token_connected_at
37
      remove :github_token_rotated_at
38
    end
39
  end
40
end
priv/repo/migrations/20260820074227_create_api_tokens.exs added +38

@@ -0,0 +1,38 @@

1
defmodule OpenAgents.Repo.Migrations.CreateApiTokens do
2
  use Ecto.Migration
3
4
  def up do
5
    create table(:api_tokens, primary_key: false) do
6
      add :id, :binary_id, primary_key: true
7
      add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
8
      add :name, :string, null: false
9
      add :token_digest, :binary, null: false
10
      add :scopes, {:array, :string}, null: false
11
      add :expires_at, :utc_datetime_usec, null: false
12
      add :last_used_at, :utc_datetime_usec
13
      add :revoked_at, :utc_datetime_usec
14
15
      timestamps(type: :utc_datetime_usec)
16
    end
17
18
    create unique_index(:api_tokens, [:token_digest])
19
    create index(:api_tokens, [:user_id, :inserted_at])
20
    create constraint(:api_tokens, :api_tokens_scopes_present, check: "cardinality(scopes) > 0")
21
22
    create constraint(:api_tokens, :api_tokens_scopes_allowed,
23
             check: "scopes <@ ARRAY['forge:write']::varchar[]"
24
           )
25
26
    create constraint(:api_tokens, :api_tokens_expiry_after_creation,
27
             check: "expires_at > inserted_at"
28
           )
29
30
    create constraint(:api_tokens, :api_tokens_digest_length,
31
             check: "octet_length(token_digest) = 32"
32
           )
33
  end
34
35
  def down do
36
    drop table(:api_tokens)
37
  end
38
end
priv/repo/migrations/20260820074644_add_machine_token_expiry.exs added +27

@@ -0,0 +1,27 @@

1
defmodule OpenAgents.Repo.Migrations.AddMachineTokenExpiry do
2
  use Ecto.Migration
3
4
  def up do
5
    alter table(:machines) do
6
      add :token_expires_at, :utc_datetime_usec
7
    end
8
9
    execute("UPDATE machines SET token_expires_at = NOW() + INTERVAL '30 days'")
10
11
    alter table(:machines) do
12
      modify :token_expires_at, :utc_datetime_usec, null: false
13
    end
14
15
    create constraint(:machines, :machines_token_expiry_after_creation,
16
             check: "token_expires_at > inserted_at"
17
           )
18
  end
19
20
  def down do
21
    drop_if_exists constraint(:machines, :machines_token_expiry_after_creation)
22
23
    alter table(:machines) do
24
      remove :token_expires_at
25
    end
26
  end
27
end
rel/overlays/bin/rotate-github-tokens added +6

@@ -0,0 +1,6 @@

1
#!/bin/sh
2
set -eu
3
4
cd -P -- "$(dirname -- "$0")"
5
./prepare-config >/dev/null
6
exec ./openagents eval OpenAgents.Release.rotate_github_tokens
test/openagents/accounts/token_vault_test.exs modified +26

@@ -43,4 +43,30 @@ defmodule OpenAgents.Accounts.TokenVaultTest do

43 43
    assert {:error, :token_vault_not_configured} = TokenVault.seal("gho_whatever")
44 44
    assert {:error, :token_vault_not_configured} = TokenVault.open(sealed)
45 45
  end
46
47
  test "a versioned keyring opens old envelopes while new seals use the active key" do
48
    original_key = Application.fetch_env!(:openagents, :github_token_encryption_key)
49
    original_id = Application.fetch_env!(:openagents, :github_token_encryption_key_id)
50
    original_previous = Application.fetch_env!(:openagents, :github_token_decryption_keys)
51
    old_key = Base.encode64(:crypto.strong_rand_bytes(32))
52
    new_key = Base.encode64(:crypto.strong_rand_bytes(32))
53
54
    on_exit(fn ->
55
      Application.put_env(:openagents, :github_token_encryption_key, original_key)
56
      Application.put_env(:openagents, :github_token_encryption_key_id, original_id)
57
      Application.put_env(:openagents, :github_token_decryption_keys, original_previous)
58
    end)
59
60
    Application.put_env(:openagents, :github_token_encryption_key, old_key)
61
    Application.put_env(:openagents, :github_token_encryption_key_id, "staging-old")
62
    assert {:ok, old_envelope} = TokenVault.seal("gho_rotate_me")
63
64
    Application.put_env(:openagents, :github_token_encryption_key, new_key)
65
    Application.put_env(:openagents, :github_token_encryption_key_id, "staging-current")
66
    Application.put_env(:openagents, :github_token_decryption_keys, %{"staging-old" => old_key})
67
68
    assert {:ok, "gho_rotate_me"} = TokenVault.open(old_envelope)
69
    assert {:ok, new_envelope} = TokenVault.seal("gho_new")
70
    assert {:ok, "staging-current"} = TokenVault.key_id(new_envelope)
71
  end
46 72
end
test/openagents/accounts_test.exs modified +41

@@ -73,6 +73,47 @@ defmodule OpenAgents.AccountsTest do

73 73
    assert Conversations.get_conversation_for_browser("legacy-browser-credential").id == legacy.id
74 74
  end
75 75
76
  test "retained GitHub grants can be rewrapped and disconnected without deleting identity" do
77
    assert {:ok, user} = Accounts.upsert_github_user(profile(501, "token-owner"))
78
79
    assert {:error, :invalid_token_scopes} =
80
             Accounts.store_github_token(user, "gho_too_broad", ["read:user", "repo"])
81
82
    assert {:ok, connected} = Accounts.store_github_token(user, "gho_retained")
83
    assert connected.github_token_key_id == "test-2026-08"
84
85
    assert {:ok, rotated} = Accounts.rotate_github_token(connected)
86
    assert rotated.github_token_rotated_at
87
    assert {:ok, "gho_retained"} = Accounts.github_token(rotated)
88
89
    assert {:ok, disconnected} =
90
             Accounts.disconnect_github(rotated, fn token ->
91
               assert token == "gho_retained"
92
               :ok
93
             end)
94
95
    assert disconnected.id == user.id
96
    assert disconnected.github_token_ciphertext == nil
97
    assert disconnected.github_token_scopes == []
98
    assert {:error, :github_token_missing} = Accounts.github_token(disconnected)
99
  end
100
101
  test "disconnecting a stale envelope never clears a concurrently replaced grant" do
102
    assert {:ok, user} = Accounts.upsert_github_user(profile(502, "token-race-owner"))
103
    assert {:ok, old_connection} = Accounts.store_github_token(user, "gho_old")
104
    assert {:ok, new_connection} = Accounts.store_github_token(old_connection, "gho_new")
105
106
    assert {:error, :github_connection_changed} =
107
             Accounts.disconnect_github(old_connection, fn token ->
108
               assert token == "gho_old"
109
               :ok
110
             end)
111
112
    retained = Accounts.get_user(user.id)
113
    assert retained.github_token_ciphertext == new_connection.github_token_ciphertext
114
    assert {:ok, "gho_new"} = Accounts.github_token(retained)
115
  end
116
76 117
  defp profile(id, login, avatar_url \\ nil) do
77 118
    %{
78 119
      github_id: id,
test/openagents/api_tokens_test.exs added +66

@@ -0,0 +1,66 @@

1
defmodule OpenAgents.ApiTokensTest do
2
  use OpenAgents.DataCase, async: true
3
4
  alias OpenAgents.{Accounts, ApiTokens, Repo}
5
6
  test "tokens are stored as digests, require scope, expire, and revoke" do
7
    {:ok, user} = Accounts.upsert_github_user(profile(801, "api-owner"))
8
9
    assert {:ok, token, plaintext} =
10
             ApiTokens.create(user, %{
11
               name: "automation",
12
               scopes: ["forge:write"],
13
               lifetime_days: 7
14
             })
15
16
    refute token.token_digest =~ plaintext
17
    assert {:ok, authenticated, used} = ApiTokens.authenticate(plaintext, "forge:write")
18
    assert authenticated.id == user.id
19
    assert used.last_used_at
20
    assert {:error, :invalid_api_token} = ApiTokens.authenticate(plaintext, "computers:write")
21
22
    backdated = DateTime.add(DateTime.utc_now(), -2, :day)
23
24
    Repo.update_all(from(t in OpenAgents.ApiTokens.ApiToken, where: t.id == ^token.id),
25
      set: [inserted_at: backdated]
26
    )
27
28
    expired =
29
      Repo.get!(OpenAgents.ApiTokens.ApiToken, token.id)
30
      |> Ecto.Changeset.change(expires_at: DateTime.add(DateTime.utc_now(), -1, :second))
31
      |> Repo.update!()
32
33
    assert {:error, :invalid_api_token} = ApiTokens.authenticate(plaintext, "forge:write")
34
35
    fresh =
36
      expired
37
      |> Ecto.Changeset.change(expires_at: DateTime.add(DateTime.utc_now(), 1, :day))
38
      |> Repo.update!()
39
40
    assert {:ok, revoked} = ApiTokens.revoke(user, fresh.id)
41
    assert revoked.revoked_at
42
    assert {:error, :invalid_api_token} = ApiTokens.authenticate(plaintext, "forge:write")
43
  end
44
45
  test "invalid scopes and lifetimes fail closed" do
46
    {:ok, user} = Accounts.upsert_github_user(profile(802, "api-invalid"))
47
48
    assert {:error, :invalid_api_token} =
49
             ApiTokens.create(user, %{name: "too broad", scopes: ["admin"], lifetime_days: 1})
50
51
    assert {:error, :invalid_api_token} =
52
             ApiTokens.create(user, %{
53
               name: "too long",
54
               scopes: ["forge:write"],
55
               lifetime_days: 91
56
             })
57
  end
58
59
  defp profile(id, login) do
60
    %{
61
      github_id: id,
62
      github_login: login,
63
      github_avatar_url: "https://avatars.githubusercontent.com/u/#{id}?v=4"
64
    }
65
  end
66
end
test/openagents/forge/builder_test.exs modified +22 -2

@@ -10,8 +10,28 @@ defmodule OpenAgents.Forge.BuilderTest do

10 10
11 11
  describe "pure Sidecar adapter pieces" do
12 12
    test "render_job serializes the two env-style lines the watcher sources" do
13
      assert Sidecar.render_job("abc123", "http://x:tok@127.0.0.1:8080/git/openagents.com.git") ==
14
               "SHA=abc123\nREPO_URL=http://x:tok@127.0.0.1:8080/git/openagents.com.git\n"
13
      assert Sidecar.render_job("abc123", "http://127.0.0.1:8080/git/openagents.com.git") ==
14
               "SHA=abc123\nREPO_URL=http://127.0.0.1:8080/git/openagents.com.git\n"
15
16
      refute Sidecar.render_job("abc123", "http://127.0.0.1/repo.git") =~ "token"
17
    end
18
19
    test "sidecar repository URLs never contain the operator credential" do
20
      previous_url = Application.get_env(:openagents, :forge_internal_git_url)
21
      previous_token = Application.get_env(:openagents, :forge_operator_token)
22
23
      on_exit(fn ->
24
        Application.put_env(:openagents, :forge_internal_git_url, previous_url)
25
        Application.put_env(:openagents, :forge_operator_token, previous_token)
26
      end)
27
28
      Application.put_env(:openagents, :forge_internal_git_url, "http://forge.internal/git")
29
      Application.put_env(:openagents, :forge_operator_token, "forge-secret-sentinel")
30
31
      assert Sidecar.repo_url("openagents.com") ==
32
               "http://forge.internal/git/openagents.com.git"
33
34
      refute Sidecar.repo_url("openagents.com") =~ "forge-secret-sentinel"
15 35
    end
16 36
17 37
    test "parse_result reads env-style lines, tolerating garbage" do
test/openagents/forge/mirror_watch_test.exs modified +11

@@ -140,6 +140,17 @@ defmodule OpenAgents.Forge.MirrorWatchTest do

140 140
    assert incident_count() == 0
141 141
  end
142 142
143
  test "credential-bearing and scp-style mirror remotes fail closed" do
144
    for url <- [
145
          "https://operator:secret@mirror.example/openagents.com.git",
146
          "ssh://operator:secret@mirror.example/openagents.com.git",
147
          "operator:secret@mirror.example:openagents.com.git"
148
        ] do
149
      Application.put_env(:openagents, :forge_mirror_urls, %{"openagents.com" => url})
150
      assert OpenAgents.Forge.Pushes.mirror_url("openagents.com") == nil
151
    end
152
  end
153
143 154
  defp incident_count do
144 155
    Repo.aggregate(
145 156
      from(i in OpenAgents.Incidents.Incident, where: i.code == "forge_mirror_lagging"),
test/openagents/github_oauth_test.exs modified +32 -6

@@ -11,7 +11,7 @@ defmodule OpenAgents.GitHubOAuthTest do

11 11
12 12
    assert query["client_id"] == "test-github-client-id"
13 13
    assert query["redirect_uri"] == "http://127.0.0.1:4002/auth/github/callback"
14
    assert query["scope"] == "read:user repo"
14
    assert query["scope"] == "repo"
15 15
    assert query["state"] == attempt["state"]
16 16
    assert query["code_challenge_method"] == "S256"
17 17
    assert byte_size(query["code_challenge"]) == 43

@@ -50,7 +50,12 @@ defmodule OpenAgents.GitHubOAuthTest do

50 50
      assert body =~ "client_secret=test-github-client-secret"
51 51
      assert body =~ "code=github-code"
52 52
      assert body =~ "code_verifier="
53
      Req.Test.json(conn, %{"access_token" => "short-lived-token", "token_type" => "bearer"})
53
54
      Req.Test.json(conn, %{
55
        "access_token" => "short-lived-token",
56
        "token_type" => "bearer",
57
        "scope" => "repo"
58
      })
54 59
    end)
55 60
56 61
    Req.Test.expect(__MODULE__, fn conn ->

@@ -68,7 +73,9 @@ defmodule OpenAgents.GitHubOAuthTest do

68 73
69 74
    verifier = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
70 75
71
    assert {:ok, profile, access_token} = GitHubOAuth.exchange_and_fetch("github-code", verifier)
76
    assert {:ok, profile, access_token, ["repo"]} =
77
             GitHubOAuth.exchange_and_fetch("github-code", verifier)
78
72 79
    assert access_token == "short-lived-token"
73 80
    assert profile.github_id == 7_654
74 81
    assert profile.github_login == "octo-user"

@@ -87,7 +94,10 @@ defmodule OpenAgents.GitHubOAuthTest do

87 94
      setup_req_test()
88 95
89 96
      Req.Test.expect(__MODULE__, fn conn ->
90
        Req.Test.json(conn, %{"access_token" => "short-lived-token"})
97
        Req.Test.json(conn, %{
98
          "access_token" => "short-lived-token",
99
          "scope" => "repo"
100
        })
91 101
      end)
92 102
93 103
      Req.Test.expect(__MODULE__, fn conn ->

@@ -105,7 +115,7 @@ defmodule OpenAgents.GitHubOAuthTest do

105 115
106 116
      # GitHub leaves `name` null far more often than not, so an unusable value
107 117
      # must degrade to no name rather than fail the login.
108
      assert {:ok, profile, _access_token} =
118
      assert {:ok, profile, _access_token, _scopes} =
109 119
               GitHubOAuth.exchange_and_fetch("github-code", verifier)
110 120
111 121
      assert profile.github_name == expected

@@ -117,7 +127,7 @@ defmodule OpenAgents.GitHubOAuthTest do

117 127
    setup_req_test()
118 128
119 129
    Req.Test.expect(__MODULE__, fn conn ->
120
      Req.Test.json(conn, %{"access_token" => "provider-token"})
130
      Req.Test.json(conn, %{"access_token" => "provider-token", "scope" => "repo"})
121 131
    end)
122 132
123 133
    Req.Test.expect(__MODULE__, fn conn ->

@@ -132,6 +142,22 @@ defmodule OpenAgents.GitHubOAuthTest do

132 142
    assert {:error, :invalid_github_profile} = GitHubOAuth.exchange_and_fetch("code", verifier)
133 143
  end
134 144
145
  test "a missing or broadened granted scope fails before profile lookup" do
146
    for scope <- [nil, "", "read:user", "repo,admin:org"] do
147
      setup_req_test()
148
149
      Req.Test.expect(__MODULE__, fn conn ->
150
        body = %{"access_token" => "provider-token"}
151
        Req.Test.json(conn, if(scope, do: Map.put(body, "scope", scope), else: body))
152
      end)
153
154
      verifier = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
155
156
      expected = if scope, do: :oauth_scope_mismatch, else: :invalid_oauth_token_response
157
      assert {:error, ^expected} = GitHubOAuth.exchange_and_fetch("code", verifier)
158
    end
159
  end
160
135 161
  defp setup_req_test do
136 162
    original = Application.fetch_env!(:openagents, :github_oauth)
137 163
test/openagents/log_safety_test.exs added +61

@@ -0,0 +1,61 @@

1
defmodule OpenAgents.LogSafetyTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.LogSafety
5
6
  test "content-free operational lines and filtered parameters pass" do
7
    assert :ok =
8
             LogSafety.scan([
9
               "request_id=abc Sent 200 in 14ms\n",
10
               "voice_operation {\"event\":\"connected\",\"total_tokens\":42}\n",
11
               "Parameters: %{\"code\" => \"[FILTERED]\", \"content\" => \"[FILTERED]\"}\n"
12
             ])
13
  end
14
15
  test "credential, OAuth query, URL userinfo, and private content fields fail without echoing values" do
16
    lines = [
17
      "GET /auth/github/callback?code=oauth-secret&state=state-secret\n",
18
      "authorization: Bearer smct_machine-secret-value\n",
19
      "clone ecto://x:database-secret@database/openagents\n",
20
      "push operator:forge-secret@mirror.example:openagents.com.git\n",
21
      ~s|payload {"raw_arguments":"private tool value"}\n|,
22
      ~s|event=%{transcript: "private spoken value"}\n|
23
    ]
24
25
    assert {:error, findings} = LogSafety.scan(lines)
26
27
    kinds = Enum.frequencies_by(findings, & &1.kind)
28
    assert kinds == %{credential: 1, oauth_query: 1, private_field: 3, url_userinfo: 2}
29
30
    refute inspect(findings) =~ "secret"
31
    refute inspect(findings) =~ "private tool value"
32
    refute inspect(findings) =~ "private spoken value"
33
  end
34
35
  test "redaction removes credentials and private fields before bounded output is receipted" do
36
    unsafe =
37
      ~s|clone https://x:forge-secret@forge/repo?code=oauth-code | <>
38
        ~s|authorization: Bearer smct_machine-secret {"content":"private prompt"}|
39
40
    redacted = LogSafety.redact(unsafe)
41
42
    refute redacted =~ "forge-secret"
43
    refute redacted =~ "oauth-code"
44
    refute redacted =~ "smct_machine-secret"
45
    refute redacted =~ "private prompt"
46
    assert redacted =~ "[REDACTED_CREDENTIAL]"
47
    assert redacted =~ "[FILTERED]"
48
  end
49
50
  test "logger calls do not interpolate raw exception messages or inspected failure payloads" do
51
    source =
52
      "lib/**/*.ex"
53
      |> Path.wildcard()
54
      |> Enum.map_join("\n", &File.read!/1)
55
56
    refute Regex.match?(
57
             ~r/Logger\.(?:debug|info|notice|warning|error)[\s\S]{0,200}(?:Exception\.message|inspect\((?:reason|error|build|output)\))/,
58
             source
59
           )
60
  end
61
end
test/openagents/machines_test.exs modified +64

@@ -50,6 +50,7 @@ defmodule OpenAgents.MachinesTest do

50 50
    assert {:ok, machine} = Machines.approve_pairing(owner, code)
51 51
    assert machine.user_id == owner.id
52 52
    assert machine.tier == "probe"
53
    assert DateTime.compare(machine.token_expires_at, DateTime.utc_now()) == :gt
53 54
54 55
    assert {:ok, %{token: "smct_" <> _rest = token, machine_id: machine_id}} =
55 56
             Machines.claim_pairing(pairing.id, poll_secret)

@@ -62,6 +63,34 @@ defmodule OpenAgents.MachinesTest do

62 63
    assert authenticated.id == machine.id
63 64
  end
64 65
66
  test "concurrent claims have exactly one winner" do
67
    %{pairing: pairing, code: code, poll_secret: poll_secret} = start_pairing()
68
    assert {:ok, _machine} = Machines.approve_pairing(user("concurrent-claim"), code)
69
    parent = self()
70
71
    tasks =
72
      for _attempt <- 1..2 do
73
        Task.async(fn ->
74
          send(parent, {:claim_ready, self()})
75
76
          receive do
77
            :claim -> Machines.claim_pairing(pairing.id, poll_secret)
78
          end
79
        end)
80
      end
81
82
    Enum.each(tasks, fn %{pid: pid} ->
83
      assert_receive {:claim_ready, ^pid}
84
      Ecto.Adapters.SQL.Sandbox.allow(Repo, self(), pid)
85
    end)
86
87
    Enum.each(tasks, &send(&1.pid, :claim))
88
    results = Enum.map(tasks, &Task.await/1)
89
90
    assert Enum.count(results, &match?({:ok, %{token: "smct_" <> _rest}}, &1)) == 1
91
    assert Enum.count(results, &(&1 == {:error, :pairing_consumed})) == 1
92
  end
93
65 94
  test "claim requires the correct poll secret" do
66 95
    %{pairing: pairing, code: code} = start_pairing()
67 96
    {:ok, _machine} = Machines.approve_pairing(user("wrong-secret"), code)

@@ -104,6 +133,41 @@ defmodule OpenAgents.MachinesTest do

104 133
    assert {:error, :machine_not_found} = Machines.authenticate_token("other_prefix")
105 134
  end
106 135
136
  test "expired machine tokens fail closed" do
137
    %{pairing: pairing, code: code, poll_secret: poll_secret} = start_pairing()
138
    owner = user("expired-token")
139
    {:ok, machine} = Machines.approve_pairing(owner, code)
140
    {:ok, %{token: token}} = Machines.claim_pairing(pairing.id, poll_secret)
141
142
    backdated = DateTime.add(DateTime.utc_now(), -31, :day)
143
144
    Repo.update_all(from(m in OpenAgents.Machines.Machine, where: m.id == ^machine.id),
145
      set: [inserted_at: backdated]
146
    )
147
148
    Repo.get!(OpenAgents.Machines.Machine, machine.id)
149
    |> Ecto.Changeset.change(token_expires_at: DateTime.add(DateTime.utc_now(), -1, :second))
150
    |> Repo.update!()
151
152
    assert {:error, :machine_expired} = Machines.authenticate_token(token)
153
    refute Machines.active_machine?(owner.id)
154
    assert Machines.approval_receipts(owner.id, "conversation:test") == []
155
  end
156
157
  test "an approved pairing cannot be claimed after its one-time window" do
158
    %{pairing: pairing, code: code, poll_secret: poll_secret} = start_pairing()
159
    owner = user("expired-approved-pairing")
160
    assert {:ok, machine} = Machines.approve_pairing(owner, code)
161
162
    pairing
163
    |> Ecto.Changeset.change(expires_at: DateTime.add(DateTime.utc_now(), -1, :second))
164
    |> Repo.update!()
165
166
    assert {:error, :pairing_expired} = Machines.claim_pairing(pairing.id, poll_secret)
167
    assert %{status: "expired", token_ciphertext: nil} = Repo.get!(Pairing, pairing.id)
168
    assert %{status: "revoked"} = Repo.get!(OpenAgents.Machines.Machine, machine.id)
169
  end
170
107 171
  test "machines are scoped to their owner" do
108 172
    %{code: code} = start_pairing()
109 173
    owner = user("owner-a")
test/openagents/operational_log_test.exs added +13

@@ -0,0 +1,13 @@

1
defmodule OpenAgents.OperationalLogTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.OperationalLog
5
6
  test "arbitrary failure details reduce to bounded codes" do
7
    assert OperationalLog.code({:provider_failed, "private prompt sentinel"}) ==
8
             "provider_failed"
9
10
    assert OperationalLog.code(%RuntimeError{message: "credential sentinel"}) == "runtime_error"
11
    assert OperationalLog.code("raw failure with a token") == "other"
12
  end
13
end
test/openagents/runtime_config_test.exs modified +54

@@ -103,6 +103,59 @@ defmodule OpenAgents.RuntimeConfigTest do

103 103
    refute error.message =~ sentinel
104 104
  end
105 105
106
  test "GitHub keyring metadata and prior keys fail closed without entering readiness" do
107
    sentinel = Base.encode64(:crypto.strong_rand_bytes(32))
108
109
    settings =
110
      staging_settings()
111
      |> Map.put(:github_token_decryption_keys, %{"staging-prior-key" => sentinel})
112
113
    encoded =
114
      settings |> RuntimeConfig.load!() |> RuntimeConfig.readiness_report() |> Jason.encode!()
115
116
    refute encoded =~ sentinel
117
    refute encoded =~ "staging-prior-key"
118
119
    assert {:error, %{setting: :github_token_encryption_key_id}} =
120
             settings
121
             |> Map.put(:github_token_encryption_key_id, "production-wrong-environment")
122
             |> RuntimeConfig.validate()
123
124
    assert {:error, %{setting: :github_token_decryption_keys}} =
125
             settings
126
             |> Map.put(:github_token_decryption_keys, %{"prior" => "not-base64"})
127
             |> RuntimeConfig.validate()
128
129
    assert {:error, %{setting: :github_token_decryption_keys}} =
130
             settings
131
             |> Map.put(:github_token_decryption_keys, %{"production-prior" => sentinel})
132
             |> RuntimeConfig.validate()
133
134
    assert {:error, %{setting: :github_token_decryption_keys}} =
135
             settings
136
             |> Map.put(:github_token_decryption_keys, %{"staging-2026-08" => sentinel})
137
             |> RuntimeConfig.validate()
138
  end
139
140
  test "forge mirror remotes refuse credential-bearing URLs" do
141
    for url <- [
142
          "https://operator:secret@mirror.example/openagents.com.git",
143
          "ssh://operator:secret@mirror.example/openagents.com.git",
144
          "operator:secret@mirror.example:openagents.com.git"
145
        ] do
146
      settings =
147
        staging_settings()
148
        |> Map.put(:forge_mirror_urls, %{"openagents.com" => url})
149
150
      assert {:error, %{setting: :forge_mirror_urls}} = RuntimeConfig.validate(settings)
151
    end
152
153
    assert {:ok, _config} =
154
             staging_settings()
155
             |> Map.put(:forge_mirror_urls, %{"openagents.com" => "/var/lib/openagents/mirror"})
156
             |> RuntimeConfig.validate()
157
  end
158
106 159
  test "startup refuses an empty tool catalog when tools are enabled" do
107 160
    config = RuntimeConfig.load!(staging_settings())
108 161

@@ -144,6 +197,7 @@ defmodule OpenAgents.RuntimeConfigTest do

144 197
      forge_public_visibility: %{"openagents.com" => :l3},
145 198
      forge_public_paths: %{"openagents.com" => []},
146 199
      forge_operator_token: nil,
200
      github_token_encryption_key_id: "staging-2026-08",
147 201
      dns_cluster_query: nil,
148 202
      distribution: [
149 203
        enabled: false,
test/openagents_web/auth_controller_test.exs modified +71 -2

@@ -42,6 +42,9 @@ defmodule OpenAgentsWeb.AuthControllerTest do

42 42
    assert is_binary(user.github_token_ciphertext)
43 43
    refute user.github_token_ciphertext =~ "ephemeral-github-token"
44 44
    assert {:ok, "ephemeral-github-token"} = Accounts.github_token(user)
45
    assert user.github_token_key_id == "test-2026-08"
46
    assert user.github_token_scopes == ["repo"]
47
    assert user.github_token_connected_at
45 48
46 49
    cookie = authenticated |> get_resp_header("set-cookie") |> Enum.join(";")
47 50
    refute cookie =~ "ephemeral-github-token"

@@ -79,6 +82,59 @@ defmodule OpenAgentsWeb.AuthControllerTest do

79 82
    assert get_session(callback, "user_id") == nil
80 83
  end
81 84
85
  test "GitHub tools require an explicit retained-token choice", %{conn: conn} do
86
    refused =
87
      conn
88
      |> init_test_session(%{})
89
      |> put_req_header("x-csrf-token", Plug.CSRFProtection.get_csrf_token())
90
      |> post(~p"/auth/github")
91
92
    assert redirected_to(refused) == ~p"/?auth_error=consent_required"
93
    assert get_session(refused, "github_oauth_attempt") == nil
94
  end
95
96
  test "disconnect revokes the GitHub grant and clears local token metadata", %{conn: conn} do
97
    user = github_user("disconnect-github-tools")
98
    assert {:ok, user} = Accounts.store_github_token(user, "ephemeral-github-token")
99
    expect_revoke()
100
101
    disconnected =
102
      conn
103
      |> init_test_session(%{"user_id" => user.id})
104
      |> delete(~p"/github/connection")
105
106
    assert redirected_to(disconnected) == ~p"/chat"
107
    retained_identity = Accounts.get_user(user.id)
108
    assert retained_identity.github_token_ciphertext == nil
109
    assert retained_identity.github_token_key_id == nil
110
    assert retained_identity.github_token_scopes == []
111
    assert retained_identity.github_token_connected_at == nil
112
    assert get_session(disconnected, "user_id") == user.id
113
  end
114
115
  test "a provider revocation failure preserves the retained grant for a safe retry", %{
116
    conn: conn
117
  } do
118
    user = github_user("disconnect-provider-failure")
119
    assert {:ok, user} = Accounts.store_github_token(user, "ephemeral-github-token")
120
121
    Req.Test.expect(__MODULE__, fn conn ->
122
      assert conn.method == "DELETE"
123
      Plug.Conn.send_resp(conn, 503, ~s|{"message":"provider failure"}|)
124
    end)
125
126
    refused =
127
      conn
128
      |> init_test_session(%{"user_id" => user.id})
129
      |> delete(~p"/github/connection")
130
131
    assert redirected_to(refused) == ~p"/chat"
132
    assert get_resp_header(refused, "cache-control") == ["no-store"]
133
    retained = Accounts.get_user(user.id)
134
    assert retained.github_token_ciphertext == user.github_token_ciphertext
135
    assert {:ok, "ephemeral-github-token"} = Accounts.github_token(retained)
136
  end
137
82 138
  test "banned GitHub identities cannot establish a Sarah session", %{conn: conn} do
83 139
    {:ok, user} = Accounts.upsert_github_user(profile(777, "before-ban"))
84 140
    {:ok, _banned} = Accounts.ban_user(user, "manual_review")

@@ -130,7 +186,7 @@ defmodule OpenAgentsWeb.AuthControllerTest do

130 186
    conn
131 187
    |> init_test_session(%{})
132 188
    |> put_req_header("x-csrf-token", csrf_token)
133
    |> post(~p"/auth/github")
189
    |> post(~p"/auth/github?github_tools=enabled")
134 190
  end
135 191
136 192
  defp attempt_and_state(conn) do

@@ -143,7 +199,10 @@ defmodule OpenAgentsWeb.AuthControllerTest do

143 199
144 200
  defp expect_github(github_id, login) do
145 201
    Req.Test.expect(__MODULE__, fn conn ->
146
      Req.Test.json(conn, %{"access_token" => "ephemeral-github-token"})
202
      Req.Test.json(conn, %{
203
        "access_token" => "ephemeral-github-token",
204
        "scope" => "repo"
205
      })
147 206
    end)
148 207
149 208
    Req.Test.expect(__MODULE__, fn conn ->

@@ -155,6 +214,16 @@ defmodule OpenAgentsWeb.AuthControllerTest do

155 214
    end)
156 215
  end
157 216
217
  defp expect_revoke do
218
    Req.Test.expect(__MODULE__, fn conn ->
219
      assert conn.method == "DELETE"
220
      assert conn.request_path == "/applications/test-github-client-id/token"
221
      assert ["Basic " <> _credential] = Plug.Conn.get_req_header(conn, "authorization")
222
      refute Req.Test.raw_body(conn) == ""
223
      Plug.Conn.send_resp(conn, 204, "")
224
    end)
225
  end
226
158 227
  defp profile(github_id, login) do
159 228
    %{
160 229
      github_id: github_id,
test/openagents_web/controllers/api_token_controller_test.exs added +116

@@ -0,0 +1,116 @@

1
defmodule OpenAgentsWeb.ApiTokenControllerTest do
2
  use OpenAgentsWeb.ConnCase, async: true
3
  import Ecto.Query
4
5
  alias OpenAgents.{ApiTokens, Repo}
6
7
  test "a browser-authenticated owner issues a token once and can revoke it", %{conn: conn} do
8
    user = github_user("api-token-controller-owner")
9
    browser = Plug.Test.init_test_session(conn, %{"user_id" => user.id})
10
11
    issued =
12
      post(browser, ~p"/api/tokens", %{
13
        name: "local CLI",
14
        scopes: ["forge:write"],
15
        lifetime_days: 30
16
      })
17
18
    assert get_resp_header(issued, "cache-control") == ["no-store"]
19
    assert %{"token" => plaintext, "credential" => %{"id" => id}} = json_response(issued, 201)
20
    assert String.starts_with?(plaintext, "oa_pat_")
21
    assert {:ok, ^user, _token} = ApiTokens.authenticate(plaintext, "forge:write")
22
23
    listed = issued |> recycle() |> get(~p"/api/tokens")
24
    assert [%{"id" => ^id}] = json_response(listed, 200)["tokens"]
25
    refute inspect(json_response(listed, 200)) =~ plaintext
26
27
    revoked = listed |> recycle() |> delete(~p"/api/tokens/#{id}")
28
    assert json_response(revoked, 200)["credential"]["revoked_at"]
29
    assert {:error, :invalid_api_token} = ApiTokens.authenticate(plaintext, "forge:write")
30
  end
31
32
  test "forge mutations refuse missing, malformed, expired, and revoked credentials", %{
33
    conn: conn
34
  } do
35
    path = ~p"/api/v3/repos/OpenAgents/openagents/issues"
36
37
    missing = conn |> delete_req_header("authorization") |> post(path, %{title: "denied"})
38
    assert json_response(missing, 401) == %{"error" => "invalid_api_token"}
39
40
    malformed =
41
      conn
42
      |> put_req_header("authorization", "Bearer not-a-token")
43
      |> post(path, %{title: "denied"})
44
45
    assert json_response(malformed, 401) == %{"error" => "invalid_api_token"}
46
47
    user = github_user("api-token-denial-owner")
48
49
    assert {:ok, token, plaintext} =
50
             ApiTokens.create(user, %{
51
               name: "denial cases",
52
               scopes: ["forge:write"],
53
               lifetime_days: 1
54
             })
55
56
    backdated = DateTime.add(DateTime.utc_now(), -2, :day)
57
58
    Repo.update_all(from(t in OpenAgents.ApiTokens.ApiToken, where: t.id == ^token.id),
59
      set: [inserted_at: backdated]
60
    )
61
62
    expired_token =
63
      Repo.get!(OpenAgents.ApiTokens.ApiToken, token.id)
64
      |> Ecto.Changeset.change(expires_at: DateTime.add(DateTime.utc_now(), -1, :second))
65
      |> Repo.update!()
66
67
    expired =
68
      conn
69
      |> put_req_header("authorization", "Bearer " <> plaintext)
70
      |> post(path, %{title: "denied"})
71
72
    assert json_response(expired, 401) == %{"error" => "invalid_api_token"}
73
74
    fresh_token =
75
      expired_token
76
      |> Ecto.Changeset.change(expires_at: DateTime.add(DateTime.utc_now(), 1, :day))
77
      |> Repo.update!()
78
79
    assert {:ok, _revoked} = ApiTokens.revoke(user, fresh_token.id)
80
81
    revoked =
82
      conn
83
      |> put_req_header("authorization", "Bearer " <> plaintext)
84
      |> post(path, %{title: "denied"})
85
86
    assert json_response(revoked, 401) == %{"error" => "invalid_api_token"}
87
  end
88
89
  test "browser credential mutations remain CSRF protected" do
90
    user = github_user("api-token-csrf-owner")
91
92
    browser =
93
      build_conn()
94
      |> init_test_session(%{"user_id" => user.id})
95
      |> get(~p"/settings/api-tokens")
96
97
    assert is_binary(get_session(browser, "_csrf_token"))
98
99
    assert_raise Plug.CSRFProtection.InvalidCSRFTokenError, fn ->
100
      browser
101
      |> recycle()
102
      |> enable_csrf_protection()
103
      |> put_req_header("accept", "application/json")
104
      |> put_req_header("x-csrf-token", "invalid")
105
      |> post(~p"/api/tokens", %{
106
        name: "must not exist",
107
        scopes: ["forge:write"],
108
        lifetime_days: 1
109
      })
110
    end
111
  end
112
113
  defp enable_csrf_protection(conn) do
114
    %{conn | private: Map.delete(conn.private, :plug_skip_csrf_protection)}
115
  end
116
end
test/openagents_web/controllers/comment_controller_test.exs modified +2

@@ -1,6 +1,8 @@

1 1
defmodule OpenAgentsWeb.CommentControllerTest do
2 2
  use OpenAgentsWeb.ConnCase
3 3
4
  setup %{conn: conn}, do: {:ok, conn: put_forge_api_token(conn, "comments")}
5
4 6
  alias OpenAgents.Issues
5 7
6 8
  setup do
test/openagents_web/controllers/data_controller_test.exs modified +27

@@ -19,6 +19,7 @@ defmodule OpenAgentsWeb.DataControllerTest do

19 19
  alias OpenAgents.Provenance.Canonical
20 20
21 21
  alias OpenAgents.{
22
    ApiTokens,
22 23
    Conversations,
23 24
    ExperienceMemory,
24 25
    GraphMemory,

@@ -37,6 +38,15 @@ defmodule OpenAgentsWeb.DataControllerTest do

37 38
  test "account owner can export conversation, memory, and voice disclosure", %{conn: conn} do
38 39
    token = "data-export-browser-credential-00000000000000000"
39 40
    user = github_user(token)
41
    assert {:ok, user} = OpenAgents.Accounts.store_github_token(user, "gho_export_sentinel")
42
43
    assert {:ok, api_credential, api_plaintext} =
44
             ApiTokens.create(user, %{
45
               name: "export metadata",
46
               scopes: ["forge:write"],
47
               lifetime_days: 7
48
             })
49
40 50
    {:ok, conversation} = Conversations.ensure_conversation(user)
41 51
    owner = Conversations.get_conversation_owner!(conversation)
42 52

@@ -72,6 +82,23 @@ defmodule OpenAgentsWeb.DataControllerTest do

72 82
    assert Enum.any?(export["messages"], &(&1["role"] == "assistant"))
73 83
    assert [%{"claim" => "Keep export tests bounded."}] = export["profile_memory"]["records"]
74 84
    assert export["voice_sessions"] == []
85
    assert [exported_api_credential] = export["api_credentials"]
86
    assert exported_api_credential["id"] == api_credential.id
87
    assert exported_api_credential["name"] == "export metadata"
88
    assert exported_api_credential["scopes"] == ["forge:write"]
89
    assert exported_api_credential["credential_exported"] == false
90
91
    assert export["github_connection"] == %{
92
             "connected" => true,
93
             "connected_at" => DateTime.to_iso8601(user.github_token_connected_at),
94
             "credential_exported" => false,
95
             "product_data_deletion" => "retained_until_explicit_disconnect",
96
             "rotated_at" => nil,
97
             "scopes" => ["repo"]
98
           }
99
100
    refute inspect(export) =~ "gho_export_sentinel"
101
    refute inspect(export) =~ api_plaintext
75 102
    refute inspect(export) =~ user.id
76 103
  end
77 104
test/openagents_web/controllers/issue_assignee_controller_test.exs modified +2

@@ -1,6 +1,8 @@

1 1
defmodule OpenAgentsWeb.IssueAssigneeControllerTest do
2 2
  use OpenAgentsWeb.ConnCase
3 3
4
  setup %{conn: conn}, do: {:ok, conn: put_forge_api_token(conn, "issue-assignees")}
5
4 6
  alias OpenAgents.Issues
5 7
6 8
  setup do
test/openagents_web/controllers/issue_controller_test.exs modified +2

@@ -1,6 +1,8 @@

1 1
defmodule OpenAgentsWeb.IssueControllerTest do
2 2
  use OpenAgentsWeb.ConnCase
3 3
4
  setup %{conn: conn}, do: {:ok, conn: put_forge_api_token(conn, "issues")}
5
4 6
  alias OpenAgents.Issues
5 7
6 8
  describe "index" do
test/openagents_web/controllers/issue_label_controller_test.exs modified +2

@@ -1,6 +1,8 @@

1 1
defmodule OpenAgentsWeb.IssueLabelControllerTest do
2 2
  use OpenAgentsWeb.ConnCase
3 3
4
  setup %{conn: conn}, do: {:ok, conn: put_forge_api_token(conn, "issue-labels")}
5
4 6
  import OpenAgents.LabelsFixtures
5 7
6 8
  alias OpenAgents.Issues
test/openagents_web/controllers/label_controller_test.exs modified +2

@@ -1,6 +1,8 @@

1 1
defmodule OpenAgentsWeb.LabelControllerTest do
2 2
  use OpenAgentsWeb.ConnCase
3 3
4
  setup %{conn: conn}, do: {:ok, conn: put_forge_api_token(conn, "labels")}
5
4 6
  import OpenAgents.LabelsFixtures
5 7
6 8
  alias OpenAgents.Labels
test/openagents_web/controllers/milestone_controller_test.exs modified +2

@@ -1,6 +1,8 @@

1 1
defmodule OpenAgentsWeb.MilestoneControllerTest do
2 2
  use OpenAgentsWeb.ConnCase
3 3
4
  setup %{conn: conn}, do: {:ok, conn: put_forge_api_token(conn, "milestones")}
5
4 6
  import OpenAgents.MilestonesFixtures
5 7
6 8
  alias OpenAgents.Milestones
test/openagents_web/controllers/project_controller_test.exs modified +2

@@ -1,6 +1,8 @@

1 1
defmodule OpenAgentsWeb.ProjectControllerTest do
2 2
  use OpenAgentsWeb.ConnCase
3 3
4
  setup %{conn: conn}, do: {:ok, conn: put_forge_api_token(conn, "projects")}
5
4 6
  import OpenAgents.ProjectFieldsFixtures
5 7
  import OpenAgents.ProjectItemsFixtures
6 8
  import OpenAgents.ProjectsFixtures
test/openagents_web/home_controller_test.exs modified +5 -2

@@ -8,8 +8,11 @@ defmodule OpenAgentsWeb.HomeControllerTest do

8 8
    # OpenAgents deliberately ships its own landing page ("The Agent Forge"),
9 9
    # not Sarah's. These assertions match the current product identity.
10 10
    assert html =~ "The Agent Forge"
11
    assert html =~ ~s(action="/auth/github")
12
    assert html =~ "Sign in with GitHub"
11
    assert html =~ ~s(action="/auth/github?github_tools=enabled")
12
    assert html =~ "Sign in and enable GitHub tools"
13
    assert html =~ ~s(id="github-tools-disclosure")
14
    assert html =~ "retain an encrypted GitHub grant"
15
    assert html =~ "read/write"
13 16
14 17
    refute html =~ "One continuing conversation"
15 18
    refute html =~ ~s(href="/chat")
test/openagents_web/live/api_tokens_live_test.exs added +35

@@ -0,0 +1,35 @@

1
defmodule OpenAgentsWeb.ApiTokensLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: true
3
4
  import Phoenix.LiveViewTest
5
6
  alias OpenAgents.{ApiTokens, Repo}
7
8
  test "owner creates a one-time credential and revokes it", %{conn: conn} do
9
    user = github_user("api-token-live")
10
    conn = Plug.Test.init_test_session(conn, %{"user_id" => user.id})
11
    {:ok, view, _html} = live(conn, ~p"/settings/api-tokens")
12
13
    assert has_element?(view, "#api-token-form")
14
    assert has_element?(view, "#api-tokens-empty")
15
16
    view
17
    |> form("#api-token-form", %{
18
      "api_token" => %{"name" => "Local release", "lifetime_days" => "7"}
19
    })
20
    |> render_submit()
21
22
    assert has_element?(view, "#issued-api-token")
23
    assert [token] = ApiTokens.list(user)
24
    assert has_element?(view, "#revoke-api-token-#{token.id}")
25
26
    view |> element("#revoke-api-token-#{token.id}") |> render_click()
27
28
    refute has_element?(view, "#revoke-api-token-#{token.id}")
29
    assert Repo.reload!(token).revoked_at
30
  end
31
32
  test "anonymous browser is redirected without revealing the settings surface", %{conn: conn} do
33
    assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/settings/api-tokens")
34
  end
35
end
test/openagents_web/route_authority_test.exs added +81

@@ -0,0 +1,81 @@

1
defmodule OpenAgentsWeb.RouteAuthorityTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgentsWeb.RouteAuthority
5
6
  test "every HTTP route and endpoint socket declares one authority, principal, and scope" do
7
    inventory = RouteAuthority.inventory()
8
9
    assert inventory != []
10
11
    Enum.each(inventory, fn entry ->
12
      assert entry.class in RouteAuthority.classes(), inspect(entry)
13
      assert is_binary(entry.principal) and entry.principal != "", inspect(entry)
14
      assert is_binary(entry.scope) and entry.scope != "", inspect(entry)
15
16
      if entry.verb in ["post", "put", "patch", "delete", "connect", "*"] do
17
        assert entry.mutation, inspect(entry)
18
      end
19
20
      if entry.mutation do
21
        refute entry.class == :public_read, inspect(entry)
22
      end
23
    end)
24
  end
25
26
  test "public forge reads and bearer-authenticated forge writes are separate" do
27
    read = route!(:get, "/api/v3/repos/:owner/:repo/issues")
28
    write = route!(:post, "/api/v3/repos/:owner/:repo/issues")
29
30
    assert read.class == :public_read
31
    assert read.mutation == false
32
    assert write.class == :authenticated_api
33
    assert write.principal == "first-party bearer token"
34
    assert write.scope == "forge:write"
35
    assert write.mutation
36
37
    assert Phoenix.Router.route_info(
38
             OpenAgentsWeb.Router,
39
             "GET",
40
             "/api/v3/repos/OpenAgentsInc/openagents.com/issues",
41
             "stage.openagents.com"
42
           ).pipe_through == [:api]
43
44
    assert Phoenix.Router.route_info(
45
             OpenAgentsWeb.Router,
46
             "POST",
47
             "/api/v3/repos/OpenAgentsInc/openagents.com/issues",
48
             "stage.openagents.com"
49
           ).pipe_through == [:forge_write_api]
50
  end
51
52
  test "operator and machine surfaces cannot drift into browser or public classes" do
53
    assert route!(:get, "/admin").class == :operator
54
    assert route!(:get, "/admin/forge").scope == "forge:promote"
55
    assert route!(:post, "/controller/pairings").class == :machine
56
    assert route!(:get, "/controller/pairings/:id").scope == "machine:pairing:claim"
57
    assert route!(:post, "/api/inference/proxy").class == :internal_service
58
  end
59
60
  test "the OAuth callback suppresses router parameter logging at the application boundary" do
61
    callback =
62
      Enum.find(OpenAgentsWeb.Router.__routes__(), &(&1.path == "/auth/github/callback"))
63
64
    assert callback.metadata.log == false
65
66
    assert Phoenix.Logger.filter_values(%{
67
             "code" => "oauth-code-sentinel",
68
             "state" => "oauth-state-sentinel",
69
             "safe" => "visible"
70
           }) == %{
71
             "code" => "[FILTERED]",
72
             "state" => "[FILTERED]",
73
             "safe" => "visible"
74
           }
75
  end
76
77
  defp route!(verb, path) do
78
    Enum.find(RouteAuthority.inventory(), &(&1.verb == to_string(verb) and &1.path == path)) ||
79
      flunk("missing route #{verb} #{path}")
80
  end
81
end
test/support/conn_case.ex modified +13

@@ -56,6 +56,19 @@ defmodule OpenAgentsWeb.ConnCase do

56 56
    Plug.Test.init_test_session(conn, %{"user_id" => user.id})
57 57
  end
58 58
59
  def put_forge_api_token(conn, key) when is_binary(key) do
60
    user = github_user("api-token-" <> key)
61
62
    {:ok, _credential, plaintext} =
63
      OpenAgents.ApiTokens.create(user, %{
64
        name: "test forge client",
65
        scopes: ["forge:write"],
66
        lifetime_days: 1
67
      })
68
69
    Plug.Conn.put_req_header(conn, "authorization", "Bearer " <> plaintext)
70
  end
71
59 72
  @doc """
60 73
  Logs in an account and grants it operator access for the duration of the test.
61 74
  """

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