Tighten the sidebar's vertical rhythm to match its horizontal padding

3deddf346c60 · AtlantisPleb · · parent cf15a330b73a

Tighten the sidebar's vertical rhythm to match its horizontal padding

A collapsed section cost 36px of label, 6px of section padding and 2px of nav
gap: a 44px pitch around a 20px line, so 24px of vertical air against the 8px
of horizontal padding inside the same row.

Two causes. The section heading was 36px while the row beside it was 32px, so
the heading carried 8px of air per side and the row 6px -- a near-match rather
than a match, and near-matches are what make a column look airier down than
across. It is now the same 32px as `.sidebar-row`.

The trailing 6px belonged to separating an open section's items from the next
section, but it was charged whether or not the section was open, so a rail of
collapsed sections carried a separator between rows that were already just
rows. It now applies only to `[open]`.

Collapsed pitch drops from 44px to 34px: 7px of space per side against 8px of
horizontal padding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149rBWy7br1Z7bbz9NrQhEr
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified Dockerfile
  • modified assets/css/app.css
  • modified config/config.exs
  • modified config/runtime.exs
  • modified config/test.exs
  • modified docs/runtime-configuration.md
  • modified docs/scv-codex-app-server-planning.md
  • modified docs/security/secrets-and-log-handling.md
  • modified lib/openagents/runtime_supervisor.ex
  • added lib/openagents/scv/codex_accounts.ex
  • added lib/openagents/scv/codex_app_server.ex
  • added lib/openagents/scv/codex_credential_store.ex
  • added lib/openagents/scv/codex_credential_store/file.ex
  • added lib/openagents/scv/codex_credential_store/gcp_secret_manager.ex
  • added lib/openagents/scv/codex_login.ex
  • added lib/openagents/scv/codex_login_supervisor.ex
  • added lib/openagents/scv/driver_account.ex
  • added lib/openagents/scv/driver_login_attempt.ex
  • modified lib/openagents_web/components/ui.ex
  • added lib/openagents_web/live/admin_scv_accounts_live.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • modified priv/migration_lineages/prior-2026-08-19.json
  • added priv/repo/migrations/20260820161342_create_scv_codex_driver_accounts.exs
  • added test/openagents/scv/codex_accounts_test.exs
  • added test/openagents/scv/codex_app_server_test.exs
  • added test/openagents/scv/codex_gcp_secret_manager_test.exs
  • added test/openagents_web/live/admin_scv_accounts_live_test.exs
  • modified test/openagents_web/route_authority_test.exs
  • added test/support/fake_codex_app_server.sh

Diff

30 files changed, +2144 -31

Dockerfile modified +19 -1

@@ -16,6 +16,7 @@ ARG TAILWIND_VERSION=4.3.0

16 16
ARG TAILWIND_SHA256=73f0e5459054e5cfaa8ab6f3b940f3fbe0f13cc7fd83bc24e7c655033c203400
17 17
ARG ESBUILD_VERSION=0.25.4
18 18
ARG ESBUILD_SHA256=93433b456cac3a454ee27403d3de9adce88d83e5439ba37e1471af54730c9ca7
19
ARG CODEX_VERSION=0.147.0
19 20
20 21
ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}@sha256:ae38be7cb19bffa78adedb04732d9e6ba83a507b4cfb06983cbe711edb49da54"
21 22
ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}@sha256:3a39a0592364683e6bab97937b72cad5a8fa6dcbbee90edb3bb48c7f8e94f258"

@@ -109,6 +110,8 @@ CMD ["mix", "run", "--no-compile", "--no-start", "ops/forge/build-worker.exs"]

109 110
# the compiled release and other runtime necessities
110 111
FROM ${RUNNER_IMAGE} AS final
111 112
113
ARG TARGETARCH
114
ARG CODEX_VERSION
112 115
ARG DEBIAN_SNAPSHOT
113 116
ARG SOURCE_DATE_EPOCH=0
114 117
ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}

@@ -121,9 +124,24 @@ RUN sed -i \

121 124
      /etc/apt/sources.list.d/debian.sources \
122 125
  && printf 'Acquire::Check-Valid-Until "false";\n' > /etc/apt/apt.conf.d/99snapshot \
123 126
  && apt-get update \
124
  && apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates git \
127
  && apt-get install -y --no-install-recommends libstdc++6 openssl libncurses6 locales ca-certificates curl git \
125 128
  && rm -rf /var/lib/apt/lists/*
126 129
130
RUN set -eu; \
131
  case "${TARGETARCH}" in \
132
    amd64) codex_arch=x86_64; checksum=0246e2e773834e07f0fb5249ed6ebad12e4591e608f8c7bb97dd6a9690544c36 ;; \
133
    arm64) codex_arch=aarch64; checksum=eb677c80f666b1ab8b4b1d083b66e8d614b1281d960bb6f9fd8ca98f58b38b90 ;; \
134
    *) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
135
  esac; \
136
  archive="codex-${codex_arch}-unknown-linux-musl.tar.gz"; \
137
  curl -fsSL --retry 3 -o "/tmp/${archive}" \
138
    "https://github.com/openai/codex/releases/download/rust-v${CODEX_VERSION}/${archive}"; \
139
  echo "${checksum}  /tmp/${archive}" | sha256sum --check --strict; \
140
  tar -xzf "/tmp/${archive}" -C /tmp; \
141
  install -D -m 0755 "/tmp/codex-${codex_arch}-unknown-linux-musl" /usr/local/bin/codex; \
142
  rm "/tmp/${archive}" "/tmp/codex-${codex_arch}-unknown-linux-musl"; \
143
  codex --version
144
127 145
# Set the locale
128 146
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen \
129 147
  && locale-gen
assets/css/app.css modified +13 -4

@@ -370,6 +370,12 @@

370 370
371 371
.sidebar-section {
372 372
  position: relative;
373
}
374
375
/* Only an open section needs holding off the next one. Paying that gap while
376
   collapsed made a rail of collapsed sections carry a separator between rows
377
   that were already just rows, which is most of why it read loose. */
378
.sidebar-section[open] {
373 379
  padding-block-end: 6px;
374 380
}
375 381

@@ -379,10 +385,13 @@

379 385
  top: 0;
380 386
  display: flex;
381 387
  align-items: center;
382
  /* A section heading is a row you can click, so it is sized like one: same
383
     height, same type, sentence case. Uppercasing it with tracking made it
384
     shout a word the reader already scanned past. */
385
  min-height: 36px;
388
  /* A section heading is a row you can click, so it is sized like one: the
389
     SAME height as `.sidebar-row`, not a taller near-match. At 36px against a
390
     20px line it carried 8px of air per side while the row beside it carried
391
     6px, and the mismatch is what made the column look airier vertically than
392
     it is horizontally. Uppercasing it with tracking made it shout a word the
393
     reader already scanned past. */
394
  min-height: 32px;
386 395
  margin-inline: 12px;
387 396
  padding-inline: 8px;
