From e1b51de46a1e89e4926ca6c3db17535163f706a0 Mon Sep 17 00:00:00 2001 From: Tim Disney Date: Wed, 29 Jul 2026 10:58:59 -0700 Subject: [PATCH] add codex login auth method --- CHANGELOG.md | 7 + docker/Dockerfile | 3 +- docs/design.md | 1 + docs/radial-json.md | 35 +++- docs/running-an-agent.md | 17 +- packages/daemon/README.md | 6 +- packages/daemon/src/cli.ts | 69 ++++++- packages/daemon/src/codex-auth.ts | 219 +++++++++++++++++++++++ packages/daemon/src/config.ts | 22 +++ packages/daemon/src/container.ts | 5 + packages/daemon/src/dispatch.ts | 21 +++ packages/daemon/src/harness.ts | 6 +- packages/daemon/src/index.ts | 1 + packages/daemon/src/node-shims.d.ts | 2 + packages/daemon/src/turn.ts | 35 +++- packages/daemon/test/cli.test.mjs | 37 ++++ packages/daemon/test/codex-auth.test.mjs | 102 +++++++++++ packages/daemon/test/config.test.mjs | 17 ++ packages/daemon/test/dispatch.test.mjs | 55 ++++++ packages/daemon/test/turn.test.mjs | 59 ++++++ 20 files changed, 702 insertions(+), 17 deletions(-) create mode 100644 packages/daemon/src/codex-auth.ts create mode 100644 packages/daemon/test/codex-auth.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b2476d..376cc04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +- **Refreshable ChatGPT-managed authentication for Codex turns.** A run may now opt into + `"codexAuth": { "mode": "chatgpt-session" }` and create a fresh instance-owned login with + `radiald codex login`. Radial serializes turns sharing the login, mounts only a scratch + `CODEX_HOME`, redacts its token bundle, validates and atomically persists refreshes, and discards + every other turn-created file. Ambient `CODEX_API_KEY` / `CODEX_ACCESS_TOKEN` values are refused + in this mode rather than silently changing the billing path. + Radial ships as one repository and one version. The only thing published to a package registry is `@radial/lexicons` — it is the wire contract, and the only piece another implementation needs. Everything else ships as a tagged git diff --git a/docker/Dockerfile b/docker/Dockerfile index 880a6e3..e1dba5f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -21,7 +21,8 @@ # /run/radial rw), env (RADIAL_SIDECAR_SOCKET, RADIAL_TURN_TOKEN, whichever provider # credential names the acting profile's harness declares — `Harness.credentialEnv` plus # `run.modelEnv`, so ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN for claude, any of pi's -# provider keys for pi, and CODEX_API_KEY / CODEX_ACCESS_TOKEN / OPENAI_API_KEY for codex — +# provider keys for pi, and CODEX_API_KEY / CODEX_ACCESS_TOKEN or a scratch managed-auth home +# for codex — # GH_TOKEN or, on a tangled project, GIT_SSH_COMMAND + # RADIAL_PUSH_REMOTE alongside a read-only /run/radial-forge mount, see # turn.ts), and the argv of whichever harness the profile names (`claude -p ...`, diff --git a/docs/design.md b/docs/design.md index 0988165..89f8f4d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -253,6 +253,7 @@ The invariant that keeps this from being alternative C (§14) is narrow and test - **Public coordination.** Everything on-protocol is world-readable. Current stance (decided): Radial is for development that can be coordinated in the open (the code itself can still live in a private repo — records leak plans/findings/paths, not file contents, but treat that as public too in practice). Permissioned/private spaces are a long-term goal with no near-term work: when atproto private state matures, membership already gives us the trust boundary to hang it on. - **Protocol credentials never enter containers.** atproto signing stays daemon-side behind the sidecar socket, which is the only harness → protocol write path, and a turn can only emit its requested type. This is the non-negotiable invariant: an agent's atproto identity is unscopeable, and revoking it costs the space its coordination history. Everything else below is scopeable and expendable, and is treated accordingly. - **Containers are a possession boundary, not a network boundary.** A turn container holds exactly two secrets: the operator's forge token (§10) — their own GitHub auth, or a repo-scoped fine-grained PAT if they want scoping — and a spend-capped model API key dedicated to agent turns, for whichever provider the profile's models name (the daemon forwards only credential names its harnesses declare, plus an explicit operator allowlist, and only when set in its own environment; a daemon-side model proxy that would keep the key out entirely — and give per-turn metering — is a possible later upgrade, not v1). Egress is open: agents fetch docs, packages, and arbitrary web resources. Network confinement was never protecting the contents — the repo and bundle are public by stance — and an allowlist taxes every legitimate lookup. What remains is **host isolation**: containers run on an ordinary bridge network (never host network), the sidecar socket is the only daemon-facing surface, and cloud metadata endpoints are blocked when deployed on cloud infra. Check containers get network too (dependency installs need it) but carry no secrets at all. +- **ChatGPT-managed Codex auth is an explicit stronger-credential mode.** `run.codexAuth.mode = "chatgpt-session"` replaces the spend-capped Codex API key with a refreshable account login for operators whose Codex entitlement lives in ChatGPT. A fresh device login belongs to one Radial instance; the operator's ordinary `~/.codex` is never copied or mounted. Each turn receives a scratch auth home, turns sharing it are serialized, and only a validated refreshed `auth.json` is atomically written back. Everything else in that home is discarded. This preserves cross-turn isolation, but not possession isolation: the running turn can read the account credential, so the mode is for trusted workloads and is never automatic. - **Prompt injection:** untrusted input is unbounded — member records in the bundle, repo contents, and anything the agent reads on the open web. The mitigation is entirely write-side. The worst an injected turn can do is waste its capped spend, leak its forge token, and spam forge writes within that token's forge-enforced scope — all attributable and revocable at the forge. The leaked token is the operator's own (§10), so the damage bound is branch protection plus the human merge, and the remedy is operator-side revocation; the deferred App tier (§10) shrinks the leak to an hour-lived scoped token when it lands. It cannot touch the protocol except through the typed, requested-type-only sidecar path (so it can't commission new work, even though the protocol itself wouldn't reject an agent-authored request, §3), and it cannot merge. Everything an agent writes is attributable to its DID and revocable with its membership. Review and the human merge remain the behavioral mitigation. ## 14. Alternatives considered diff --git a/docs/radial-json.md b/docs/radial-json.md index 5469d90..521870b 100644 --- a/docs/radial-json.md +++ b/docs/radial-json.md @@ -118,6 +118,7 @@ into the file. | `mergePollIntervalMs` / `mergePollBackoffMaxMs` | 60 s / 30 min | Per-PR merge polling base interval and backoff cap. | | `network` | Docker's default bridge | Docker network for turn containers. Host and `container:` networking are refused. | | `modelEnv` | `[]` | Extra environment variable **names** forwarded from the daemon's own environment into every turn container, on top of what the loaded profiles' harnesses already declare. See "Provider credentials" below. | +| `codexAuth` | unset | `{ "mode": "chatgpt-session" }` opts Codex profiles into a dedicated, refreshable ChatGPT login under the instance data directory. Run `radiald codex login` once. Conflicting `CODEX_API_KEY` / `CODEX_ACCESS_TOKEN` variables are then refused rather than allowed to override it. | | `gitSchemes` | `["https"]` | Git remote schemes a turn may clone from. | | `memory` | `4g` floor | Container memory limit (`--memory` syntax). Overrides the floor upward; never below it. | | `turnTransport` | `unix` | `tcp` is a macOS/Docker Desktop development escape hatch — do not expose the daemon socket. | @@ -274,13 +275,41 @@ and nothing else (design §13). Which credential depends on the harness: `MISTRAL_API_KEY`, `TOGETHER_API_KEY`, `FIREWORKS_API_KEY`, `CEREBRAS_API_KEY`, `NVIDIA_API_KEY`, `ZAI_API_KEY`, `AI_GATEWAY_API_KEY`, `ANTHROPIC_API_KEY`, and the rest of [pi's provider table](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/providers.md). -- **`codex`** reads `CODEX_API_KEY`, else `CODEX_ACCESS_TOKEN` (a ChatGPT/agent-identity token) — - its own resolution order, and either one alone is enough. Not `OPENAI_API_KEY`: current codex +- **`codex`** reads `CODEX_API_KEY`, else `CODEX_ACCESS_TOKEN` (a Business/Enterprise Codex access + token) — its own resolution order, and either one alone is enough. Not `OPENAI_API_KEY`: current codex releases no longer accept it as authentication for `codex exec` (its auth chain reads the two names above and then a persisted `~/.codex/auth.json` that a turn container never has), so Radial does not count it as a codex credential. A codex profile on a daemon that holds only `OPENAI_API_KEY` is refused at startup, which is the point. Codex also never reaches the - interactive `codex login` flow here, which would need a browser and a writable `~/.codex`. + interactive `codex login` flow inside an ordinary turn. + +Codex profiles can instead use a refreshable ChatGPT account session: + +```json +"run": { + "spaces": ["at://…"], + "codexAuth": { "mode": "chatgpt-session" } +} +``` + +Create a **fresh login dedicated to this Radial instance**: + +```sh +radiald codex login --config /path/to/radial.json +``` + +The device flow writes `/codex-auth/auth.json`. Radial never copies or mounts the +operator's ordinary `~/.codex`: two copies descended from one login can race refresh-token rotation +and break each other. A turn gets a scratch `CODEX_HOME` containing only the validated auth file +and a daemon-authored credential-storage setting. Codex may refresh that scratch file; Radial +atomically validates and writes it back, then deletes the entire scratch home. No rollout, cache, +config, plugin or turn-created file survives. Turns sharing the login are serialized even when +`run.concurrency` is greater than one. + +Managed account auth is a stronger credential than design §13's preferred spend-capped API key, +and the turn can read it while it runs. Use it only for workloads whose repository and prompt +inputs you trust. `CODEX_API_KEY` and `CODEX_ACCESS_TOKEN` are startup errors in this mode so an +ambient shell export cannot silently select a different billing path. The daemon forwards a name **only when it is set in its own environment**, and never invents a value. `radiald run` prints the names it will forward at startup (names only, never values): diff --git a/docs/running-an-agent.md b/docs/running-an-agent.md index 1b2699b..0417734 100644 --- a/docs/running-an-agent.md +++ b/docs/running-an-agent.md @@ -35,7 +35,9 @@ export CLAUDE_CODE_OAUTH_TOKEN='...' # For a "pi" profile, export the variable that profile's provider documents instead — e.g. # export OPENAI_API_KEY='...' # or GEMINI_API_KEY, OPENROUTER_API_KEY, GROQ_API_KEY, … # For a "codex" profile (OPENAI_API_KEY does not authenticate codex): -# export CODEX_API_KEY='...' # or CODEX_ACCESS_TOKEN +# export CODEX_API_KEY='...' # or a Business/Enterprise CODEX_ACCESS_TOKEN +# Or configure run.codexAuth.mode = "chatgpt-session", then run: +# radiald codex login --config /path/to/radial.json ``` `radiald run` says which credentials it will forward, by name, at startup — and @@ -370,9 +372,16 @@ humans reading the agent record and is never sent to any CLI. Moving one to `codex` is the same three steps: `"harness": "codex"` with a bare model slug (`"models": ["gpt-5.6-sol=high"]`), `radiald init --update`, -then `export CODEX_API_KEY='...'` (or `CODEX_ACCESS_TOKEN` — codex reads them in -that order, and no longer accepts `OPENAI_API_KEY` for a `codex exec` run, so -Radial does not treat it as a codex credential) and restart. +then either: + +- export `CODEX_API_KEY` (or a Business/Enterprise `CODEX_ACCESS_TOKEN`) and restart; or +- add `"codexAuth": { "mode": "chatgpt-session" }` to `run`, execute + `radiald codex login --config `, ensure the two environment credentials are unset, and + restart. + +The managed-login form creates a fresh session owned by this Radial instance; it never copies the +desktop/CLI `~/.codex/auth.json`. Codex turns sharing it are serialized so token refresh is safe, +and only a validated refreshed `auth.json` survives a turn. The daemon forwards the provider variable only when it is set in the daemon's own environment, and only names it knows: each harness declares the provider variables diff --git a/packages/daemon/README.md b/packages/daemon/README.md index 2932ce2..c28488b 100644 --- a/packages/daemon/README.md +++ b/packages/daemon/README.md @@ -54,8 +54,10 @@ Each agent profile names the harness it runs on — `claude` (Claude Code), `pi` the provider environment variables it understands. For `claude` that is a dedicated, spend-capped `ANTHROPIC_API_KEY` or a `CLAUDE_CODE_OAUTH_TOKEN`; for `pi` it is whichever variable that provider documents (`OPENAI_API_KEY`, `GEMINI_API_KEY`, `OPENROUTER_API_KEY`, …); for `codex` it is -`CODEX_API_KEY` or `CODEX_ACCESS_TOKEN` (not `OPENAI_API_KEY`, which current codex releases no -longer accept as authentication for `codex exec`). All are extensible with +`CODEX_API_KEY`, a Business/Enterprise `CODEX_ACCESS_TOKEN`, or the explicit +`run.codexAuth.mode = "chatgpt-session"` flow initialized by `radiald codex login` (not +`OPENAI_API_KEY`, which current codex releases no longer accept as authentication for `codex exec`). +All are extensible with `run.modelEnv`. The daemon forwards a name only when it is set in its own environment, prints the names (never the values) at startup, and refuses to start when a loaded profile's harness has no credential at all. Forwarded credentials reach turn containers only; check containers are diff --git a/packages/daemon/src/cli.ts b/packages/daemon/src/cli.ts index 31c98dc..97484c5 100644 --- a/packages/daemon/src/cli.ts +++ b/packages/daemon/src/cli.ts @@ -58,6 +58,7 @@ import { remoteBranchExists } from './bundle-writer.js' import { CheckDispatcher } from './check-dispatch.js' import { CheckLedger } from './check-ledger.js' import { runCheckRun, type CheckRunInput } from './check-runner.js' +import { CodexAuthStore, loginManagedCodex } from './codex-auth.js' import { DockerRunner, type ContainerRunner } from './container.js' import { TurnDispatcher } from './dispatch.js' import { ForgeRegistry, type ForgeAdapter, type PullStateContext } from './forge.js' @@ -121,6 +122,9 @@ const usage = `Usage: config, data dir, sessions file and state dir it resolved, plus the DID every profile is running as, and holds a lock on the state dir so a second daemon cannot open the same ledgers + radiald codex login [--config ] [--data-dir ] + creates a fresh, file-backed ChatGPT login dedicated to this Radial instance. + Requires run.codexAuth.mode = "chatgpt-session"; never copies ~/.codex radiald turn list [--state ] [--config ] [--data-dir ] prints the turn ledger, newest first: state, when it last moved, the request uri, and attempts/retry/branch. --state narrows it — use @@ -229,6 +233,8 @@ export interface ResolvedRunConfig { /** Extra environment variable names forwarded into turn containers, on top of what each * profile's harness declares. Defaults to `[]`. See `DaemonRunConfig.modelEnv`. */ modelEnv: string[] + /** Explicit refreshable ChatGPT authentication for Codex turns. */ + codexAuth?: { mode: 'chatgpt-session' } /** `unix` (default) or the dev-only `tcp` escape hatch — see `DaemonRunConfig.turnTransport`. */ turnTransport: 'unix' | 'tcp' gitSchemes: string[] @@ -293,6 +299,7 @@ export function resolveRunConfig( mergePollBackoffMaxMs: Math.max(mergePollInterval, run.mergePollBackoffMaxMs ?? 30 * 60_000), ...(run.network !== undefined ? { network: run.network } : {}), modelEnv: run.modelEnv ?? [], + ...(run.codexAuth !== undefined ? { codexAuth: run.codexAuth } : {}), turnTransport, gitSchemes: run.gitSchemes ?? ['https'], ...(run.memory !== undefined ? { memory: run.memory } : {}), @@ -406,6 +413,7 @@ export function resolveModelEnvironment( actors: readonly { profile: string; harness: string }[], extra: readonly string[], env: Environment, + options: { managedCodexAuth?: boolean } = {}, ): Record { for (const actor of actors) { try { @@ -414,8 +422,29 @@ export function resolveModelEnvironment( throw new Error(`profile "${actor.profile}": ${error instanceof Error ? error.message : String(error)}`) } } - const modelEnv = collectModelEnv(modelEnvNames(actors, extra), env) + const codexActors = actors.filter((actor) => actor.harness === 'codex') + if (options.managedCodexAuth && codexActors.length === 0) { + throw new Error('run.codexAuth is configured but no loaded profile uses harness "codex"') + } + if (options.managedCodexAuth) { + const conflicts = ['CODEX_API_KEY', 'CODEX_ACCESS_TOKEN'].filter( + (name) => env[name] !== undefined && env[name] !== '', + ) + if (conflicts.length > 0) { + throw new Error( + `run.codexAuth.mode is "chatgpt-session", but ${conflicts.join(', ')} is also set. ` + + 'Unset it so it cannot silently override the dedicated ChatGPT login.', + ) + } + } + const names = modelEnvNames(actors, extra).filter( + (name) => + !options.managedCodexAuth || + (name !== 'CODEX_API_KEY' && name !== 'CODEX_ACCESS_TOKEN'), + ) + const modelEnv = collectModelEnv(names, env) for (const actor of actors) { + if (options.managedCodexAuth && actor.harness === 'codex') continue const accepted = [...selectHarness(actor.harness).credentialEnv, ...extra] if (accepted.some((name) => modelEnv[name] !== undefined)) continue throw new Error( @@ -427,6 +456,29 @@ export function resolveModelEnvironment( return modelEnv } +async function codexLoginCommand(args: string[]): Promise { + const { instance, run } = await requireRunConfig(args) + if (run.codexAuth?.mode !== 'chatgpt-session') { + throw new Error( + `${instance.configPath} does not enable managed Codex auth; add ` + + '"codexAuth": { "mode": "chatgpt-session" } to its "run" block', + ) + } + const directory = join(instance.dataDir.path, 'codex-auth') + // A login replaces the refresh-token lineage. Refuse while this instance can be running a turn + // against the old lineage; the state-dir lock is the same single-daemon boundary `run` holds. + const stateLock = StateDirLock.acquire(run.stateDir) + try { + console.log(`Starting a fresh Radial-specific Codex device login`) + console.log(` auth: ${join(directory, 'auth.json')}`) + await loginManagedCodex(directory) + await new CodexAuthStore(directory).validate() + console.log(`Managed Codex login saved for ${instance.configPath}`) + } finally { + stateLock.release() + } +} + /** Deterministic per-request run directory under `/turns/`. Kept short (12 hex chars, * not a full hash) because the turn socket lives at `/run/turn.sock`, and AF_UNIX socket * paths have a small platform-enforced max length (~104-108 bytes) that a long stateDir plus a @@ -873,8 +925,15 @@ async function runCommand(args: string[]): Promise { // Fail closed at startup rather than per dispatch: an unrunnable harness or a profile with no // provider credential is knowable now, and finding out per-request costs a ledger attempt and a // cooldown each time. Names only in the log — never a value. - const modelEnv = resolveModelEnvironment(actors.all, run.modelEnv, process.env) + const codexAuth = run.codexAuth + ? new CodexAuthStore(join(instance.dataDir.path, 'codex-auth')) + : undefined + await codexAuth?.validate() + const modelEnv = resolveModelEnvironment(actors.all, run.modelEnv, process.env, { + managedCodexAuth: Boolean(codexAuth), + }) console.log(`model credentials forwarded to turns: ${Object.keys(modelEnv).join(', ') || '(none)'}`) + if (codexAuth) console.log(`managed Codex auth: ${codexAuth.path} (serialized refreshable session)`) const ledger = new TurnLedger(join(run.stateDir, 'ledger.db'), { retryBound: run.retryBound, @@ -978,6 +1037,7 @@ async function runCommand(args: string[]): Promise { runner, harnesses: { select: selectHarness }, modelEnv, + ...(codexAuth ? { codexAuth } : {}), ...(githubToken ? { githubToken } : {}), forges, log: (message) => console.log(message), @@ -1007,6 +1067,7 @@ async function runCommand(args: string[]): Promise { }), implementationEnabled: !!githubToken, runTurn: boundRunTurn, + ...(codexAuth ? { serializedHarnesses: ['codex'] } : {}), // Unassigned requests dispatch only for a claim this daemon wrote and has watched win. heldClaims: () => claimLedger.heldRequests(), kill: (label) => runner.kill(label), @@ -1177,6 +1238,10 @@ export async function main(args = argv.slice(2)): Promise { await runCommand(args.slice(1)) return } + if (args[0] === 'codex' && args[1] === 'login') { + await codexLoginCommand(args.slice(2)) + return + } if (args[0] === 'turn' && args[1] === 'list') { await turnListCommand(args.slice(2)) return diff --git a/packages/daemon/src/codex-auth.ts b/packages/daemon/src/codex-auth.ts new file mode 100644 index 0000000..c930e44 --- /dev/null +++ b/packages/daemon/src/codex-auth.ts @@ -0,0 +1,219 @@ +import { spawn } from 'node:child_process' +import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { Environment } from './config.js' + +const AUTH_FILENAME = 'auth.json' +const AUTH_LIMIT = 1024 * 1024 +const SCRATCH_CONFIG = 'cli_auth_credentials_store = "file"\n' + +interface ManagedAuth { + auth_mode: 'chatgpt' + tokens: { + access_token: string + id_token: string + refresh_token: string + } + last_refresh?: string +} + +function object(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Validate the narrow auth shape Radial is willing to persist. A turn controls its scratch home, + * so accepting arbitrary JSON back would turn that directory into cross-turn persistence. + */ +export function parseManagedCodexAuth(raw: string, source = AUTH_FILENAME): ManagedAuth { + if (raw.length > AUTH_LIMIT) throw new Error(`${source} exceeds ${AUTH_LIMIT} bytes`) + let value: unknown + try { + value = JSON.parse(raw) + } catch { + throw new Error(`${source} is not valid JSON`) + } + if (!object(value) || value.auth_mode !== 'chatgpt' || !object(value.tokens)) { + throw new Error(`${source} is not a ChatGPT-managed Codex login (expected auth_mode "chatgpt")`) + } + const accessToken = value.tokens.access_token + const idToken = value.tokens.id_token + const refreshToken = value.tokens.refresh_token + if ( + typeof accessToken !== 'string' || + accessToken.length === 0 || + typeof idToken !== 'string' || + idToken.length === 0 || + typeof refreshToken !== 'string' || + refreshToken.length === 0 + ) { + throw new Error(`${source} has an incomplete ChatGPT token bundle`) + } + return { + auth_mode: 'chatgpt', + tokens: { + access_token: accessToken, + id_token: idToken, + refresh_token: refreshToken, + }, + ...(typeof value.last_refresh === 'string' ? { last_refresh: value.last_refresh } : {}), + } +} + +async function readAuth(path: string): Promise<{ raw: string; auth: ManagedAuth }> { + const info = await lstat(path).catch((error: unknown) => { + if (object(error) && error.code === 'ENOENT') { + throw new Error(`No managed Codex login at ${path}; run "radiald codex login"`) + } + throw error + }) + if (!info.isFile() || info.isSymbolicLink()) throw new Error(`${path} must be a regular file`) + const raw = await readFile(path, 'utf8') + return { raw, auth: parseManagedCodexAuth(raw, path) } +} + +async function atomicWrite(path: string, raw: string): Promise { + const temporary = `${path}.tmp-${process.pid}` + await writeFile(temporary, raw, { mode: 0o600 }) + await chmod(temporary, 0o600) + await rename(temporary, path) + await chmod(path, 0o600) +} + +export interface CodexAuthLease { + /** Host directory mounted as the turn's otherwise-isolated CODEX_HOME. */ + directory: string + /** Token values scrubbed from live output and saved diagnostics. */ + secrets: string[] + /** Match the host owner of the 0600 scratch files on native Linux Docker bind mounts. */ + uid: number + gid: number + /** Persist a valid refresh result, discard every other scratch file, and release serialization. */ + finish(): Promise +} + +/** + * One refreshable ChatGPT login owned by a Radial instance. + * + * The master auth file is never mounted. Each turn receives only a scratch CODEX_HOME containing a + * copy plus a daemon-authored config. A promise chain serializes leases because Codex refresh-token + * rotation requires one job stream per auth copy. + */ +export class CodexAuthStore { + readonly directory: string + readonly path: string + #tail: Promise = Promise.resolve() + + constructor(directory: string) { + this.directory = directory + this.path = join(directory, AUTH_FILENAME) + } + + async validate(): Promise { + await readAuth(this.path) + await chmod(this.directory, 0o700) + await chmod(this.path, 0o600) + } + + async acquire(runDir: string): Promise { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const previous = this.#tail + this.#tail = previous.then(() => gate) + await previous + + const scratch = join(runDir, 'codex-home') + try { + const { raw, auth } = await readAuth(this.path) + await mkdir(scratch, { recursive: true, mode: 0o700 }) + await chmod(scratch, 0o700) + await writeFile(join(scratch, AUTH_FILENAME), raw, { mode: 0o600 }) + await chmod(join(scratch, AUTH_FILENAME), 0o600) + await writeFile(join(scratch, 'config.toml'), SCRATCH_CONFIG, { mode: 0o600 }) + await chmod(join(scratch, 'config.toml'), 0o600) + let finished = false + return { + directory: scratch, + secrets: [ + auth.tokens.access_token, + auth.tokens.id_token, + auth.tokens.refresh_token, + ], + uid: process.getuid?.() ?? 1000, + gid: process.getgid?.() ?? 1000, + finish: async () => { + if (finished) return + finished = true + try { + const refreshed = await readAuth(join(scratch, AUTH_FILENAME)) + // Persist only the validated auth document. Config, rollouts, caches, plugins and any + // other turn-created files die with runDir and can never influence a later turn. + await mkdir(this.directory, { recursive: true, mode: 0o700 }) + await chmod(this.directory, 0o700) + await atomicWrite(this.path, refreshed.raw) + } finally { + release() + } + }, + } + } catch (error) { + release() + throw error + } + } +} + +/** Remove credentials that could silently override the dedicated ChatGPT login. */ +export function codexLoginEnvironment(env: Environment = process.env): Record { + const clean: Record = {} + for (const [name, value] of Object.entries(env)) { + if (value === undefined) continue + if (name === 'CODEX_API_KEY' || name === 'CODEX_ACCESS_TOKEN' || name === 'OPENAI_API_KEY') continue + clean[name] = value + } + return clean +} + +function spawnInteractive(command: string, args: string[], env: Record): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { env, stdio: 'inherit' } as never) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) resolve() + else reject(new Error(`${command} ${args.join(' ')} exited ${code ?? -1}`)) + }) + }) +} + +/** + * Create a fresh login lineage for Radial. It deliberately does not copy ~/.codex/auth.json: + * another Codex surface refreshing that copy could invalidate Radial's session. + */ +export async function loginManagedCodex( + directory: string, + options: { + env?: Environment + run?: (command: string, args: string[], env: Record) => Promise + } = {}, +): Promise { + const loginDir = join(directory, 'login') + await rm(loginDir, { recursive: true, force: true }) + await mkdir(loginDir, { recursive: true, mode: 0o700 }) + await chmod(loginDir, 0o700) + const env = { ...codexLoginEnvironment(options.env), CODEX_HOME: loginDir } + try { + await (options.run ?? spawnInteractive)( + 'codex', + ['login', '--device-auth', '-c', 'cli_auth_credentials_store="file"'], + env, + ) + const { raw } = await readAuth(join(loginDir, AUTH_FILENAME)) + await mkdir(directory, { recursive: true, mode: 0o700 }) + await chmod(directory, 0o700) + await atomicWrite(join(directory, AUTH_FILENAME), raw) + } finally { + await rm(loginDir, { recursive: true, force: true }) + } +} diff --git a/packages/daemon/src/config.ts b/packages/daemon/src/config.ts index 1dd106b..c42dbe8 100644 --- a/packages/daemon/src/config.ts +++ b/packages/daemon/src/config.ts @@ -62,6 +62,12 @@ export interface DaemonRunConfig { * every entry is a credential the turn then possesses (design §13). */ modelEnv?: string[] + /** + * ChatGPT-managed authentication for Codex turns. Unlike an API key or a Codex workspace access + * token, this is a refreshable login stored under the instance data directory. The daemon gives + * each turn a scratch copy and serializes users of that login so Codex can rotate it safely. + */ + codexAuth?: CodexAuthConfig /** Turn-socket transport: `unix` (default) is the production/Linux path — a bind-mounted socket * file. `tcp` is a dev-only escape hatch for macOS/Docker Desktop, where a bind-mounted AF_UNIX * socket is visible inside the VM but `connect()` gets ECONNREFUSED. See `resolveRunConfig` in @@ -101,6 +107,10 @@ export interface DaemonRunConfig { jetstream?: JetstreamConfig } +export interface CodexAuthConfig { + mode: 'chatgpt-session' +} + /** Claim/lease timing. Defaults live in `resolveRunConfig`; the relationships between them are * checked at parse time, because an operator who sets a lease shorter than the renewal interval * gets a daemon that drops every claim it makes and no error to explain it. */ @@ -346,6 +356,16 @@ function jetstreamValue(value: unknown, where: string): JetstreamConfig | undefi return { endpoint, ...(backfillIntervalMs !== undefined ? { backfillIntervalMs } : {}) } } +function codexAuthValue(value: unknown, where: string): CodexAuthConfig | undefined { + if (value === undefined) return undefined + if (!object(value)) throw new TypeError(`${where} must be an object`) + const mode = text(value.mode, `${where}.mode`) + if (mode !== 'chatgpt-session') { + throw new TypeError(`${where}.mode must be "chatgpt-session"`) + } + return { mode } +} + /** Validates the raw `run` block; applies no runtime defaults (the daemon's run path applies those). */ export function parseRunConfig(value: unknown): DaemonRunConfig { if (!object(value)) throw new TypeError('run config must be an object') @@ -368,6 +388,7 @@ export function parseRunConfig(value: unknown): DaemonRunConfig { const mergePollBackoffMaxMs = num(value.mergePollBackoffMaxMs, 'run.mergePollBackoffMaxMs') const network = text(value.network, 'run.network') const modelEnv = envNames(value.modelEnv, 'run.modelEnv') + const codexAuth = codexAuthValue(value.codexAuth, 'run.codexAuth') const turnTransport = turnTransportValue(value.turnTransport, 'run.turnTransport') const gitSchemes = strings(value.gitSchemes, 'run.gitSchemes') const memory = text(value.memory, 'run.memory') @@ -395,6 +416,7 @@ export function parseRunConfig(value: unknown): DaemonRunConfig { ...(mergePollBackoffMaxMs !== undefined ? { mergePollBackoffMaxMs } : {}), ...(network !== undefined ? { network } : {}), ...(modelEnv !== undefined ? { modelEnv } : {}), + ...(codexAuth !== undefined ? { codexAuth } : {}), ...(turnTransport !== undefined ? { turnTransport } : {}), ...(gitSchemes !== undefined ? { gitSchemes } : {}), ...(memory !== undefined ? { memory } : {}), diff --git a/packages/daemon/src/container.ts b/packages/daemon/src/container.ts index 9438558..cee4755 100644 --- a/packages/daemon/src/container.ts +++ b/packages/daemon/src/container.ts @@ -50,6 +50,11 @@ const MAX_CAPTURED_OUTPUT = 64 * 1024 /** The non-root harness user needs a private, writable home on a read-only root filesystem. */ export const RADIAL_HOME_TMPFS = '/home/radial:uid=1000,gid=1000,mode=700' +/** A managed-auth turn runs as the daemon's host uid/gid so its 0600 scratch auth file is usable. */ +export function radialHomeTmpfs(uid: number, gid: number): string { + return `/home/radial:uid=${uid},gid=${gid},mode=700` +} + /** Keep the tail: command failures conventionally print their useful diagnosis last. */ function appendCapturedOutput(existing: string, chunk: Uint8Array, decoder: TextDecoder): string { const combined = existing + decoder.decode(chunk) diff --git a/packages/daemon/src/dispatch.ts b/packages/daemon/src/dispatch.ts index 0282242..66bc7ff 100644 --- a/packages/daemon/src/dispatch.ts +++ b/packages/daemon/src/dispatch.ts @@ -551,6 +551,8 @@ export interface DispatcherDeps { instance?: string /** Injected wrapper around turn.ts's runTurn that binds TurnDeps (runner/harness/secrets/etc). */ runTurn: (input: TurnInput) => Promise + /** Harnesses whose turns share mutable credentials and therefore must never overlap. */ + serializedHarnesses?: readonly string[] /** The request URIs whose claim this daemon has confirmed, read fresh on every pump — supplied by * `ClaimManager.heldClaims`. Absent means "no claim manager", and unassigned requests are then * never dispatched (see `selectDispatchable`'s `heldClaims`). */ @@ -599,6 +601,9 @@ export class TurnDispatcher { readonly #activeBranches = new Map() /** Requests skipped for branch contention, so the log says it once rather than every pump. */ readonly #contended = new Set() + /** Requests waiting for a serialized harness, logged once rather than on every pump. */ + readonly #serializedContended = new Set() + readonly #activeSerializedHarnesses = new Set() constructor(deps: DispatcherDeps) { this.#deps = deps @@ -712,6 +717,20 @@ export class TurnDispatcher { continue } + const serializedHarness = this.#deps.serializedHarnesses?.includes(item.actor.harness) + ? item.actor.harness + : undefined + if (serializedHarness && this.#activeSerializedHarnesses.has(serializedHarness)) { + if (!this.#serializedContended.has(uri)) { + this.#serializedContended.add(uri) + this.#deps.log?.( + `not dispatching ${uri}: harness ${serializedHarness} uses a shared refreshable login; retrying after its active turn finishes`, + ) + } + continue + } + this.#serializedContended.delete(uri) + // Contention, not an error: skip and retry on a later pump, leaving the ledger row untouched // (this runs BEFORE markRunning, so the request stays plainly eligible). const contested = @@ -733,12 +752,14 @@ export class TurnDispatcher { this.#deps.log?.(`dispatching turn ${uri} (label ${label})`) if (contested) this.#activeBranches.set(contested, uri) + if (serializedHarness) this.#activeSerializedHarnesses.add(serializedHarness) const promise = this.#launch(item, { anchor, runDir, label }) .finally(() => { this.#inFlight.delete(uri) // A relaunch (a lease reclaimed after the winner crashed) must be abandonable again. this.#abandoned.delete(uri) if (contested && this.#activeBranches.get(contested) === uri) this.#activeBranches.delete(contested) + if (serializedHarness) this.#activeSerializedHarnesses.delete(serializedHarness) }) this.#inFlight.set(uri, { promise, label }) } diff --git a/packages/daemon/src/harness.ts b/packages/daemon/src/harness.ts index 9be4770..94eb33a 100644 --- a/packages/daemon/src/harness.ts +++ b/packages/daemon/src/harness.ts @@ -591,9 +591,11 @@ export class CodexHarness implements Harness { /** * The only two variables that authenticate a `codex exec` run, in codex's own resolution order: * `CODEX_API_KEY` wins over everything, then the ephemeral store, then `CODEX_ACCESS_TOKEN` (a - * ChatGPT/agent-identity token), then a persisted `auth.json` a tmpfs `$HOME` never has + * Business/Enterprise Codex access token), then a persisted `auth.json` * (`codex-rs/login/src/auth/manager.rs::load_auth`, reached with `enable_codex_api_key_env: true` - * from `exec/src/lib.rs`). + * from `exec/src/lib.rs`). The ordinary path has no auth file; explicit + * `run.codexAuth.mode = "chatgpt-session"` mounts a per-turn scratch CODEX_HOME whose refreshed + * auth file is validated and persisted daemon-side. * * `OPENAI_API_KEY` is deliberately NOT here. It no longer authenticates this path: `load_auth` * never calls `read_openai_api_key_from_env` (only the interactive login flow does), and the diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index 6617385..50ddcf8 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -7,6 +7,7 @@ export * from './check-ledger.js' export * from './check-runner.js' export * from './claim-ledger.js' export * from './claims.js' +export * from './codex-auth.js' export * from './config.js' export * from './container.js' export * from './dispatch.js' diff --git a/packages/daemon/src/node-shims.d.ts b/packages/daemon/src/node-shims.d.ts index 29dbd58..703a2c8 100644 --- a/packages/daemon/src/node-shims.d.ts +++ b/packages/daemon/src/node-shims.d.ts @@ -58,6 +58,8 @@ declare const process: { env: Record exitCode?: number pid: number + getuid?: () => number + getgid?: () => number kill(pid: number, signal: number): void on(event: string, listener: (...args: unknown[]) => void): void } diff --git a/packages/daemon/src/turn.ts b/packages/daemon/src/turn.ts index 81e4fba..e6916e1 100644 --- a/packages/daemon/src/turn.ts +++ b/packages/daemon/src/turn.ts @@ -11,7 +11,8 @@ import { } from '@radial/core' import type { LoadedActor } from './actors.js' import { checkoutCommit, checkoutRepo, MERGE_CLONE_DEPTH, writeBundle } from './bundle-writer.js' -import { RADIAL_HOME_TMPFS, type ContainerOutputStream, type ContainerRunner, type ContainerSpec } from './container.js' +import type { CodexAuthLease, CodexAuthStore } from './codex-auth.js' +import { RADIAL_HOME_TMPFS, radialHomeTmpfs, type ContainerOutputStream, type ContainerRunner, type ContainerSpec } from './container.js' import type { ForgeRegistry, TurnForgeGrant } from './forge.js' import { composeBrief, type Harness, type HarnessOutput } from './harness.js' import { TurnSocketServer, implArtifactRkey, planArtifactRkey, type TurnMode, type TurnObservation, type TurnRequestAnchor, type TurnRequestContext } from './turn-socket.js' @@ -205,6 +206,8 @@ export interface TurnDeps { * streamed output and diagnostic logs; see `modelEnvSecrets`. */ modelEnv?: Record + /** Dedicated refreshable ChatGPT login, used only by turns whose selected harness is Codex. */ + codexAuth?: CodexAuthStore githubToken?: string /** The forges this daemon speaks. The adapter matching the project's `gitUrl` supplies the turn's * push credentials (`turnEnvironment`) and, where the container cannot open a pull request @@ -385,6 +388,7 @@ export async function runTurn(input: TurnInput, deps: TurnDeps): Promise = isImpl @@ -625,6 +633,7 @@ export async function runTurn(input: TurnInput, deps: TurnDeps): Promise { @@ -727,6 +751,11 @@ export async function runTurn(input: TurnInput, deps: TurnDeps): Promise { + deps.log?.( + `failed to persist managed Codex auth: ${error instanceof Error ? error.message : String(error)}`, + ) + }) // Every turn runDir is ephemeral; log (never swallow) a cleanup failure so a // leaked directory that would break the next attempt's clone is at least visible. await rm(input.runDir, { recursive: true, force: true }).catch((error: unknown) => { diff --git a/packages/daemon/test/cli.test.mjs b/packages/daemon/test/cli.test.mjs index 1e9c51d..28ca2fe 100644 --- a/packages/daemon/test/cli.test.mjs +++ b/packages/daemon/test/cli.test.mjs @@ -453,6 +453,15 @@ it('resolveRunConfig defaults run.modelEnv to an empty list', () => { assert.deepEqual(resolveRunConfig({ spaces: [SPACE], modelEnv: ['GROQ_API_KEY'] }).modelEnv, ['GROQ_API_KEY']) }) +it('resolveRunConfig preserves explicit managed Codex auth', () => { + const SPACE = 'at://did:plc:human/com.disnetdev.radial.space/space1' + assert.deepEqual( + resolveRunConfig({ spaces: [SPACE], codexAuth: { mode: 'chatgpt-session' } }).codexAuth, + { mode: 'chatgpt-session' }, + ) + assert.equal(resolveRunConfig({ spaces: [SPACE] }).codexAuth, undefined) +}) + it('modelEnvNames unions what the loaded profiles need, without repeating a shared name', () => { const names = modelEnvNames( [{ harness: 'claude' }, { harness: 'pi' }, { harness: 'pi' }, { harness: 'codex' }], @@ -515,6 +524,34 @@ it('resolveModelEnvironment accepts a codex profile on any one of the credential ) }) +it('managed Codex auth replaces environment credentials and rejects ambiguous precedence', () => { + const actors = [{ profile: 'impl', harness: 'codex' }] + assert.deepEqual( + resolveModelEnvironment(actors, [], {}, { managedCodexAuth: true }), + {}, + ) + assert.throws( + () => + resolveModelEnvironment( + actors, + [], + { CODEX_API_KEY: 'api-key' }, + { managedCodexAuth: true }, + ), + /CODEX_API_KEY is also set.*Unset it/, + ) + assert.throws( + () => + resolveModelEnvironment( + [{ profile: 'planner', harness: 'claude' }], + [], + { ANTHROPIC_API_KEY: 'key' }, + { managedCodexAuth: true }, + ), + /no loaded profile uses harness "codex"/, + ) +}) + it('resolveModelEnvironment refuses a profile whose harness has no credential available', () => { assert.throws( () => resolveModelEnvironment([{ profile: 'planner', harness: 'claude' }], [], { OPENAI_API_KEY: 'sk-openai' }), diff --git a/packages/daemon/test/codex-auth.test.mjs b/packages/daemon/test/codex-auth.test.mjs new file mode 100644 index 0000000..e57a8f9 --- /dev/null +++ b/packages/daemon/test/codex-auth.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { it } from 'node:test' +import { + CodexAuthStore, + codexLoginEnvironment, + loginManagedCodex, + parseManagedCodexAuth, +} from '../dist/index.js' + +const auth = (suffix = 'one') => ({ + auth_mode: 'chatgpt', + tokens: { + access_token: `access-${suffix}`, + id_token: `id-${suffix}`, + refresh_token: `refresh-${suffix}`, + }, + last_refresh: '2026-07-29T00:00:00Z', +}) + +it('managed auth validation rejects non-ChatGPT and incomplete token files', () => { + assert.equal(parseManagedCodexAuth(JSON.stringify(auth())).auth_mode, 'chatgpt') + assert.throws( + () => parseManagedCodexAuth(JSON.stringify({ auth_mode: 'apikey', tokens: auth().tokens })), + /not a ChatGPT-managed Codex login/, + ) + assert.throws( + () => parseManagedCodexAuth(JSON.stringify({ auth_mode: 'chatgpt', tokens: {} })), + /incomplete ChatGPT token bundle/, + ) +}) + +it('a managed auth store serializes turns and persists only refreshed auth', async () => { + const root = await mkdtemp(join(tmpdir(), 'radial-codex-auth-')) + const storeDir = join(root, 'store') + await mkdir(storeDir) + await writeFile(join(storeDir, 'auth.json'), JSON.stringify(auth())) + const store = new CodexAuthStore(storeDir) + try { + await store.validate() + const first = await store.acquire(join(root, 'turn-one')) + let secondAcquired = false + const secondPromise = store.acquire(join(root, 'turn-two')).then((lease) => { + secondAcquired = true + return lease + }) + await new Promise((resolve) => setTimeout(resolve, 10)) + assert.equal(secondAcquired, false) + + await writeFile(join(first.directory, 'auth.json'), JSON.stringify(auth('refreshed'))) + await writeFile(join(first.directory, 'untrusted-config.toml'), 'model = "attacker/model"') + await first.finish() + + const second = await secondPromise + assert.equal(secondAcquired, true) + assert.equal( + JSON.parse(await readFile(join(second.directory, 'auth.json'), 'utf8')).tokens.refresh_token, + 'refresh-refreshed', + ) + await assert.rejects(readFile(join(second.directory, 'untrusted-config.toml'), 'utf8'), /ENOENT/) + await second.finish() + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +it('dedicated login strips competing credentials and imports only the new auth file', async () => { + const root = await mkdtemp(join(tmpdir(), 'radial-codex-login-')) + try { + const seen = {} + await loginManagedCodex(root, { + env: { + PATH: '/bin', + CODEX_API_KEY: 'api-secret', + CODEX_ACCESS_TOKEN: 'access-secret', + OPENAI_API_KEY: 'openai-secret', + }, + run: async (command, args, env) => { + seen.command = command + seen.args = args + seen.env = env + await writeFile(join(env.CODEX_HOME, 'auth.json'), JSON.stringify(auth('login'))) + await writeFile(join(env.CODEX_HOME, 'rollout.jsonl'), 'must not persist') + }, + }) + assert.equal(seen.command, 'codex') + assert.ok(seen.args.includes('--device-auth')) + assert.equal(seen.env.CODEX_API_KEY, undefined) + assert.equal(seen.env.CODEX_ACCESS_TOKEN, undefined) + assert.equal(seen.env.OPENAI_API_KEY, undefined) + assert.equal( + JSON.parse(await readFile(join(root, 'auth.json'), 'utf8')).tokens.refresh_token, + 'refresh-login', + ) + await assert.rejects(readFile(join(root, 'rollout.jsonl'), 'utf8'), /ENOENT/) + assert.deepEqual(codexLoginEnvironment({ PATH: '/bin', CODEX_API_KEY: 'x' }), { PATH: '/bin' }) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/packages/daemon/test/config.test.mjs b/packages/daemon/test/config.test.mjs index c7cf2e6..d729267 100644 --- a/packages/daemon/test/config.test.mjs +++ b/packages/daemon/test/config.test.mjs @@ -266,6 +266,23 @@ it('parseRunConfig accepts run.modelEnv names and refuses anything that is not o assert.throws(() => parseRunConfig({ spaces: [SPACE], modelEnv: ['1BAD'] }), /environment variable names/) }) +it('parseRunConfig accepts only the explicit managed Codex auth mode', () => { + const SPACE = 'at://did:plc:human/com.disnetdev.radial.space/space1' + assert.deepEqual( + parseRunConfig({ spaces: [SPACE], codexAuth: { mode: 'chatgpt-session' } }).codexAuth, + { mode: 'chatgpt-session' }, + ) + assert.equal('codexAuth' in parseRunConfig({ spaces: [SPACE] }), false) + assert.throws( + () => parseRunConfig({ spaces: [SPACE], codexAuth: 'chatgpt-session' }), + /run\.codexAuth must be an object/, + ) + assert.throws( + () => parseRunConfig({ spaces: [SPACE], codexAuth: { mode: 'auto' } }), + /run\.codexAuth\.mode must be "chatgpt-session"/, + ) +}) + it('parseRunConfig leaves run.memory unset when not configured (no layer-injected default)', () => { const parsed = parseRunConfig({ spaces: ['at://did:plc:human/com.disnetdev.radial.space/space1'] }) assert.equal('memory' in parsed, false) diff --git a/packages/daemon/test/dispatch.test.mjs b/packages/daemon/test/dispatch.test.mjs index 73643a5..08ed37d 100644 --- a/packages/daemon/test/dispatch.test.mjs +++ b/packages/daemon/test/dispatch.test.mjs @@ -2346,6 +2346,61 @@ it('serializes two v2 requests that resolve the same predecessor branch', async ledger.close() }) +it('does not launch overlapping turns for a harness with one refreshable login', async () => { + const base = buildScenario() + const second = mk(HUMAN, COLLECTIONS.artifactRequest, 'request-2', 'cid-request-2', { + $type: COLLECTIONS.artifactRequest, + goal: ref(base.goal), + type: 'plan', + basedOn: [], + assignee: AGENT, + createdAt: '2026-01-01T00:03:01Z', + }) + const { records, request } = buildScenario({ extra: [second] }) + const index = materialize(store(records), { spaceUri: SPACE_URI }) + const actors = registryFor([actorFor(AGENT, ['plan'], [], 'codex')]) + const ledger = new TurnLedger() + let release + const gate = new Promise((resolve) => { + release = resolve + }) + const started = [] + const logs = [] + const dispatcher = new TurnDispatcher({ + ledger, + concurrency: 2, + serializedHarnesses: ['codex'], + runDirFor: (uri) => `/tmp/${uri.slice(-8)}`, + image: 'radial-turn:test', + timeoutMs: 30_000, + allowedSchemes: ['https'], + log: (message) => logs.push(message), + runTurn: async (input) => { + started.push(input.request.uri) + await gate + return { + outcome: 'fulfilled', + acceptedRef: { uri: `at://did:plc:agent/artifact/${started.length}`, cid: `cid-${started.length}` }, + label: 'radial.turn.codex', + } + }, + }) + + dispatcher.pump(index, actors) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(started.length, 1) + assert.ok(logs.some((entry) => entry.includes('shared refreshable login')), logs.join(' | ')) + const waiting = [request.uri, second.uri].find((uri) => uri !== started[0]) + assert.equal(ledger.get(waiting), undefined) + + release() + await dispatcher.drain() + dispatcher.pump(index, actors) + await dispatcher.drain() + assert.equal(started.length, 2) + ledger.close() +}) + // --- per-project forge selection -------------------------------------------- diff --git a/packages/daemon/test/turn.test.mjs b/packages/daemon/test/turn.test.mjs index ef028b8..107cdca 100644 --- a/packages/daemon/test/turn.test.mjs +++ b/packages/daemon/test/turn.test.mjs @@ -9,6 +9,7 @@ import { COLLECTIONS } from '../../core/dist/index.js' import { LocalPds } from '../../atproto/test/local-pds.mjs' import { ANSWER_TYPE, + CodexAuthStore, FakeContainerRunner, ForgeRegistry, GitHubForge, @@ -1235,6 +1236,64 @@ it('a codex profile launches codex exec, and the launch line names the harness i }) }) +it('a managed-auth codex turn mounts a scratch home, redacts tokens, and writes refreshes back', async () => { + const { actor } = await makeActor('did:plc:agent-codex-managed', [], 'codex') + const root = await mkdtemp(join(tmpdir(), 'radial-codex-turn-')) + const authDir = join(root, 'auth') + await mkdir(authDir) + await writeFile( + join(authDir, 'auth.json'), + JSON.stringify({ + auth_mode: 'chatgpt', + tokens: { + access_token: 'managed-access-secret', + id_token: 'managed-id-secret', + refresh_token: 'managed-refresh-secret', + }, + }), + ) + try { + const messages = [] + let launched + const runner = new FakeContainerRunner(async (spec) => { + launched = spec + const mount = spec.mounts.find((entry) => entry.target === '/run/radial-codex') + assert.ok(mount) + spec.onOutput?.('stderr', 'managed-refresh-secret\n') + await writeFile( + join(mount.source, 'auth.json'), + JSON.stringify({ + auth_mode: 'chatgpt', + tokens: { + access_token: 'refreshed-access', + id_token: 'refreshed-id', + refresh_token: 'refreshed-refresh', + }, + }), + ) + return { exitCode: 0, timedOut: false } + }) + await runTurn(baseInput(join(root, 'turn'), { actor }), { + runner, + harnesses: HARNESSES_FOR_TEST, + checkout: noopCheckout, + codexAuth: new CodexAuthStore(authDir), + log: (message) => messages.push(message), + }) + assert.equal(launched.env.CODEX_HOME, '/run/radial-codex') + assert.equal(launched.env.CODEX_API_KEY, undefined) + assert.equal(launched.user, `${process.getuid?.() ?? 1000}:${process.getgid?.() ?? 1000}`) + assert.ok(messages.some((message) => /agent .* stderr: \[redacted\]/.test(message))) + assert.ok(messages.every((message) => !message.includes('managed-refresh-secret'))) + assert.equal( + JSON.parse(await readFile(join(authDir, 'auth.json'), 'utf8')).tokens.refresh_token, + 'refreshed-refresh', + ) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + it('three profiles with different harnesses run their own CLI in one daemon', async () => { // The whole point of per-profile harnesses (and of the reviewer-diversity case in design §11): // one daemon process, one identity, three harnesses. -- 2.51.2