388 397
  /* Sticky, so it needs an opaque fill for rows to scroll under -- but it must
config/config.exs modified +9

@@ -50,6 +50,15 @@ config :openagents,

50 50
  coding_jobs_dir: "/var/lib/openagents/coding-jobs",
51 51
  work_workers_enabled: false,
52 52
  work: [enabled: false],
53
  scv_codex: [
54
    enabled: false,
55
    executable: "/usr/local/bin/codex",
56
    credential_store: OpenAgents.SCV.CodexCredentialStore.File,
57
    credential_refs: ["file:operator-1"],
58
    file_root: "/var/lib/openagents/scv/codex-accounts",
59
    temporary_root: System.tmp_dir!(),
60
    client_options: []
61
  ],
53 62
  tools_enabled: true,
54 63
  voice: [
55 64
    enabled: false,
config/runtime.exs modified +41

@@ -159,6 +159,46 @@ if config_env() == :prod and runtime_role == :web do

159 159
  boot_convergence_enabled = feature.("BOOT_CONVERGENCE")
160 160
  ra_enabled = feature.("RA")
161 161
  horde_enabled = feature.("HORDE")
162
  scv_codex_enabled = parse_optional_boolean.("OPENAGENTS_FEATURE_SCV_CODEX")
163
164
  scv_codex_credential_store =
165
    case optional_text.("OPENAGENTS_SCV_CODEX_CREDENTIAL_STORE") do
166
      nil ->
167
        OpenAgents.SCV.CodexCredentialStore.File
168
169
      "file" ->
170
        OpenAgents.SCV.CodexCredentialStore.File
171
172
      "gcp_secret_manager" ->
173
        OpenAgents.SCV.CodexCredentialStore.GcpSecretManager
174
175
      _invalid ->
176
        raise "environment variable OPENAGENTS_SCV_CODEX_CREDENTIAL_STORE is not admitted"
177
    end
178
179
  scv_codex_credential_refs =
180
    case optional_text.("OPENAGENTS_SCV_CODEX_CREDENTIAL_REFS") do
181
      nil -> []
182
      _configured -> parse_csv.("OPENAGENTS_SCV_CODEX_CREDENTIAL_REFS")
183
    end
184
185
  if scv_codex_enabled and scv_codex_credential_refs == [] do
186
    raise "environment variable OPENAGENTS_SCV_CODEX_CREDENTIAL_REFS is required when Codex SCV accounts are enabled"
187
  end
188
189
  scv_codex = [
190
    enabled: scv_codex_enabled,
191
    executable:
192
      optional_text.("OPENAGENTS_SCV_CODEX_BIN") ||
193
        Application.fetch_env!(:openagents, :scv_codex)[:executable],
194
    credential_store: scv_codex_credential_store,
195
    credential_refs: scv_codex_credential_refs,
196
    file_root:
197
      optional_text.("OPENAGENTS_SCV_CODEX_FILE_ROOT") ||
198
        Application.fetch_env!(:openagents, :scv_codex)[:file_root],
199
    temporary_root: System.tmp_dir!(),
200
    client_options: []
201
  ]
162 202
163 203
  voice =
164 204
    :openagents

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

286 326
    voice_retention_enabled: voice_retention_enabled,
287 327
    work: work,
288 328
    work_workers_enabled: work_enabled,
329
    scv_codex: scv_codex,
289 330
    semantic_index: semantic_index,
290 331
    experience_memory: experience_memory,
291 332
    graph_memory: graph_memory,
config/test.exs modified +9

@@ -77,6 +77,15 @@ config :openagents, :ra_enabled, false

77 77
78 78
config :openagents, :computer_controller_enabled, true
79 79
80
config :openagents, :scv_codex,
81
  enabled: true,
82
  executable: Path.expand("../test/support/fake_codex_app_server.sh", __DIR__),
83
  credential_store: OpenAgents.SCV.CodexCredentialStore.File,
84
  credential_refs: ["file:test-operator-1", "file:test-operator-2"],
85
  file_root: Path.join(System.tmp_dir!(), "openagents-codex-test-credentials"),
86
  temporary_root: System.tmp_dir!(),
87
  client_options: []
88
80 89
config :openagents, :voice_recording_encryption_key, Base.encode64(:crypto.strong_rand_bytes(32))
81 90
82 91
config :openagents, :voice_recording,
docs/runtime-configuration.md modified +24

@@ -76,6 +76,30 @@ general cloud credentials into this role. A provider key is a temporary

76 76
qualification mechanism; replace it with a run-scoped inference grant before
77 77
admitting repository writes.
78 78
79
## Operator Codex account settings
80
81
The web role can enable a restricted operator surface for connecting individual
82
Codex accounts. This surface starts a temporary Codex app-server only for the
83
device ceremony, verifies the admitted model and reasoning efforts, and writes
84
the resulting managed credential to a configured credential slot. It never
85
stores the credential value in PostgreSQL.
86
87
| Environment setting | Requirement |
88
| --- | --- |
89
| `OPENAGENTS_FEATURE_SCV_CODEX` | `true` to enable the operator connection surface; otherwise `false` or empty |
90
| `OPENAGENTS_SCV_CODEX_CREDENTIAL_STORE` | `gcp_secret_manager` in staging; `file` is allowed only for local development and tests |
91
| `OPENAGENTS_SCV_CODEX_CREDENTIAL_REFS` | Comma-separated, preallocated credential-slot references; required when the feature is enabled |
92
| `OPENAGENTS_SCV_CODEX_BIN` | Absolute path to the pinned Codex executable; the release image uses `/usr/local/bin/codex` |
93
| `OPENAGENTS_SCV_CODEX_FILE_ROOT` | Local credential directory used only with the `file` store |
94
95
Each successful connection creates an immutable secret version and records only
96
its reference and numeric version. Grant the web identity version-add and
97
exact-version-read access only to the configured slots. Do not grant the web
98
identity access to unrelated SCV, Forge, or deployment credentials.
99
100
Implement ChatGPT service accounts after this individual operator flow passes
101
qualification. Service accounts are available only for pay-as-you-go plans.
102
79 103
## Required release settings
80 104
81 105
All settings in this section are mandatory in a production release unless
docs/scv-codex-app-server-planning.md modified +44 -24

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

2 2
3 3
Date: 2026-08-20
4 4
5
Status: design only; no implementation or deployment changes
5
Status: operator account connection first; staging implementation in progress
6 6
7 7
## Outcome
8 8

@@ -19,12 +19,13 @@ turn, approval, rate-limit, and live event operations that an SCV needs.

19 19
20 20
Support these credential paths:
21 21
22
- Prefer a ChatGPT service-account access token for production SCVs when the
23
  workspace and plan support one.
24
- Support ChatGPT device-code login for an operator-linked pilot and for
25
  workspaces that explicitly allow device login.
22
- Implement ChatGPT device-code login first so an authenticated OpenAgents
23
  operator can connect an individual Codex account.
24
- Add ChatGPT service-account access tokens second, and only for a workspace
25
  on a pay-as-you-go plan. OpenAI does not make service accounts available on
26
  other plans.
26 27
- Support a personal Codex access token when an operator needs ChatGPT
27
  workspace attribution but a non-human service account is unavailable.
28
  workspace attribution and device login is unavailable.
28 29
- Keep API-key authentication available for usage-based automation that does
29 30
  not need ChatGPT workspace entitlements.
30 31
- Do not use experimental `chatgptAuthTokens` in the first version.

@@ -168,9 +169,9 @@ deployment, SCV-policy, account-management, or credential-management tools.

168 169
169 170
| Credential | Appropriate use | Persistence | Initial status |
170 171
| --- | --- | --- | --- |
171
| ChatGPT service-account access token | Production Business or Enterprise automation that needs a non-human ChatGPT workspace identity, governance, and attribution | Store the token in the platform secret manager and rotate it. Do not persist a login in the worker. | Preferred production path when available. |
172
| Managed ChatGPT device login | An authenticated operator connects an individual ChatGPT account and lets Codex own refresh and persistence | Preserve the account's updated `auth.json` across restarts under a single-writer lease. | Implement first. |
173
| ChatGPT service-account access token | Headless workspace automation that needs a non-human ChatGPT identity, governance, and attribution | Store the token in the platform secret manager and rotate it. Do not persist a login in the worker. | Implement second. Available only on pay-as-you-go plans. |
172 174
| Personal Codex access token | Trusted automation attributed to one workspace member | Store and rotate it like any other automation secret. | Allowed for a bounded pilot; prefer a service account for shared production work. |
173
| Managed ChatGPT device login | An authenticated operator explicitly connects a ChatGPT account and lets Codex own refresh and persistence | Preserve the account's updated `auth.json` across restarts under a single-writer lease. | Preferred operator-linked pilot path. |
174 175
| Platform API key | Usage-based Codex work that does not need ChatGPT plan limits or workspace identity | Use a scoped secret or existing inference grant. | Supported fallback. |
175 176
| Browser callback login | Interactive local clients where the browser can return to a localhost callback | Requires the app-server callback listener. | Do not use for the hosted admin interface; the device flow is less brittle. |
176 177
| Experimental external ChatGPT tokens | A host that already owns the complete ChatGPT token lifecycle | Host-managed access-token refresh. | Refused for the first implementation. |

@@ -182,11 +183,20 @@ automation. [Service accounts](https://learn.chatgpt.com/docs/enterprise/service

182 183
provide non-human workspace identities on eligible pay-as-you-go plans and
183 184
require Codex CLI `0.142.0` or later.
184 185
185
If a Platform API key meets the requirement, prefer it over connecting a human
186
ChatGPT account. Use a ChatGPT credential only when the SCV needs ChatGPT
187
workspace attribution, entitlements, limits, or governance.
186
The first product path deliberately connects an individual operator account.
187
This order proves the account ceremony, app-server lifecycle, credential-home
188
persistence, rate-limit visibility, and account isolation before OpenAgents
189
adds non-human credentials. A Platform API key remains the right path for
190
usage-based work that does not need ChatGPT workspace attribution,
191
entitlements, limits, or governance.
188 192
189
### Production preference
193
### Second implementation: pay-as-you-go service accounts
194
195
Do not implement service accounts as an alternative first-login button. Add
196
them only after the individual operator flow passes staging qualification.
197
OpenAI states that service accounts are available only on pay-as-you-go plans.
198
OpenAgents must fail closed when the selected workspace does not meet that
199
requirement.
190 200
191 201
Use one ChatGPT service account per distinct SCV authority domain, not one
192 202
service account per short run and not one employee credential for the entire

@@ -649,10 +659,10 @@ incompatible account.

649 659
650 660
## Implementation phases
651 661
652
### Phase 0: Support and policy confirmation
662
### Phase 0: Runtime and policy confirmation
653 663
654
- Confirm the eligible ChatGPT plan, service-account availability, token
655
  expiration policy, and Codex Local permissions.
664
- Confirm device login is enabled for the individual operator account and
665
  record the admitted Codex runtime and protocol schema.
656 666
- Contact OpenAI about the `openagents_scv` client identifier and the supported
657 667
  app-server or SDK path.
658 668
- Decide which repositories may use ChatGPT credentials instead of Platform

@@ -669,16 +679,26 @@ incompatible account.

669 679
  `openagents.scv.report.v1` terminal result.
670 680
- Compare behavior with the stable Python SDK and capture protocol fixtures.
671 681
672
### Phase 2: Operator account connection
682
### Phase 2: Individual operator account connection
673 683
674 684
- Add restricted account and login-attempt records.
675 685
- Implement the device-code ceremony with one temporary process per attempt.
676 686
- Add account read, model, rate-limit, health, drain, disconnect, and audit
677 687
  operations.
678
- Add service-account and personal access-token secret references without
679
  showing saved token values after entry.
680 688
681
### Phase 3: Account runtime scheduler
689
### Phase 3: Pay-as-you-go service accounts
690
691
- Confirm the selected ChatGPT workspace uses a pay-as-you-go plan before
692
  presenting or accepting a service-account credential.
693
- Add service-account access-token secret references without showing saved
694
  token values after entry.
695
- Prove rotation by draining the old account runtime, starting a new runtime
696
  generation, and revoking the old token after the replacement passes a
697
  bounded smoke test.
698
- Keep personal access tokens as a separate operator-attributed fallback, not
699
  as a service-account substitute.
700
701
### Phase 4: Account runtime scheduler
682 702
683 703
- Add one runtime generation and one capacity lease per account.
684 704
- Bind every SCV execution to one account, credential revision, Codex version,

@@ -687,14 +707,14 @@ incompatible account.

687 707
  state.
688 708
- Add live public SCV projection and restricted driver diagnostics.
689 709
690
### Phase 4: Propose-only SCV qualification
710
### Phase 5: Propose-only SCV qualification
691 711
692 712
- Run bounded read-only investigation and candidate proposal tasks.
693 713
- Prove cancellation, report durability, event continuity, exact-SHA binding,
694 714
  resource collection, and restart behavior.
695 715
- Keep repository writes, pushes, Forge promotion, and deployment disabled.
696 716
697
### Phase 5: Credential-free effect execution
717
### Phase 6: Credential-free effect execution
698 718
699 719
- Separate app-server credentials from candidate command and file effects.
700 720
- Add a durable pre-effect receipt and idempotency boundary.

@@ -702,7 +722,7 @@ incompatible account.

702 722
  filesystem, process metadata, sockets, logs, or artifacts.
703 723
- Run adversarial repository instructions and build scripts with network denied.
704 724
705
### Phase 6: Bounded write admission
725
### Phase 7: Bounded write admission
706 726
707 727
- Enable only a repository-scoped propose branch and admitted path and command
708 728
  policy.

@@ -741,8 +761,8 @@ Do not call the Codex-backed driver ready until it proves all of these items:

741 761
742 762
- Will OpenAI support `openagents_scv` as a direct app-server client, or should
743 763
  production use the stable Python SDK bridge?
744
- Which ChatGPT workspace and pay-as-you-go plan will own the production SCV
745
  service account?
764
- After the individual operator path passes qualification, which ChatGPT
765
  workspace and pay-as-you-go plan will own the first SCV service account?
746 766
- Should staging and production use separate service accounts, separate
747 767
  workspaces, or both?
748 768
- Which encrypted persistent store will hold managed device-login homes with a
docs/security/secrets-and-log-handling.md modified +5

@@ -39,6 +39,8 @@ use distinct names and values and remains locked.

39 39
| `VOICE_RECORDING_ENCRYPTION_KEY` | `openagents-staging-voice-recording-key` | web and fleet when recording is admitted | Scheduled recording-key procedure or suspected exposure |
40 40
| `OPENAGENTS_FORGE_OPERATOR_TOKEN` | `openagents-staging-forge-operator-token` | web, fleet, builder | Scheduled rotation, builder replacement, or suspected URL/argv/log exposure |
41 41
| `RELEASE_COOKIE` | `openagents-staging-release-cookie` | web, fleet, deployer | Fleet-wide coordinated rotation or suspected exposure |
42
| Connected Codex account slot 1 | `openagents-staging-scv-codex-operator-1` | web may add and read versions; a future isolated Codex SCV runtime may read one exact version | Operator disconnect, OpenAI reauthentication, or suspected exposure |
43
| Connected Codex account slot 2 | `openagents-staging-scv-codex-operator-2` | web may add and read versions; a future isolated Codex SCV runtime may read one exact version | Operator disconnect, OpenAI reauthentication, or suspected exposure |
42 44
43 45
`GITHUB_CLIENT_ID` and `GITHUB_TOKEN_ENCRYPTION_KEY_ID` are identifiers, not
44 46
secrets. `DB_PASSWORD` is not used by the admitted staging profile because it

@@ -46,6 +48,9 @@ uses `DATABASE_URL`; if socket mode is admitted later, give it its own named

46 48
secret and update this table first. First-party API tokens, machine tokens,
47 49
pairing secrets, inference grants, browser cookies, and OAuth codes are minted
48 50
credentials, never deployment configuration and never Secret Manager values.
51
The two Codex slots are preallocated containers for credentials that an
52
operator creates through OpenAI's device flow; they are not deployment inputs.
53
The database stores only the slot resource and immutable version number.
49 54
50 55
## Handling rules
51 56
lib/openagents/runtime_supervisor.ex modified +2

@@ -33,6 +33,8 @@ defmodule OpenAgents.RuntimeSupervisor do

33 33
        {Registry, keys: :unique, name: OpenAgents.VoiceSessionRegistry},
34 34
        {DynamicSupervisor, strategy: :one_for_one, name: OpenAgents.VoiceSessionSupervisor},
35 35
        OpenAgents.SCV.Activity,
36
        {Registry, keys: :unique, name: OpenAgents.SCV.CodexLoginRegistry},
37
        OpenAgents.SCV.CodexLoginSupervisor,
36 38
        OpenAgents.Leaderboard.Server,
37 39
        {Task.Supervisor, name: OpenAgents.ProviderTaskSupervisor},
38 40
        {Task.Supervisor, name: OpenAgents.ToolTaskSupervisor},
lib/openagents/scv/codex_accounts.ex added +256

@@ -0,0 +1,256 @@

1
defmodule OpenAgents.SCV.CodexAccounts do
2
  @moduledoc "Durable authority for operator-connected Codex accounts used by SCVs."
3
4
  import Ecto.Query
5
6
  require Logger
7
8
  alias OpenAgents.Accounts
9
  alias OpenAgents.Accounts.User
10
  alias OpenAgents.Repo
11
  alias OpenAgents.SCV.CodexLoginSupervisor
12
  alias OpenAgents.SCV.DriverAccount
13
  alias OpenAgents.SCV.DriverLoginAttempt
14
15
  @topic "scv_codex_accounts:operator"
16
  @login_lifetime_seconds 15 * 60
17
18
  @spec enabled?() :: boolean()
19
  def enabled?, do: Keyword.get(config(), :enabled, false)
20
21
  @spec subscribe() :: :ok | {:error, term()}
22
  def subscribe, do: Phoenix.PubSub.subscribe(OpenAgents.PubSub, @topic)
23
24
  @spec list_accounts() :: [DriverAccount.t()]
25
  def list_accounts do
26
    Repo.all(from(account in DriverAccount, order_by: [desc: account.inserted_at]))
27
  end
28
29
  @spec start_device_login(User.t(), map()) ::
30
          {:ok, DriverAccount.t(), DriverLoginAttempt.t(), map()} | {:error, atom()}
31
  def start_device_login(%User{} = operator, attributes) when is_map(attributes) do
32
    with true <- Accounts.admin?(operator) or {:error, :not_authorized},
33
         true <- enabled?() or {:error, :codex_not_enabled},
34
         {:ok, account, attempt} <- create_pending(operator, attributes),
35
         {:ok, ceremony} <- CodexLoginSupervisor.start_login(account, attempt) do
36
      {:ok, account, attempt, ceremony}
37
    else
38
      {:error, _reason} = error ->
39
        error
40
41
      false ->
42
        {:error, :not_authorized}
43
    end
44
  end
45
46
  def start_device_login(%User{}, _attributes), do: {:error, :attributes_invalid}
47
  def start_device_login(_operator, _attributes), do: {:error, :not_authorized}
48
49
  @spec cancel_device_login(User.t(), Ecto.UUID.t()) :: :ok | {:error, atom()}
50
  def cancel_device_login(%User{} = operator, attempt_id) when is_binary(attempt_id) do
51
    with true <- Accounts.admin?(operator) or {:error, :not_authorized},
52
         %DriverLoginAttempt{operator_id: operator_id} = attempt <-
53
           Repo.get(DriverLoginAttempt, attempt_id),
54
         true <- operator_id == operator.id or {:error, :not_authorized} do
55
      CodexLoginSupervisor.cancel(attempt)
56
    else
57
      nil -> {:error, :login_not_found}
58
      {:error, reason} -> {:error, reason}
59
      false -> {:error, :not_authorized}
60
    end
61
  end
62
63
  def cancel_device_login(%User{}, _attempt_id), do: {:error, :login_not_found}
64
  def cancel_device_login(_operator, _attempt_id), do: {:error, :not_authorized}
65
66
  @doc false
67
  def mark_waiting(%DriverLoginAttempt{} = attempt, login_id, verification_url, user_code) do
68
    result =
69
      attempt
70
      |> DriverLoginAttempt.waiting_changeset(%{
71
        login_id: login_id,
72
        verification_url: verification_url,
73
        user_code_digest: :crypto.hash(:sha256, user_code)
74
      })
75
      |> Repo.update()
76
77
    case result do
78
      {:ok, waiting} ->
79
        emit("device_login_waiting", waiting.account_id, waiting.id)
80
        {:ok, waiting}
81
82
      error ->
83
        error
84
    end
85
  end
86
87
  @doc false
88
  def mark_ready(%DriverAccount{} = account, %DriverLoginAttempt{} = attempt, attributes) do
89
    Repo.transaction(fn ->
90
      ready =
91
        account
92
        |> DriverAccount.ready_changeset(attributes)
93
        |> Repo.update!()
94
95
      _completed =
96
        attempt
97
        |> DriverLoginAttempt.terminal_changeset("succeeded")
98
        |> Repo.update!()
99
100
      ready
101
    end)
102
    |> case do
103
      {:ok, ready} ->
104
        emit("account_ready", ready.id, attempt.id, %{
105
          credential_version: ready.credential_version
106
        })
107
108
        broadcast({:account_ready, ready.id})
109
        {:ok, ready}
110
111
      {:error, _reason} ->
112
        {:error, :account_persistence_failed}
113
    end
114
  end
115
116
  @doc false
117
  def mark_failed(%DriverAccount{} = account, %DriverLoginAttempt{} = attempt, code) do
118
    code = normalize_error_code(code)
119
120
    _result =
121
      Repo.transaction(fn ->
122
        account
123
        |> DriverAccount.failed_changeset(code)
124
        |> Repo.update!()
125
126
        attempt
127
        |> DriverLoginAttempt.terminal_changeset("failed", code)
128
        |> Repo.update!()
129
      end)
130
131
    broadcast({:account_failed, account.id, code})
132
    emit("account_failed", account.id, attempt.id, %{error_code: code})
133
    :ok
134
  end
135
136
  @doc false
137
  def mark_cancelled(%DriverAccount{} = account, %DriverLoginAttempt{} = attempt) do
138
    _result =
139
      Repo.transaction(fn ->
140
        account
141
        |> DriverAccount.failed_changeset("login_cancelled")
142
        |> Repo.update!()
143
144
        attempt
145
        |> DriverLoginAttempt.terminal_changeset("cancelled", "login_cancelled")
146
        |> Repo.update!()
147
      end)
148
149
    broadcast({:account_cancelled, account.id})
150
    emit("device_login_cancelled", account.id, attempt.id)
151
    :ok
152
  end
153
154
  defp create_pending(operator, attributes) do
155
    Repo.transaction(fn ->
156
      used_refs =
157
        Repo.all(
158
          from(account in DriverAccount,
159
            where: account.status != "disconnected",
160
            select: account.secret_ref,
161
            lock: "FOR UPDATE"
162
          )
163
        )
164
165
      secret_ref =
166
        credential_refs()
167
        |> Enum.find(&(&1 not in used_refs))
168
        |> case do
169
          nil -> Repo.rollback(:account_capacity_reached)
170
          ref -> ref
171
        end
172
173
      account_id = Ecto.UUID.generate()
174
      label = normalized_label(Map.get(attributes, "label") || Map.get(attributes, :label))
175
176
      account =
177
        %DriverAccount{}
178
        |> DriverAccount.create_changeset(%{
179
          id: account_id,
180
          operator_id: operator.id,
181
          label: label,
182
          secret_ref: secret_ref
183
        })
184
        |> Repo.insert!()
185
186
      attempt =
187
        %DriverLoginAttempt{}
188
        |> DriverLoginAttempt.create_changeset(%{
189
          account_id: account.id,
190
          operator_id: operator.id,
191
          expires_at: DateTime.add(DateTime.utc_now(), @login_lifetime_seconds, :second)
192
        })
193
        |> Repo.insert!()
194
195
      {account, attempt}
196
    end)
197
    |> case do
198
      {:ok, {account, attempt}} -> {:ok, account, attempt}
199
      {:error, reason} when is_atom(reason) -> {:error, reason}
200
      {:error, _reason} -> {:error, :account_persistence_failed}
201
    end
202
  end
203
204
  defp normalized_label(value) when is_binary(value) do
205
    case String.trim(value) do
206
      "" -> "Operator Codex account"
207
      label -> String.slice(label, 0, 80)
208
    end
209
  end
210
211
  defp normalized_label(_value), do: "Operator Codex account"
212
213
  defp credential_refs do
214
    config()
215
    |> Keyword.get(:credential_refs, [])
216
    |> Enum.filter(&is_binary/1)
217
  end
218
219
  defp broadcast(event) do
220
    Phoenix.PubSub.broadcast(OpenAgents.PubSub, @topic, {:scv_codex_accounts, event})
221
  end
222
223
  defp emit(type, account_id, attempt_id, extra \\ %{}) do
224
    metadata =
225
      Map.merge(
226
        %{
227
          schema: "openagents.scv.codex_account.event.v1",
228
          type: type,
229
          account_id: account_id,
230
          attempt_id: attempt_id
231
        },
232
        extra
233
      )
234
235
    :telemetry.execute([:openagents, :scv, :codex_account, :event], %{count: 1}, metadata)
236
    Logger.info("SCV Codex account lifecycle event", Map.to_list(metadata))
237
    :ok
238
  end
239
240
  defp normalize_error_code(code) when is_atom(code), do: Atom.to_string(code)
241
242
  defp normalize_error_code(code) when is_binary(code) do
243
    code
244
    |> String.downcase()
245
    |> String.replace(~r/[^a-z0-9_]+/, "_")
246
    |> String.trim("_")
247
    |> case do
248
      "" -> "login_failed"
249
      normalized -> String.slice(normalized, 0, 80)
250
    end
251
  end
252
253
  defp normalize_error_code(_code), do: "login_failed"
254
255
  defp config, do: Application.fetch_env!(:openagents, :scv_codex)
256
end
lib/openagents/scv/codex_app_server.ex added +233

@@ -0,0 +1,233 @@

1
defmodule OpenAgents.SCV.CodexAppServer do
2
  @moduledoc """
3
  Owns one local Codex app-server process and its JSONL JSON-RPC connection.
4
5
  The client keeps standard output protocol-only, bounds incomplete lines, and
6
  rejects server requests it does not implement. Codex tracing stays disabled
7
  for the device-login process so one-time codes and account payloads do not
8
  enter application logs.
9
  """
10
11
  use GenServer
12
13
  @maximum_line_bytes 1_048_576
14
  @default_timeout 15_000
15
16
  @type request_result :: {:ok, map()} | {:error, atom() | map()}
17
18
  @spec start_link(keyword()) :: GenServer.on_start()
19
  def start_link(options) do
20
    GenServer.start_link(__MODULE__, options)
21
  end
22
23
  @spec request(pid(), String.t(), map(), timeout()) :: request_result()
24
  def request(server, method, params \\ %{}, timeout \\ @default_timeout)
25
      when is_binary(method) and is_map(params) do
26
    GenServer.call(server, {:request, method, params}, timeout)
27
  catch
28
    :exit, {:timeout, _detail} -> {:error, :request_timeout}
29
    :exit, _reason -> {:error, :server_unavailable}
30
  end
31
32
  @spec notify(pid(), String.t(), map()) :: :ok | {:error, atom()}
33
  def notify(server, method, params \\ %{}) when is_binary(method) and is_map(params) do
34
    GenServer.call(server, {:notify, method, params})
35
  catch
36
    :exit, _reason -> {:error, :server_unavailable}
37
  end
38
39
  @spec stop(pid()) :: :ok
40
  def stop(server) when is_pid(server) do
41
    GenServer.stop(server, :normal, 5_000)
42
  catch
43
    :exit, _reason -> :ok
44
  end
45
46
  @impl true
47
  def init(options) do
48
    owner = Keyword.fetch!(options, :owner)
49
    executable = Keyword.fetch!(options, :executable)
50
    codex_home = Keyword.fetch!(options, :codex_home)
51
    args = Keyword.get(options, :args, ["app-server", "--listen", "stdio://"])
52
53
    with :ok <- validate_executable(executable),
54
         :ok <- File.mkdir_p(codex_home),
55
         :ok <- File.chmod(codex_home, 0o700),
56
         {:ok, port} <- open_port(executable, args, codex_home, options) do
57
      {:ok,
58
       %{
59
         buffer: "",
60
         next_id: 1,
61
         owner: owner,
62
         pending: %{},
63
         port: port
64
       }}
65
    else
66
      {:error, reason} -> {:stop, reason}
67
    end
68
  end
69
70
  @impl true
71
  def handle_call({:request, method, params}, from, state) do
72
    id = state.next_id
73
    payload = %{"id" => id, "method" => method, "params" => params}
74
75
    case send_message(state.port, payload) do
76
      :ok ->
77
        {:noreply, %{state | next_id: id + 1, pending: Map.put(state.pending, id, from)}}
78
79
      {:error, reason} ->
80
        {:reply, {:error, reason}, state}
81
    end
82
  end
83
84
  def handle_call({:notify, method, params}, _from, state) do
85
    payload =
86
      if params == %{},
87
        do: %{"method" => method},
88
        else: %{"method" => method, "params" => params}
89
90
    {:reply, send_message(state.port, payload), state}
91
  end
92
93
  @impl true
94
  def handle_info({port, {:data, data}}, %{port: port} = state) when is_binary(data) do
95
    combined = state.buffer <> data
96
97
    if byte_size(combined) > @maximum_line_bytes and not String.contains?(combined, "\n") do
98
      notify_owner(state.owner, {:protocol_error, :line_too_large})
99
      {:stop, :protocol_line_too_large, state}
100
    else
101
      pieces = :binary.split(combined, "\n", [:global])
102
      {buffer, lines} = List.pop_at(pieces, -1)
103
      state = Enum.reduce(lines, %{state | buffer: buffer}, &handle_line/2)
104
      {:noreply, state}
105
    end
106
  end
107
108
  def handle_info({port, {:exit_status, status}}, %{port: port} = state) do
109
    Enum.each(state.pending, fn {_id, from} -> GenServer.reply(from, {:error, :server_exited}) end)
110
111
    notify_owner(state.owner, {:exited, status})
112
    {:stop, :normal, %{state | pending: %{}}}
113
  end
114
115
  def handle_info(_message, state), do: {:noreply, state}
116
117
  @impl true
118
  def terminate(_reason, state) do
119
    if is_port(state.port), do: Port.close(state.port)
120
    :ok
121
  catch
122
    :error, :badarg -> :ok
123
  end
124
125
  defp handle_line("", state), do: state
126
127
  defp handle_line(line, state) do
128
    case Jason.decode(line) do
129
      {:ok, %{"id" => id, "result" => result}} when is_integer(id) ->
130
        reply_pending(state, id, {:ok, result})
131
132
      {:ok, %{"id" => id, "error" => error}} when is_integer(id) ->
133
        reply_pending(state, id, {:error, error})
134
135
      {:ok, %{"id" => id, "method" => _method}} when is_integer(id) ->
136
        _ =
137
          send_message(state.port, %{
138
            "id" => id,
139
            "error" => %{"code" => -32601, "message" => "Method not supported"}
140
          })
141
142
        state
143
144
      {:ok, %{"method" => _method} = notification} ->
145
        notify_owner(state.owner, {:notification, notification})
146
        state
147
148
      {:ok, _unknown} ->
149
        notify_owner(state.owner, {:protocol_error, :unknown_message})
150
        state
151
152
      {:error, _reason} ->
153
        notify_owner(state.owner, {:protocol_error, :invalid_json})
154
        state
155
    end
156
  end
157
158
  defp reply_pending(state, id, response) do
159
    case Map.pop(state.pending, id) do
160
      {nil, _pending} ->
161
        state
162
163
      {from, pending} ->
164
        GenServer.reply(from, response)
165
        %{state | pending: pending}
166
    end
167
  end
168
169
  defp open_port(executable, args, codex_home, options) do
170
    environment = isolated_environment(codex_home, options)
171
172
    port =
173
      Port.open(
174
        {:spawn_executable, String.to_charlist(executable)},
175
        [
176
          :binary,
177
          :exit_status,
178
          :hide,
179
          :use_stdio,
180
          args: Enum.map(args, &String.to_charlist/1),
181
          cd: String.to_charlist(codex_home),
182
          env: port_environment(environment)
183
        ]
184
      )
185
186
    {:ok, port}
187
  rescue
188
    _error -> {:error, :process_start_failed}
189
  end
190
191
  defp isolated_environment(codex_home, options) do
192
    allowed = %{
193
      "CODEX_HOME" => codex_home,
194
      "HOME" => codex_home,
195
      "LANG" => "C.UTF-8",
196
      "LC_ALL" => "C.UTF-8",
197
      "LOG_FORMAT" => "json",
198
      "PATH" =>
199
        Keyword.get(options, :path, System.get_env("PATH", "/usr/local/bin:/usr/bin:/bin")),
200
      "RUST_LOG" => "off",
201
      "TMPDIR" => codex_home
202
    }
203
204
    current = Map.new(System.get_env(), fn {key, _value} -> {key, false} end)
205
    Map.merge(current, allowed)
206
  end
207
208
  defp port_environment(environment) do
209
    Enum.map(environment, fn
210
      {key, false} -> {String.to_charlist(key), false}
211
      {key, value} -> {String.to_charlist(key), String.to_charlist(value)}
212
    end)
213
  end
214
215
  defp send_message(port, payload) do
216
    case Port.command(port, Jason.encode!(payload) <> "\n") do
217
      true -> :ok
218
      false -> {:error, :server_unavailable}
219
    end
220
  rescue
221
    _error -> {:error, :server_unavailable}
222
  end
223
224
  defp notify_owner(owner, message) do
225
    send(owner, {:codex_app_server, self(), message})
226
  end
227
228
  defp validate_executable(executable) when is_binary(executable) do
229
    if File.regular?(executable), do: :ok, else: {:error, :executable_not_found}
230
  end
231
232
  defp validate_executable(_executable), do: {:error, :executable_not_found}
233
end
lib/openagents/scv/codex_credential_store.ex added +23

@@ -0,0 +1,23 @@

1
defmodule OpenAgents.SCV.CodexCredentialStore do
2
  @moduledoc "Stores managed Codex authentication homes outside SCV metadata records."
3
4
  alias OpenAgents.SCV.DriverAccount
5
6
  @callback put(DriverAccount.t(), binary()) :: {:ok, pos_integer()} | {:error, atom()}
7
  @callback fetch(DriverAccount.t()) :: {:ok, binary()} | {:error, atom()}
8
9
  @spec put(DriverAccount.t(), binary()) :: {:ok, pos_integer()} | {:error, atom()}
10
  def put(%DriverAccount{} = account, auth_json) when is_binary(auth_json) do
11
    implementation().put(account, auth_json)
12
  end
13
14
  @spec fetch(DriverAccount.t()) :: {:ok, binary()} | {:error, atom()}
15
  def fetch(%DriverAccount{} = account), do: implementation().fetch(account)
16
17
  defp implementation do
18
    config()
19
    |> Keyword.fetch!(:credential_store)
20
  end
21
22
  defp config, do: Application.fetch_env!(:openagents, :scv_codex)
23
end
lib/openagents/scv/codex_credential_store/file.ex added +59

@@ -0,0 +1,59 @@

1
defmodule OpenAgents.SCV.CodexCredentialStore.File do
2
  @moduledoc "Private file credential store for local development and protocol tests."
3
4
  @behaviour OpenAgents.SCV.CodexCredentialStore
5
6
  alias OpenAgents.SCV.DriverAccount
7
8
  @impl true
9
  def put(%DriverAccount{} = account, auth_json) when is_binary(auth_json) do
10
    with {:ok, path} <- path(account),
11
         :ok <- File.mkdir_p(Path.dirname(path)),
12
         :ok <- File.chmod(Path.dirname(path), 0o700),
13
         :ok <- atomic_write(path, auth_json) do
14
      {:ok, System.system_time(:millisecond)}
15
    else
16
      _error -> {:error, :credential_store_failed}
17
    end
18
  end
19
20
  @impl true
21
  def fetch(%DriverAccount{} = account) do
22
    with {:ok, path} <- path(account),
23
         {:ok, auth_json} <- File.read(path) do
24
      {:ok, auth_json}
25
    else
26
      _error -> {:error, :credential_not_found}
27
    end
28
  end
29
30
  defp path(%DriverAccount{secret_ref: "file:" <> slot}) do
31
    if Regex.match?(~r/\A[a-zA-Z0-9_-]{1,80}\z/, slot) do
32
      {:ok, Path.join(root(), slot <> ".json")}
33
    else
34
      {:error, :credential_reference_invalid}
35
    end
36
  end
37
38
  defp path(%DriverAccount{}), do: {:error, :credential_reference_invalid}
39
40
  defp atomic_write(path, contents) do
41
    temporary = path <> ".tmp-" <> Integer.to_string(System.unique_integer([:positive]))
42
43
    with :ok <- File.write(temporary, contents, [:binary, :exclusive]),
44
         :ok <- File.chmod(temporary, 0o600),
45
         :ok <- File.rename(temporary, path) do
46
      :ok
47
    else
48
      error ->
49
        _ = File.rm(temporary)
50
        error
51
    end
52
  end
53
54
  defp root do
55
    :openagents
56
    |> Application.fetch_env!(:scv_codex)
57
    |> Keyword.fetch!(:file_root)
58
  end
59
end
lib/openagents/scv/codex_credential_store/gcp_secret_manager.ex added +96

@@ -0,0 +1,96 @@

1
defmodule OpenAgents.SCV.CodexCredentialStore.GcpSecretManager do
2
  @moduledoc "Google Secret Manager storage for versioned SCV Codex authentication homes."
3
4
  @behaviour OpenAgents.SCV.CodexCredentialStore
5
6
  alias OpenAgents.SCV.DriverAccount
7
8
  @impl true
9
  def put(%DriverAccount{} = account, auth_json) when is_binary(auth_json) do
10
    with {:ok, secret_ref} <- secret_ref(account),
11
         {:ok, token} <- access_token(),
12
         {:ok, response} <-
13
           Req.post(
14
             request_options(
15
               url: "#{api_base()}/v1/#{secret_ref}:addVersion",
16
               auth: {:bearer, token},
17
               json: %{payload: %{data: Base.encode64(auth_json)}}
18
             )
19
           ),
20
         200 <- response.status,
21
         %{"name" => version_name} <- response.body,
22
         {:ok, version} <- parse_version(version_name) do
23
      {:ok, version}
24
    else
25
      _error -> {:error, :credential_store_failed}
26
    end
27
  end
28
29
  @impl true
30
  def fetch(%DriverAccount{credential_version: version} = account) when is_integer(version) do
31
    with {:ok, secret_ref} <- secret_ref(account),
32
         {:ok, token} <- access_token(),
33
         {:ok, response} <-
34
           Req.get(
35
             request_options(
36
               url: "#{api_base()}/v1/#{secret_ref}/versions/#{version}:access",
37
               auth: {:bearer, token}
38
             )
39
           ),
40
         200 <- response.status,
41
         %{"payload" => %{"data" => encoded}} <- response.body,
42
         {:ok, auth_json} <- Base.decode64(encoded) do
43
      {:ok, auth_json}
44
    else
45
      _error -> {:error, :credential_not_found}
46
    end
47
  end
48
49
  def fetch(%DriverAccount{}), do: {:error, :credential_not_found}
50
51
  defp access_token do
52
    with {:ok, response} <-
53
           Req.get(
54
             request_options(
55
               url:
56
                 "#{metadata_base()}/computeMetadata/v1/instance/service-accounts/default/token",
57
               headers: [{"metadata-flavor", "Google"}]
58
             )
59
           ),
60
         200 <- response.status,
61
         %{"access_token" => token} when is_binary(token) <- response.body do
62
      {:ok, token}
63
    else
64
      _error -> {:error, :workload_identity_unavailable}
65
    end
66
  end
67
68
  defp request_options(options) do
69
    configured = Keyword.get(config(), :request_options, [])
70
    Keyword.merge(configured, options)
71
  end
72
73
  defp secret_ref(%DriverAccount{secret_ref: "projects/" <> _rest = ref}), do: {:ok, ref}
74
  defp secret_ref(%DriverAccount{}), do: {:error, :credential_reference_invalid}
75
76
  defp parse_version(name) do
77
    case Regex.run(~r{/versions/([0-9]+)\z}, name, capture: :all_but_first) do
78
      [encoded] ->
79
        case Integer.parse(encoded) do
80
          {version, ""} when version > 0 -> {:ok, version}
81
          _invalid -> {:error, :credential_version_invalid}
82
        end
83
84
      _invalid ->
85
        {:error, :credential_version_invalid}
86
    end
87
  end
88
89
  defp api_base,
90
    do: Keyword.get(config(), :secret_manager_api_base, "https://secretmanager.googleapis.com")
91
92
  defp metadata_base,
93
    do: Keyword.get(config(), :metadata_api_base, "http://metadata.google.internal")
94
95
  defp config, do: Application.fetch_env!(:openagents, :scv_codex)
96
end
lib/openagents/scv/codex_login.ex added +347

@@ -0,0 +1,347 @@

1
defmodule OpenAgents.SCV.CodexLogin do
2
  @moduledoc "Runs one bounded operator-initiated Codex device-login ceremony."
3
4
  use GenServer, restart: :temporary
5
6
  alias OpenAgents.SCV.CodexAccounts
7
  alias OpenAgents.SCV.CodexAppServer
8
  alias OpenAgents.SCV.CodexCredentialStore
9
10
  @required_model "gpt-5.6-luna"
11
  @maximum_auth_bytes 65_536
12
13
  def child_spec(options) do
14
    attempt = Keyword.fetch!(options, :attempt)
15
16
    %{
17
      id: {__MODULE__, attempt.id},
18
      start: {__MODULE__, :start_link, [options]},
19
      restart: :temporary
20
    }
21
  end
22
23
  def start_link(options) do
24
    attempt = Keyword.fetch!(options, :attempt)
25
    name = {:via, Registry, {OpenAgents.SCV.CodexLoginRegistry, attempt.id}}
26
    GenServer.start_link(__MODULE__, options, name: name)
27
  end
28
29
  @spec begin(pid()) :: {:ok, map()} | {:error, atom()}
30
  def begin(server), do: GenServer.call(server, :begin, 30_000)
31
32
  @spec snapshot(pid()) :: {:ok, map()} | {:error, atom()}
33
  def snapshot(server), do: GenServer.call(server, :snapshot)
34
35
  @spec cancel(pid()) :: :ok | {:error, atom()}
36
  def cancel(server), do: GenServer.call(server, :cancel, 15_000)
37
38
  @impl true
39
  def init(options) do
40
    account = Keyword.fetch!(options, :account)
41
    attempt = Keyword.fetch!(options, :attempt)
42
    root = temporary_home(attempt.id)
43
44
    with :ok <- File.mkdir_p(root),
45
         :ok <- File.chmod(root, 0o700),
46
         :ok <- write_config(root) do
47
      {:ok,
48
       %{
49
         account: account,
50
         app_server: nil,
51
         attempt: attempt,
52
         ceremony: nil,
53
         codex_home: root,
54
         expiry_timer: nil,
55
         login_id: nil
56
       }}
57
    else
58
      _error -> {:stop, :login_home_failed}
59
    end
60
  end
61
62
  @impl true
63
  def handle_call(:begin, _from, %{app_server: nil} = state) do
64
    case begin_login(state) do
65
      {:ok, updated} ->
66
        {:reply, {:ok, updated.ceremony}, updated}
67
68
      {:error, code, updated} ->
69
        CodexAccounts.mark_failed(updated.account, updated.attempt, code)
70
        {:stop, :normal, {:error, code}, updated}
71
    end
72
  end
73
74
  def handle_call(:begin, _from, state), do: {:reply, snapshot_response(state), state}
75
  def handle_call(:snapshot, _from, state), do: {:reply, snapshot_response(state), state}
76
77
  def handle_call(:cancel, _from, %{login_id: login_id, app_server: client} = state)
78
      when is_binary(login_id) and is_pid(client) do
79
    _result =
80
      CodexAppServer.request(client, "account/login/cancel", %{"loginId" => login_id})
81
82
    CodexAccounts.mark_cancelled(state.account, state.attempt)
83
    {:stop, :normal, :ok, state}
84
  end
85
86
  def handle_call(:cancel, _from, state) do
87
    CodexAccounts.mark_cancelled(state.account, state.attempt)
88
    {:stop, :normal, :ok, state}
89
  end
90
91
  @impl true
92
  def handle_info(
93
        {:codex_app_server, client,
94
         {:notification,
95
          %{
96
            "method" => "account/login/completed",
97
            "params" => %{"loginId" => login_id, "success" => true}
98
          }}},
99
        %{app_server: client, login_id: login_id} = state
100
      ) do
101
    case complete_login(state) do
102
      {:ok, updated} ->
103
        {:stop, :normal, updated}
104
105
      {:error, code, updated} ->
106
        CodexAccounts.mark_failed(updated.account, updated.attempt, code)
107
        {:stop, :normal, updated}
108
    end
109
  end
110
111
  def handle_info(
112
        {:codex_app_server, client,
113
         {:notification,
114
          %{
115
            "method" => "account/login/completed",
116
            "params" => %{"loginId" => login_id, "success" => false} = params
117
          }}},
118
        %{app_server: client, login_id: login_id} = state
119
      ) do
120
    code = if is_binary(params["error"]), do: params["error"], else: "login_failed"
121
    CodexAccounts.mark_failed(state.account, state.attempt, code)
122
    {:stop, :normal, state}
123
  end
124
125
  def handle_info(:expire, state) do
126
    if is_pid(state.app_server) and is_binary(state.login_id) do
127
      _result =
128
        CodexAppServer.request(
129
          state.app_server,
130
          "account/login/cancel",
131
          %{"loginId" => state.login_id}
132
        )
133
    end
134
135
    CodexAccounts.mark_failed(state.account, state.attempt, "login_expired")
136
    {:stop, :normal, state}
137
  end
138
139
  def handle_info({:codex_app_server, client, {:exited, _status}}, %{app_server: client} = state) do
140
    CodexAccounts.mark_failed(state.account, state.attempt, "app_server_exited")
141
    {:stop, :normal, state}
142
  end
143
144
  def handle_info(
145
        {:codex_app_server, client, {:protocol_error, reason}},
146
        %{app_server: client} = state
147
      ) do
148
    CodexAccounts.mark_failed(state.account, state.attempt, reason)
149
    {:stop, :normal, state}
150
  end
151
152
  def handle_info({:codex_app_server, _client, _message}, state), do: {:noreply, state}
153
  def handle_info(_message, state), do: {:noreply, state}
154
155
  @impl true
156
  def terminate(_reason, state) do
157
    if is_reference(state.expiry_timer), do: Process.cancel_timer(state.expiry_timer)
158
    if is_pid(state.app_server), do: CodexAppServer.stop(state.app_server)
159
    File.rm_rf(state.codex_home)
160
    :ok
161
  end
162
163
  defp begin_login(state) do
164
    config = config()
165
    executable = Keyword.fetch!(config, :executable)
166
    client_options = Keyword.get(config, :client_options, [])
167
168
    with {:ok, client} <-
169
           CodexAppServer.start_link(
170
             [owner: self(), executable: executable, codex_home: state.codex_home] ++
171
               client_options
172
           ),
173
         {:ok, _initialization} <- initialize(client),
174
         :ok <- CodexAppServer.notify(client, "initialized"),
175
         {:ok,
176
          %{
177
            "type" => "chatgptDeviceCode",
178
            "loginId" => login_id,
179
            "verificationUrl" => verification_url,
180
            "userCode" => user_code
181
          }} <-
182
           CodexAppServer.request(client, "account/login/start", %{
183
             "type" => "chatgptDeviceCode"
184
           }),
185
         :ok <- validate_login_response(login_id, verification_url, user_code),
186
         {:ok, attempt} <-
187
           CodexAccounts.mark_waiting(
188
             state.attempt,
189
             login_id,
190
             verification_url,
191
             user_code
192
           ) do
193
      expires_in_ms = max(DateTime.diff(attempt.expires_at, DateTime.utc_now(), :millisecond), 1)
194
      timer = Process.send_after(self(), :expire, expires_in_ms)
195
196
      ceremony = %{
197
        account_id: state.account.id,
198
        attempt_id: attempt.id,
199
        expires_at: attempt.expires_at,
200
        user_code: user_code,
201
        verification_url: verification_url
202
      }
203
204
      {:ok,
205
       %{
206
         state
207
         | app_server: client,
208
           attempt: attempt,
209
           ceremony: ceremony,
210
           expiry_timer: timer,
211
           login_id: login_id
212
       }}
213
    else
214
      {:error, reason} -> {:error, error_code(reason), state}
215
      _invalid -> {:error, :login_protocol_invalid, state}
216
    end
217
  end
218
219
  defp initialize(client) do
220
    CodexAppServer.request(client, "initialize", %{
221
      "clientInfo" => %{
222
        "name" => "openagents_scv",
223
        "title" => "OpenAgents SCV",
224
        "version" => Application.get_env(:openagents, :build_revision, "image")
225
      },
226
      "capabilities" => %{"experimentalApi" => false}
227
    })
228
  end
229
230
  defp complete_login(state) do
231
    with {:ok, account_response} <-
232
           CodexAppServer.request(state.app_server, "account/read", %{"refreshToken" => false}),
233
         {:ok, account_metadata} <- account_metadata(account_response),
234
         {:ok, model_response} <-
235
           CodexAppServer.request(state.app_server, "model/list", %{
236
             "includeHidden" => true,
237
             "limit" => 100
238
           }),
239
         {:ok, model_metadata} <- model_metadata(model_response),
240
         {:ok, _rate_limits} <-
241
           CodexAppServer.request(state.app_server, "account/rateLimits/read", %{}),
242
         {:ok, auth_json} <- read_auth_json(state.codex_home),
243
         {:ok, version} <- CodexCredentialStore.put(state.account, auth_json),
244
         {:ok, account} <-
245
           CodexAccounts.mark_ready(state.account, state.attempt, %{
246
             credential_version: version,
247
             account_email: account_metadata.email,
248
             plan_type: account_metadata.plan_type,
249
             available_models: model_metadata.models,
250
             reasoning_efforts: model_metadata.reasoning_efforts,
251
             last_verified_at: DateTime.utc_now()
252
           }) do
253
      {:ok, %{state | account: account}}
254
    else
255
      {:error, reason} -> {:error, error_code(reason), state}
256
      _invalid -> {:error, :login_completion_invalid, state}
257
    end
258
  end
259
260
  defp account_metadata(%{
261
         "account" => %{"type" => "chatgpt", "planType" => plan_type} = account
262
       })
263
       when is_binary(plan_type) do
264
    email = if is_binary(account["email"]), do: account["email"], else: nil
265
    {:ok, %{email: email, plan_type: plan_type}}
266
  end
267
268
  defp account_metadata(_response), do: {:error, :chatgpt_account_required}
269
270
  defp model_metadata(%{"data" => models}) when is_list(models) do
271
    admitted =
272
      Enum.filter(models, fn model ->
273
        model["id"] == @required_model or model["model"] == @required_model
274
      end)
275
276
    if admitted == [] do
277
      {:error, :required_model_unavailable}
278
    else
279
      model_ids =
280
        models
281
        |> Enum.map(&(&1["id"] || &1["model"]))
282
        |> Enum.filter(&is_binary/1)
283
        |> Enum.uniq()
284
285
      reasoning_efforts =
286
        admitted
287
        |> Enum.flat_map(&Map.get(&1, "supportedReasoningEfforts", []))
288
        |> Enum.map(fn option -> option["reasoningEffort"] end)
289
        |> Enum.filter(&(&1 in ["none", "low"]))
290
        |> Enum.uniq()
291
292
      if reasoning_efforts == [] do
293
        {:error, :required_reasoning_effort_unavailable}
294
      else
295
        {:ok, %{models: model_ids, reasoning_efforts: reasoning_efforts}}
296
      end
297
    end
298
  end
299
300
  defp model_metadata(_response), do: {:error, :model_catalog_invalid}
301
302
  defp read_auth_json(codex_home) do
303
    path = Path.join(codex_home, "auth.json")
304
305
    with {:ok, contents} when byte_size(contents) in 2..@maximum_auth_bytes <- File.read(path),
306
         {:ok, decoded} when is_map(decoded) <- Jason.decode(contents) do
307
      {:ok, contents}
308
    else
309
      _invalid -> {:error, :auth_cache_invalid}
310
    end
311
  end
312
313
  defp validate_login_response(login_id, verification_url, user_code)
314
       when is_binary(login_id) and byte_size(login_id) in 1..128 and is_binary(user_code) and
315
              byte_size(user_code) in 1..64 do
316
    case URI.new(verification_url) do
317
      {:ok, %URI{scheme: "https", host: "auth.openai.com", path: "/codex/device"}} -> :ok
318
      _invalid -> {:error, :verification_url_invalid}
319
    end
320
  end
321
322
  defp validate_login_response(_login_id, _verification_url, _user_code),
323
    do: {:error, :login_protocol_invalid}
324
325
  defp snapshot_response(%{ceremony: ceremony}) when is_map(ceremony), do: {:ok, ceremony}
326
  defp snapshot_response(_state), do: {:error, :login_not_ready}
327
328
  defp write_config(codex_home) do
329
    path = Path.join(codex_home, "config.toml")
330
    contents = "cli_auth_credentials_store = \"file\"\n"
331
332
    with :ok <- File.write(path, contents, [:binary, :exclusive]),
333
         :ok <- File.chmod(path, 0o600) do
334
      :ok
335
    end
336
  end
337
338
  defp temporary_home(attempt_id) do
339
    root = Keyword.get(config(), :temporary_root, System.tmp_dir!())
340
    Path.join(root, "openagents-scv-codex-login-#{attempt_id}")
341
  end
342
343
  defp error_code(reason) when is_atom(reason), do: reason
344
  defp error_code(_reason), do: :login_failed
345
346
  defp config, do: Application.fetch_env!(:openagents, :scv_codex)
347
end
lib/openagents/scv/codex_login_supervisor.ex added +38

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

1
defmodule OpenAgents.SCV.CodexLoginSupervisor do
2
  @moduledoc "Supervises one isolated Codex app-server process per pending account login."
3
4
  use DynamicSupervisor
5
6
  alias OpenAgents.SCV.CodexLogin
7
  alias OpenAgents.SCV.DriverAccount
8
  alias OpenAgents.SCV.DriverLoginAttempt
9
10
  def start_link(options) do
11
    DynamicSupervisor.start_link(__MODULE__, options, name: __MODULE__)
12
  end
13
14
  @impl true
15
  def init(_options), do: DynamicSupervisor.init(strategy: :one_for_one)
16
17
  @spec start_login(DriverAccount.t(), DriverLoginAttempt.t()) :: {:ok, map()} | {:error, atom()}
18
  def start_login(%DriverAccount{} = account, %DriverLoginAttempt{} = attempt) do
19
    child = {CodexLogin, account: account, attempt: attempt}
20
21
    with {:ok, pid} <- DynamicSupervisor.start_child(__MODULE__, child),
22
         {:ok, ceremony} <- CodexLogin.begin(pid) do
23
      {:ok, ceremony}
24
    else
25
      {:error, {:already_started, pid}} -> CodexLogin.snapshot(pid)
26
      {:error, reason} when is_atom(reason) -> {:error, reason}
27
      {:error, _reason} -> {:error, :login_start_failed}
28
    end
29
  end
30
31
  @spec cancel(DriverLoginAttempt.t()) :: :ok | {:error, atom()}
32
  def cancel(%DriverLoginAttempt{id: attempt_id}) do
33
    case Registry.lookup(OpenAgents.SCV.CodexLoginRegistry, attempt_id) do
34
      [{pid, _value}] -> CodexLogin.cancel(pid)
35
      [] -> {:error, :login_not_running}
36
    end
37
  end
38
end
lib/openagents/scv/driver_account.ex added +79

@@ -0,0 +1,79 @@

1
defmodule OpenAgents.SCV.DriverAccount do
2
  @moduledoc "Restricted metadata for one operator-connected SCV driver account."
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  alias OpenAgents.Accounts.User
8
  alias OpenAgents.SCV.DriverLoginAttempt
9
10
  @primary_key {:id, :binary_id, autogenerate: true}
11
  @foreign_key_type :binary_id
12
  @timestamps_opts [type: :utc_datetime_usec]
13
14
  schema "scv_driver_accounts" do
15
    field :driver, :string, default: "codex_app_server"
16
    field :credential_kind, :string, default: "managed_chatgpt"
17
    field :label, :string
18
    field :status, :string, default: "pending"
19
    field :secret_ref, :string, redact: true
20
    field :credential_version, :integer
21
    field :account_email, :string
22
    field :plan_type, :string
23
    field :available_models, {:array, :string}, default: []
24
    field :reasoning_efforts, {:array, :string}, default: []
25
    field :last_verified_at, :utc_datetime_usec
26
    field :last_error_code, :string
27
    field :disconnected_at, :utc_datetime_usec
28
29
    belongs_to :operator, User
30
    has_many :login_attempts, DriverLoginAttempt, foreign_key: :account_id
31
32
    timestamps()
33
  end
34
35
  @type t :: %__MODULE__{}
36
37
  @doc false
38
  def create_changeset(account, attributes) do
39
    account
40
    |> cast(attributes, [:id, :operator_id, :label, :secret_ref])
41
    |> validate_required([:operator_id, :label, :secret_ref])
42
    |> validate_length(:label, min: 1, max: 80)
43
    |> validate_length(:secret_ref, min: 1, max: 512)
44
    |> put_change(:driver, "codex_app_server")
45
    |> put_change(:credential_kind, "managed_chatgpt")
46
    |> put_change(:status, "pending")
47
    |> foreign_key_constraint(:operator_id)
48
    |> unique_constraint(:secret_ref)
49
    |> check_constraint(:driver, name: :scv_driver_accounts_driver_check)
50
    |> check_constraint(:credential_kind, name: :scv_driver_accounts_credential_kind_check)
51
    |> check_constraint(:status, name: :scv_driver_accounts_status_check)
52
  end
53
54
  @doc false
55
  def ready_changeset(account, attributes) do
56
    account
57
    |> cast(attributes, [
58
      :credential_version,
59
      :account_email,
60
      :plan_type,
61
      :available_models,
62
      :reasoning_efforts,
63
      :last_verified_at
64
    ])
65
    |> validate_required([:credential_version, :plan_type, :last_verified_at])
66
    |> validate_length(:account_email, max: 320)
67
    |> validate_length(:plan_type, min: 1, max: 80)
68
    |> put_change(:status, "ready")
69
    |> put_change(:last_error_code, nil)
70
    |> check_constraint(:status, name: :scv_driver_accounts_status_check)
71
  end
72
73
  @doc false
74
  def failed_changeset(account, code) when is_binary(code) do
75
    account
76
    |> change(status: "failed", last_error_code: String.slice(code, 0, 80))
77
    |> check_constraint(:status, name: :scv_driver_accounts_status_check)
78
  end
79
end
lib/openagents/scv/driver_login_attempt.ex added +71

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

1
defmodule OpenAgents.SCV.DriverLoginAttempt do
2
  @moduledoc "Durable, credential-free state for one SCV Codex device login."
3
4
  use Ecto.Schema
5
  import Ecto.Changeset
6
7
  alias OpenAgents.Accounts.User
8
  alias OpenAgents.SCV.DriverAccount
9
10
  @primary_key {:id, :binary_id, autogenerate: true}
11
  @foreign_key_type :binary_id
12
  @timestamps_opts [type: :utc_datetime_usec]
13
14
  schema "scv_driver_login_attempts" do
15
    field :login_id, :string
16
    field :status, :string, default: "starting"
17
    field :verification_url, :string
18
    field :user_code_digest, :binary, redact: true
19
    field :expires_at, :utc_datetime_usec
20
    field :completed_at, :utc_datetime_usec
21
    field :failure_code, :string
22
23
    belongs_to :account, DriverAccount
24
    belongs_to :operator, User
25
26
    timestamps()
27
  end
28
29
  @type t :: %__MODULE__{}
30
31
  @doc false
32
  def create_changeset(attempt, attributes) do
33
    attempt
34
    |> cast(attributes, [:account_id, :operator_id, :expires_at])
35
    |> validate_required([:account_id, :operator_id, :expires_at])
36
    |> put_change(:status, "starting")
37
    |> foreign_key_constraint(:account_id)
38
    |> foreign_key_constraint(:operator_id)
39
    |> check_constraint(:status, name: :scv_driver_login_attempts_status_check)
40
  end
41
42
  @doc false
43
  def waiting_changeset(attempt, attributes) do
44
    attempt
45
    |> cast(attributes, [:login_id, :verification_url, :user_code_digest])
46
    |> validate_required([:login_id, :verification_url, :user_code_digest])
47
    |> validate_change(:verification_url, &validate_verification_url/2)
48
    |> put_change(:status, "waiting")
49
    |> unique_constraint(:login_id)
50
    |> check_constraint(:status, name: :scv_driver_login_attempts_status_check)
51
  end
52
53
  @doc false
54
  def terminal_changeset(attempt, status, failure_code \\ nil)
55
      when status in ["succeeded", "failed", "cancelled", "expired"] do
56
    attempt
57
    |> change(
58
      status: status,
59
      failure_code: failure_code,
60
      completed_at: DateTime.utc_now()
61
    )
62
    |> check_constraint(:status, name: :scv_driver_login_attempts_status_check)
63
  end
64
65
  defp validate_verification_url(:verification_url, value) do
66
    case URI.new(value) do
67
      {:ok, %URI{scheme: "https", host: "auth.openai.com", path: "/codex/device"}} -> []
68
      _invalid -> [verification_url: "must be the Codex device verification URL"]
69
    end
70
  end
71
end
lib/openagents_web/components/ui.ex modified +1 -1

@@ -62,7 +62,7 @@ defmodule OpenAgentsWeb.UI do

62 62
63 63
  attr :rest, :global,
64 64
    include:
65
      ~w(disabled form name value popovertarget popovertargetaction download href navigate patch)
65
      ~w(disabled form name value popovertarget popovertargetaction download href navigate patch rel target)
66 66
67 67
  slot :inner_block, required: true
68 68
lib/openagents_web/live/admin_scv_accounts_live.ex added +326

@@ -0,0 +1,326 @@

1
defmodule OpenAgentsWeb.AdminScvAccountsLive do
2
  @moduledoc "Operator-only connection surface for individual Codex accounts used by SCVs."
3
4
  use OpenAgentsWeb, :live_view
5
6
  alias OpenAgents.Accounts
7
  alias OpenAgents.SCV.CodexAccounts
8
9
  @impl true
10
  def mount(_params, _session, socket) do
11
    if Accounts.admin?(socket.assigns.current_user) do
12
      if connected?(socket), do: CodexAccounts.subscribe()
13
14
      {:ok,
15
       socket
16
       |> assign(:page_title, "Operator · SCV Codex accounts")
17
       |> assign(:codex_enabled, CodexAccounts.enabled?())
18
       |> assign(:pending, nil)
19
       |> assign(:form, to_form(%{"label" => ""}, as: :account))
20
       |> load_accounts()}
21
    else
22
      {:ok, redirect(socket, to: ~p"/")}
23
    end
24
  end
25
26
  @impl true
27
  def handle_event("connect_account", %{"account" => attributes}, socket) do
28
    with true <- Accounts.admin?(socket.assigns.current_user),
29
         {:ok, _account, _attempt, ceremony} <-
30
           CodexAccounts.start_device_login(socket.assigns.current_user, attributes) do
31
      {:noreply,
32
       socket
33
       |> assign(:pending, ceremony)
34
       |> assign(:form, to_form(%{"label" => ""}, as: :account))
35
       |> put_flash(:info, "Codex supplied a one-time device code.")
36
       |> load_accounts()}
37
    else
38
      false ->
39
        {:noreply, redirect(socket, to: ~p"/")}
40
41
      {:error, reason} ->
42
        {:noreply, put_flash(socket, :error, error_message(reason))}
43
    end
44
  end
45
46
  def handle_event("cancel_login", _params, socket) do
47
    if Accounts.admin?(socket.assigns.current_user) do
48
      result =
49
        case socket.assigns.pending do
50
          %{attempt_id: attempt_id} ->
51
            CodexAccounts.cancel_device_login(socket.assigns.current_user, attempt_id)
52
53
          nil ->
54
            {:error, :login_not_found}
55
        end
56
57
      socket =
58
        case result do
59
          :ok -> socket |> assign(:pending, nil) |> put_flash(:info, "Codex login cancelled.")
60
          {:error, reason} -> put_flash(socket, :error, error_message(reason))
61
        end
62
63
      {:noreply, load_accounts(socket)}
64
    else
65
      {:noreply, redirect(socket, to: ~p"/")}
66
    end
67
  end
68
69
  @impl true
70
  def handle_info({:scv_codex_accounts, {:account_ready, account_id}}, socket) do
71
    pending = clear_pending(socket.assigns.pending, account_id)
72
73
    {:noreply,
74
     socket
75
     |> assign(:pending, pending)
76
     |> put_flash(:info, "Codex account connected and verified for SCVs.")
77
     |> load_accounts()}
78
  end
79
80
  def handle_info({:scv_codex_accounts, {:account_failed, account_id, code}}, socket) do
81
    pending = clear_pending(socket.assigns.pending, account_id)
82
83
    {:noreply,
84
     socket
85
     |> assign(:pending, pending)
86
     |> put_flash(:error, error_message(code))
87
     |> load_accounts()}
88
  end
89
90
  def handle_info({:scv_codex_accounts, {:account_cancelled, account_id}}, socket) do
91
    {:noreply,
92
     socket
93
     |> assign(:pending, clear_pending(socket.assigns.pending, account_id))
94
     |> load_accounts()}
95
  end
96
97
  def handle_info(_message, socket), do: {:noreply, socket}
98
99
  defp load_accounts(socket), do: assign(socket, :accounts, CodexAccounts.list_accounts())
100
101
  defp clear_pending(%{account_id: account_id}, account_id), do: nil
102
  defp clear_pending(pending, _account_id), do: pending
103
104
  @impl true
105
  def render(assigns) do
106
    ~H"""
107
    <Layouts.app flash={@flash}>
108
      <main id="admin-scv-accounts-page" class="app-shell admin-shell">
109
        <Layouts.command_bar aria_label="SCV Codex account settings" current_user={@current_user}>
110
          <:lockup>
111
            <.button
112
              id="return-to-operator"
113
              variant={:chip}
114
              size={:xs}
115
              phx-click={JS.navigate(~p"/admin")}
116
            >
117
              <.icon name="arrow-left" /> OPERATOR
118
            </.button>
119
          </:lockup>
120
        </Layouts.command_bar>
121
122
        <section class="admin space-y-8" aria-labelledby="scv-codex-heading">
123
          <header class="admin-heading">
124
            <h1 id="scv-codex-heading">Codex accounts for SCVs</h1>
125
            <p>
126
              Connect an individual Codex account through OpenAI's device flow. OpenAgents
127
              keeps each account in an isolated app-server runtime and stores no access token
128
              in the account record.
129
            </p>
130
            <div class="admin-totals">
131
              <.badge variant={if(@codex_enabled, do: :success, else: :warning)}>
132
                {if(@codex_enabled, do: "DEVICE LOGIN READY", else: "DEVICE LOGIN DISABLED")}
133
              </.badge>
134
              <.badge variant={:info}>GPT-5.6 LUNA</.badge>
135
              <.badge variant={:dim}>NONE OR LOW REASONING</.badge>
136
            </div>
137
          </header>
138
139
          <.alert :if={!@codex_enabled} id="codex-disabled" variant={:warning}>
140
            This deployment has not enabled the Codex account runtime.
141
          </.alert>
142
143
          <section :if={@codex_enabled && is_nil(@pending)} aria-labelledby="connect-codex-heading">
144
            <.card id="connect-codex-account">
145
              <div class="space-y-5">
146
                <div class="max-w-2xl space-y-2">
147
                  <h2 id="connect-codex-heading" class="card-title">Connect an individual account</h2>
148
                  <p class="text-muted-foreground">
149
                    Device login must be enabled in your ChatGPT security settings or by your
150
                    workspace administrator.
151
                  </p>
152
                </div>
153
154
                <.form
155
                  for={@form}
156
                  id="codex-account-form"
157
                  phx-submit="connect_account"
158
                  class="flex max-w-2xl flex-col gap-4 sm:flex-row sm:items-end"
159
                >
160
                  <.field class="min-w-0 flex-1">
161
                    <.label for={@form[:label].id}>Account label</.label>
162
                    <.input
163
                      field={@form[:label]}
164
                      type="text"
165
                      maxlength="80"
166
                      placeholder="Primary operator account"
167
                      autocomplete="off"
168
                    />
169
                  </.field>
170
                  <.button id="connect-codex-submit" type="submit" variant={:primary}>
171
                    CONNECT CODEX
172
                  </.button>
173
                </.form>
174
              </div>
175
            </.card>
176
          </section>
177
178
          <section :if={@pending} id="codex-device-login" aria-labelledby="device-login-heading">
179
            <.card>
180
              <div class="space-y-6">
181
                <div class="max-w-2xl space-y-2">
182
                  <div class="flex flex-wrap items-center gap-3">
183
                    <h2 id="device-login-heading" class="card-title">Finish on OpenAI</h2>
184
                    <.badge variant={:warning}>WAITING FOR YOU</.badge>
185
                  </div>
186
                  <p class="text-muted-foreground">
187
                    Continue only if you started this Codex connection from this screen. Cancel
188
                    if another site or person supplied the code.
189
                  </p>
190
                </div>
191
192
                <div class="flex flex-col gap-4 sm:flex-row sm:items-center">
193
                  <div>
194
                    <span class="block text-sm text-muted-foreground">One-time code</span>
195
                    <code
196
                      id="codex-device-code"
197
                      class="mt-1 block select-all text-2xl font-semibold tracking-[0.12em] text-foreground"
198
                    >{@pending.user_code}</code>
199
                  </div>
200
                  <.button
201
                    id="open-codex-device-login"
202
                    variant={:primary}
203
                    href={@pending.verification_url}
204
                    target="_blank"
205
                    rel="noopener noreferrer"
206
                  >
207
                    OPEN OPENAI DEVICE LOGIN <.icon name="external-link" />
208
                  </.button>
209
                </div>
210
211
                <p class="text-sm text-muted-foreground">
212
                  This code expires at {format_timestamp(@pending.expires_at)}.
213
                </p>
214
215
                <.button id="cancel-codex-login" variant={:secondary} phx-click="cancel_login">
216
                  CANCEL
217
                </.button>
218
              </div>
219
            </.card>
220
          </section>
221
222
          <section aria-labelledby="connected-codex-heading" class="space-y-4">
223
            <div class="max-w-2xl space-y-2">
224
              <h2 id="connected-codex-heading">Connected accounts</h2>
225
              <p class="text-muted-foreground">
226
                Each row represents one SCV driver identity and one private Codex credential
227
                generation.
228
              </p>
229
            </div>
230
231
            <.empty :if={@accounts == []} id="codex-accounts-empty" title="No Codex accounts">
232
              Connect an individual operator account to create the first SCV driver identity.
233
            </.empty>
234
235
            <ol :if={@accounts != []} id="codex-accounts" class="admin-rows">
236
              <li :for={account <- @accounts} id={"codex-account-#{account.id}"}>
237
                <.card>
238
                  <div class="admin-row">
239
                    <div class="admin-identity">
240
                      <span>
241
                        <strong>{account.label}</strong>
242
                        <span>{account.account_email || "Account identity pending"}</span>
243
                      </span>
244
                    </div>
245
246
                    <div class="admin-state">
247
                      <.badge variant={status_variant(account.status)}>
248
                        {String.upcase(account.status)}
249
                      </.badge>
250
                      <.badge :if={account.plan_type} variant={:dim}>
251
                        {String.upcase(account.plan_type)}
252
                      </.badge>
253
                    </div>
254
255
                    <dl class="admin-meta">
256
                      <div>
257
                        <dt>Driver</dt>
258
                        <dd>Codex app-server</dd>
259
                      </div>
260
                      <div>
261
                        <dt>Model</dt>
262
                        <dd>{model_label(account.available_models)}</dd>
263
                      </div>
264
                      <div>
265
                        <dt>Reasoning</dt>
266
                        <dd>{reasoning_label(account.reasoning_efforts)}</dd>
267
                      </div>
268
                      <div>
269
                        <dt>Credential generation</dt>
270
                        <dd>{account.credential_version || "—"}</dd>
271
                      </div>
272
                      <div>
273
                        <dt>Verified</dt>
274
                        <dd>{format_timestamp(account.last_verified_at)}</dd>
275
                      </div>
276
                    </dl>
277
                  </div>
278
                </.card>
279
              </li>
280
            </ol>
281
          </section>
282
283
          <.alert id="codex-service-accounts-later" variant={:info}>
284
            <strong>Service accounts come second.</strong> OpenAgents will add them after the
285
            individual operator flow passes qualification. OpenAI makes service accounts
286
            available only on pay-as-you-go plans.
287
          </.alert>
288
        </section>
289
      </main>
290
    </Layouts.app>
291
    """
292
  end
293
294
  defp status_variant("ready"), do: :success
295
  defp status_variant("pending"), do: :warning
296
  defp status_variant("failed"), do: :danger
297
  defp status_variant(_status), do: :dim
298
299
  defp model_label(models) do
300
    if "gpt-5.6-luna" in models, do: "gpt-5.6-luna", else: "Pending"
301
  end
302
303
  defp reasoning_label([]), do: "Pending"
304
  defp reasoning_label(efforts), do: Enum.join(efforts, " or ")
305
306
  defp format_timestamp(nil), do: "—"
307
308
  defp format_timestamp(%DateTime{} = at) do
309
    at
310
    |> DateTime.truncate(:second)
311
    |> Calendar.strftime("%Y-%m-%d %H:%M:%S UTC")
312
  end
313
314
  defp error_message(:account_capacity_reached),
315
    do: "All configured Codex account slots are occupied."
316
317
  defp error_message(:codex_not_enabled), do: "Codex account connections are disabled."
318
  defp error_message(:login_not_found), do: "No pending Codex login was found."
319
  defp error_message(:login_not_running), do: "The pending Codex login is no longer running."
320
  defp error_message(:required_model_unavailable), do: "This account cannot use gpt-5.6-luna."
321
322
  defp error_message(reason) when is_binary(reason),
323
    do: "Codex account connection failed with code #{reason}."
324
325
  defp error_message(_reason), do: "Codex account connection failed."
326
end
lib/openagents_web/route_authority.ex modified +3

@@ -113,6 +113,9 @@ defmodule OpenAgentsWeb.RouteAuthority do

113 113
  defp policy(%{path: "/admin/forge"}),
114 114
    do: declaration(:operator, "configured operator GitHub ID", "forge:promote", true)
115 115
116
  defp policy(%{path: "/admin/scv/accounts"}),
117
    do: declaration(:operator, "configured operator GitHub ID", "scv:account:connect", true)
118
116 119
  defp policy(%{path: "/admin"}),
117 120
    do: declaration(:operator, "configured operator GitHub ID", "voice:metadata:read", false)
118 121
lib/openagents_web/router.ex modified +1

@@ -112,6 +112,7 @@ defmodule OpenAgentsWeb.Router do

112 112
      live "/settings/api-tokens", ApiTokensLive, :index
113 113
      live "/admin", AdminLive, :index
114 114
      live "/admin/forge", AdminForgeLive, :index
115
      live "/admin/scv/accounts", AdminScvAccountsLive, :index
115 116
116 117
      live "/:owner/:repo/issues/new", IssueNewLive, :new
117 118
      live "/:owner/:repo/issues/:number", IssueShowLive, :show
priv/migration_lineages/prior-2026-08-19.json modified +2 -1

@@ -186,7 +186,8 @@

186 186
    20260820085203,
187 187
    20260820130000,
188 188
    20260820140000,
189
    20260820150000
189
    20260820150000,
190
    20260820161342
190 191
  ],
191 192
  "required_tables": [
192 193
    "users",
priv/repo/migrations/20260820161342_create_scv_codex_driver_accounts.exs added +68

@@ -0,0 +1,68 @@

1
defmodule OpenAgents.Repo.Migrations.CreateScvCodexDriverAccounts do
2
  use Ecto.Migration
3
4
  def change do
5
    create table(:scv_driver_accounts, primary_key: false) do
6
      add :id, :uuid, primary_key: true
7
      add :operator_id, references(:users, type: :uuid, on_delete: :restrict), null: false
8
      add :driver, :string, null: false
9
      add :credential_kind, :string, null: false
10
      add :label, :string, null: false
11
      add :status, :string, null: false
12
      add :secret_ref, :string, null: false
13
      add :credential_version, :bigint
14
      add :account_email, :string
15
      add :plan_type, :string
16
      add :available_models, {:array, :string}, null: false, default: []
17
      add :reasoning_efforts, {:array, :string}, null: false, default: []
18
      add :last_verified_at, :utc_datetime_usec
19
      add :last_error_code, :string
20
      add :disconnected_at, :utc_datetime_usec
21
22
      timestamps(type: :utc_datetime_usec)
23
    end
24
25
    create unique_index(:scv_driver_accounts, [:secret_ref])
26
    create index(:scv_driver_accounts, [:operator_id, :status])
27
28
    create constraint(:scv_driver_accounts, :scv_driver_accounts_driver_check,
29
             check: "driver = 'codex_app_server'"
30
           )
31
32
    create constraint(:scv_driver_accounts, :scv_driver_accounts_credential_kind_check,
33
             check: "credential_kind = 'managed_chatgpt'"
34
           )
35
36
    create constraint(:scv_driver_accounts, :scv_driver_accounts_status_check,
37
             check:
38
               "status IN ('pending','ready','failed','reauthentication_required','disconnected')"
39
           )
40
41
    create table(:scv_driver_login_attempts, primary_key: false) do
42
      add :id, :uuid, primary_key: true
43
44
      add :account_id,
45
          references(:scv_driver_accounts, type: :uuid, on_delete: :delete_all),
46
          null: false
47
48
      add :operator_id, references(:users, type: :uuid, on_delete: :restrict), null: false
49
      add :login_id, :string
50
      add :status, :string, null: false
51
      add :verification_url, :string
52
      add :user_code_digest, :binary
53
      add :expires_at, :utc_datetime_usec, null: false
54
      add :completed_at, :utc_datetime_usec
55
      add :failure_code, :string
56
57
      timestamps(type: :utc_datetime_usec)
58
    end
59
60
    create unique_index(:scv_driver_login_attempts, [:login_id], where: "login_id IS NOT NULL")
61
    create index(:scv_driver_login_attempts, [:account_id, :inserted_at])
62
    create index(:scv_driver_login_attempts, [:operator_id, :status])
63
64
    create constraint(:scv_driver_login_attempts, :scv_driver_login_attempts_status_check,
65
             check: "status IN ('starting','waiting','succeeded','failed','cancelled','expired')"
66
           )
67
  end
68
end
test/openagents/scv/codex_accounts_test.exs added +157

@@ -0,0 +1,157 @@

1
defmodule OpenAgents.SCV.CodexAccountsTest do
2
  use OpenAgents.DataCase, async: false
3
4
  alias OpenAgents.Accounts
5
  alias OpenAgents.Repo
6
  alias OpenAgents.SCV.CodexAccounts
7
  alias OpenAgents.SCV.DriverAccount
8
  alias OpenAgents.SCV.DriverLoginAttempt
9
10
  setup do
11
    original = Application.fetch_env!(:openagents, :scv_codex)
12
    original_admin_ids = Application.get_env(:openagents, :admin_github_ids, [])
13
14
    root =
15
      Path.join(System.tmp_dir!(), "codex-account-test-#{System.unique_integer([:positive])}")
16
17
    refs = ["file:slot-#{System.unique_integer([:positive])}"]
18
19
    Application.put_env(
20
      :openagents,
21
      :scv_codex,
22
      Keyword.merge(original, file_root: root, credential_refs: refs)
23
    )
24
25
    on_exit(fn ->
26
      Application.put_env(:openagents, :scv_codex, original)
27
      Application.put_env(:openagents, :admin_github_ids, original_admin_ids)
28
      File.rm_rf(root)
29
    end)
30
31
    {:ok, root: root}
32
  end
33
34
  test "connects an individual operator account and persists only a credential reference", %{
35
    root: root
36
  } do
37
    telemetry_id = "codex-account-test-#{System.unique_integer([:positive])}"
38
39
    :ok =
40
      :telemetry.attach(
41
        telemetry_id,
42
        [:openagents, :scv, :codex_account, :event],
43
        fn _event, _measurements, metadata, test_process ->
44
          send(test_process, {:codex_account_event, metadata})
45
        end,
46
        self()
47
      )
48
49
    on_exit(fn -> :telemetry.detach(telemetry_id) end)
50
51
    operator = operator("codex-operator")
52
    :ok = CodexAccounts.subscribe()
53
54
    assert {:ok, account, attempt, ceremony} =
55
             CodexAccounts.start_device_login(operator, %{"label" => "Primary Codex"})
56
57
    assert ceremony.verification_url == "https://auth.openai.com/codex/device"
58
    assert ceremony.user_code == "TEST-CODE"
59
    assert ceremony.account_id == account.id
60
    assert ceremony.attempt_id == attempt.id
61
62
    assert_receive {:codex_account_event,
63
                    %{
64
                      schema: "openagents.scv.codex_account.event.v1",
65
                      type: "device_login_waiting",
66
                      account_id: account_id,
67
                      attempt_id: attempt_id
68
                    }},
69
                   5_000
70
71
    assert account_id == account.id
72
    assert attempt_id == attempt.id
73
74
    assert_receive {:scv_codex_accounts, {:account_ready, account_id}}, 5_000
75
    assert account_id == account.id
76
77
    assert_receive {:codex_account_event,
78
                    %{
79
                      type: "account_ready",
80
                      account_id: ready_account_id,
81
                      attempt_id: ready_attempt_id,
82
                      credential_version: credential_version
83
                    }},
84
                   5_000
85
86
    assert ready_account_id == account.id
87
    assert ready_attempt_id == attempt.id
88
    assert is_integer(credential_version)
89
90
    connected = Repo.get!(DriverAccount, account.id)
91
    completed = Repo.get!(DriverLoginAttempt, attempt.id)
92
93
    assert connected.status == "ready"
94
    assert connected.driver == "codex_app_server"
95
    assert connected.credential_kind == "managed_chatgpt"
96
    assert connected.account_email == "operator@example.test"
97
    assert connected.plan_type == "plus"
98
    assert "gpt-5.6-luna" in connected.available_models
99
    assert Enum.sort(connected.reasoning_efforts) == ["low", "none"]
100
    assert is_integer(connected.credential_version)
101
    assert completed.status == "succeeded"
102
    assert is_binary(completed.user_code_digest)
103
104
    credential_slot = String.replace_prefix(connected.secret_ref, "file:", "")
105
    credential_file = Path.join(root, credential_slot <> ".json")
106
    assert {:ok, stored} = File.read(credential_file)
107
    assert is_map(Jason.decode!(stored))
108
109
    inspected = inspect(connected)
110
    refute inspected =~ stored
111
    refute inspected =~ "test-only"
112
  end
113
114
  test "refuses ordinary users and fails closed when configured account slots are occupied" do
115
    ordinary = user("codex-ordinary")
116
    assert {:error, :not_authorized} = CodexAccounts.start_device_login(ordinary, %{})
117
118
    first_operator = operator("codex-first")
119
    second_operator = operator("codex-second")
120
    :ok = CodexAccounts.subscribe()
121
122
    assert {:ok, account, _attempt, _ceremony} =
123
             CodexAccounts.start_device_login(first_operator, %{})
124
125
    assert {:error, :account_capacity_reached} =
126
             CodexAccounts.start_device_login(second_operator, %{})
127
128
    assert_receive {:scv_codex_accounts, {:account_ready, account_id}}, 5_000
129
    assert account_id == account.id
130
  end
131
132
  defp operator(key) do
133
    account = user(key)
134
135
    Application.put_env(
136
      :openagents,
137
      :admin_github_ids,
138
      [account.github_id | Application.get_env(:openagents, :admin_github_ids, [])]
139
    )
140
141
    account
142
  end
143
144
  defp user(key) do
145
    digest = :crypto.hash(:sha256, key)
146
    github_id = digest |> binary_part(0, 7) |> :binary.decode_unsigned()
147
148
    {:ok, account} =
149
      Accounts.upsert_github_user(%{
150
        github_id: github_id,
151
        github_login: "test-#{String.slice(key, 0, 20)}",
152
        github_avatar_url: "https://avatars.githubusercontent.com/u/#{github_id}?v=4"
153
      })
154
155
    account
156
  end
157
end
test/openagents/scv/codex_app_server_test.exs added +45

@@ -0,0 +1,45 @@

1
defmodule OpenAgents.SCV.CodexAppServerTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.SCV.CodexAppServer
5
6
  test "initializes and exposes the device-code response without merging tracing output" do
7
    codex_home =
8
      Path.join(System.tmp_dir!(), "codex-app-server-test-#{System.unique_integer([:positive])}")
9
10
    on_exit(fn -> File.rm_rf(codex_home) end)
11
12
    server =
13
      start_supervised!(
14
        {CodexAppServer, owner: self(), executable: fixture(), codex_home: codex_home, args: []}
15
      )
16
17
    assert {:ok, %{"codexHome" => ^codex_home}} =
18
             CodexAppServer.request(server, "initialize", %{
19
               "clientInfo" => %{"name" => "test", "version" => "test"}
20
             })
21
22
    assert :ok = CodexAppServer.notify(server, "initialized")
23
24
    assert {:ok,
25
            %{
26
              "type" => "chatgptDeviceCode",
27
              "verificationUrl" => "https://auth.openai.com/codex/device",
28
              "userCode" => "TEST-CODE"
29
            }} =
30
             CodexAppServer.request(server, "account/login/start", %{
31
               "type" => "chatgptDeviceCode"
32
             })
33
34
    assert_receive {:codex_app_server, ^server,
35
                    {:notification,
36
                     %{
37
                       "method" => "account/login/completed",
38
                       "params" => %{"success" => true}
39
                     }}}
40
  end
41
42
  defp fixture do
43
    Path.expand("../../support/fake_codex_app_server.sh", __DIR__)
44
  end
45
end
test/openagents/scv/codex_gcp_secret_manager_test.exs added +80

@@ -0,0 +1,80 @@

1
defmodule OpenAgents.SCV.CodexGcpSecretManagerTest do
2
  use ExUnit.Case, async: false
3
4
  alias OpenAgents.SCV.CodexCredentialStore.GcpSecretManager
5
  alias OpenAgents.SCV.DriverAccount
6
7
  setup {Req.Test, :verify_on_exit!}
8
9
  setup do
10
    original = Application.fetch_env!(:openagents, :scv_codex)
11
12
    Application.put_env(
13
      :openagents,
14
      :scv_codex,
15
      Keyword.merge(original,
16
        request_options: [plug: {Req.Test, __MODULE__}],
17
        secret_manager_api_base: "https://secretmanager.example.test",
18
        metadata_api_base: "http://metadata.example.test"
19
      )
20
    )
21
22
    on_exit(fn -> Application.put_env(:openagents, :scv_codex, original) end)
23
  end
24
25
  test "adds an immutable secret version and returns its numeric generation" do
26
    expect_metadata_token()
27
28
    Req.Test.expect(__MODULE__, fn conn ->
29
      assert conn.request_path ==
30
               "/v1/projects/staging/secrets/scv-codex-operator-1:addVersion"
31
32
      assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer workload-token"]
33
34
      body = conn |> Req.Test.raw_body() |> Jason.decode!()
35
      assert {:ok, decoded} = Base.decode64(body["payload"]["data"])
36
      assert Jason.decode!(decoded) == %{"auth_mode" => "chatgpt"}
37
38
      Req.Test.json(conn, %{
39
        "name" => "projects/staging/secrets/scv-codex-operator-1/versions/7"
40
      })
41
    end)
42
43
    account = %DriverAccount{
44
      secret_ref: "projects/staging/secrets/scv-codex-operator-1"
45
    }
46
47
    assert {:ok, 7} = GcpSecretManager.put(account, Jason.encode!(%{auth_mode: "chatgpt"}))
48
  end
49
50
  test "fetches only the account's recorded secret version" do
51
    expect_metadata_token()
52
53
    Req.Test.expect(__MODULE__, fn conn ->
54
      assert conn.request_path ==
55
               "/v1/projects/staging/secrets/scv-codex-operator-1/versions/11:access"
56
57
      Req.Test.json(conn, %{
58
        "payload" => %{"data" => Base.encode64(Jason.encode!(%{auth_mode: "chatgpt"}))}
59
      })
60
    end)
61
62
    account = %DriverAccount{
63
      secret_ref: "projects/staging/secrets/scv-codex-operator-1",
64
      credential_version: 11
65
    }
66
67
    assert {:ok, encoded} = GcpSecretManager.fetch(account)
68
    assert Jason.decode!(encoded) == %{"auth_mode" => "chatgpt"}
69
  end
70
71
  defp expect_metadata_token do
72
    Req.Test.expect(__MODULE__, fn conn ->
73
      assert conn.request_path ==
74
               "/computeMetadata/v1/instance/service-accounts/default/token"
75
76
      assert Plug.Conn.get_req_header(conn, "metadata-flavor") == ["Google"]
77
      Req.Test.json(conn, %{"access_token" => "workload-token", "expires_in" => 3_600})
78
    end)
79
  end
80
end
test/openagents_web/live/admin_scv_accounts_live_test.exs added +52

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

1
defmodule OpenAgentsWeb.AdminScvAccountsLiveTest do
2
  use OpenAgentsWeb.ConnCase, async: false
3
4
  import Phoenix.LiveViewTest
5
6
  alias OpenAgents.SCV.CodexAccounts
7
8
  describe "access" do
9
    test "an operator can open the SCV Codex account surface", %{conn: conn} do
10
      conn = log_in_admin_user(conn, "scv-codex-operator")
11
      {:ok, view, _html} = live(conn, ~p"/admin/scv/accounts")
12
13
      assert has_element?(view, "#admin-scv-accounts-page")
14
      assert has_element?(view, "#codex-account-form")
15
      assert has_element?(view, "#codex-service-accounts-later")
16
    end
17
18
    test "ordinary and anonymous users are redirected without disclosure", %{conn: conn} do
19
      ordinary = log_in_github_user(conn, "scv-codex-ordinary")
20
21
      assert {:error, {:redirect, %{to: "/"}}} = live(ordinary, ~p"/admin/scv/accounts")
22
      assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/admin/scv/accounts")
23
    end
24
  end
25
26
  test "starts the device flow and replaces the one-time code with verified account state", %{
27
    conn: conn
28
  } do
29
    conn = log_in_admin_user(conn, "scv-codex-connect")
30
    {:ok, view, _html} = live(conn, ~p"/admin/scv/accounts")
31
    :ok = CodexAccounts.subscribe()
32
33
    view
34
    |> form("#codex-account-form", account: %{label: "Primary Codex"})
35
    |> render_submit()
36
37
    assert has_element?(view, "#codex-device-login")
38
    assert has_element?(view, "#codex-device-code", "TEST-CODE")
39
40
    assert has_element?(
41
             view,
42
             "#open-codex-device-login[href='https://auth.openai.com/codex/device']"
43
           )
44
45
    assert_receive {:scv_codex_accounts, {:account_ready, _account_id}}, 5_000
46
    _state = :sys.get_state(view.pid)
47
48
    assert has_element?(view, "#codex-accounts li", "Primary Codex")
49
    assert has_element?(view, "#codex-accounts", "gpt-5.6-luna")
50
    refute has_element?(view, "#codex-device-login")
51
  end
52
end
test/openagents_web/route_authority_test.exs modified +1

@@ -52,6 +52,7 @@ defmodule OpenAgentsWeb.RouteAuthorityTest do

52 52
  test "operator and machine surfaces cannot drift into browser or public classes" do
53 53
    assert route!(:get, "/admin").class == :operator
54 54
    assert route!(:get, "/admin/forge").scope == "forge:promote"
55
    assert route!(:get, "/admin/scv/accounts").scope == "scv:account:connect"
55 56
    assert route!(:post, "/controller/pairings").class == :machine
56 57
    assert route!(:get, "/controller/pairings/:id").scope == "machine:pairing:claim"
57 58
    assert route!(:post, "/api/inference/proxy").class == :internal_service
test/support/fake_codex_app_server.sh added +40

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

1
#!/bin/sh
2
set -eu
3
exec 2>/dev/null
4
5
mkdir -p "${CODEX_HOME}"
6
7
while IFS= read -r line; do
8
  id=$(printf '%s' "${line}" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p')
9
10
  case "${line}" in
11
    *'"method":"initialize"'*)
12
      printf '{"id":%s,"result":{"userAgent":"fake-codex/0.147.0","codexHome":"%s","platformFamily":"unix","platformOs":"linux"}}\n' "${id}" "${CODEX_HOME}"
13
      ;;
14
    *'"method":"initialized"'*)
15
      ;;
16
    *'"method":"account/login/start"'*)
17
      printf '%s' '{"auth_mode":"chatgpt","tokens":{"access_token":"test-only","refresh_token":"test-only"}}' > "${CODEX_HOME}/auth.json"
18
      chmod 600 "${CODEX_HOME}/auth.json"
19
      printf '{"id":%s,"result":{"type":"chatgptDeviceCode","loginId":"fake-login-id","verificationUrl":"https://auth.openai.com/codex/device","userCode":"TEST-CODE"}}\n' "${id}"
20
      printf '%s\n' '{"method":"account/login/completed","params":{"loginId":"fake-login-id","success":true,"error":null}}'
21
      ;;
22
    *'"method":"account/read"'*)
23
      printf '{"id":%s,"result":{"account":{"type":"chatgpt","email":"operator@example.test","planType":"plus"},"requiresOpenaiAuth":true}}\n' "${id}"
24
      ;;
25
    *'"method":"model/list"'*)
26
      printf '{"id":%s,"result":{"data":[{"id":"gpt-5.6-luna","model":"gpt-5.6-luna","supportedReasoningEfforts":[{"reasoningEffort":"none","description":"None"},{"reasoningEffort":"low","description":"Low"}]}],"nextCursor":null}}\n' "${id}"
27
      ;;
28
    *'"method":"account/rateLimits/read"'*)
29
      printf '{"id":%s,"result":{"rateLimits":{"limitId":"codex","primary":null,"secondary":null,"rateLimitReachedType":null},"rateLimitsByLimitId":null,"rateLimitResetCredits":null}}\n' "${id}"
30
      ;;
31
    *'"method":"account/login/cancel"'*)
32
      printf '{"id":%s,"result":{"status":"canceled"}}\n' "${id}"
33
      ;;
34
    *)
35
      if [ -n "${id}" ]; then
36
        printf '{"id":%s,"error":{"code":-32601,"message":"Method not found"}}\n' "${id}"
37
      fi
38
      ;;
39
  esac
40
done

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