diff --git a/README.md b/README.md index e1f3c76..bf9a047 100644 --- a/README.md +++ b/README.md @@ -618,9 +618,32 @@ into the profile when the flag is absent. and `env` round-trip through the materialised config file unchanged (verified end-to-end in [`tests/mcp-passthrough.test.ts`](./tests/mcp-passthrough.test.ts)). -New recorded Codex sessions retain native rollouts and indexes under `BRIDGE_DATA_DIR/codex/`, including sessions created without MCP. +New host Codex sessions retain native rollouts and indexes under `BRIDGE_DATA_DIR/codex/`, including sessions created without MCP. The existing session execution lease serializes turns using that home. -Each turn refreshes authentication and MCP configuration, including changed Runtime attachment endpoints, and removes those two files when execution ends. +The Codex backend serializes selected account preparation and refresh on one bridge process; access-only jailed turns can run concurrently. +Unconfined ChatGPT turns hold that account lease until their native turn ends because the CLI may refresh during inference. +Host turns link `auth.json` to that home, so Codex writes a refreshed token to the persistent account file. +ChatGPT subscription turns in an enforced `fs-jail` receive only their selected account's access credential. +This subscription isolation requires Linux bubblewrap read confinement; the bridge rejects macOS jailed subscription turns. +The bridge refuses a ChatGPT subscription turn in `write-jail` because that mode can read the host refresh token. +An unavailable jail cannot fall back to an unconfined subscription turn, even with `BRIDGE_JAIL_FALLBACK=warn`. +Recognized non-ChatGPT file auth modes retain their existing credential behavior; unrecognized files fail closed. +Env-authenticated launches can materialize MCP config without a local `auth.json`. +For Codex exec, a nonempty `CODEX_API_KEY` or `CODEX_ACCESS_TOKEN` takes precedence over persisted `auth.json`. +The bridge preserves that effective credential in a jail and does not seed an unrelated subscription account. +Docker Codex turns keep the container's mounted account home and receive only the declared MCP settings through a private per-turn config file. +The bridge removes ambient MCP servers even when a turn declares none, then restores the original config after the turn. +It refuses base config MCP syntax it cannot safely remove. +Docker and jailed Codex turns omit saved project trust from their temporary config, so project `.codex/` config, hooks, and rules cannot add tools outside the request. +Per-slot Docker Codex sessions use a stable slot chosen from the external session ID while the pool size stays fixed. +A busy slot queues the turn until its acquire deadline, even when another slot is free. +Changing the pool size can remap existing sessions; migrate or finish them before resizing. +Per-slot Docker turns do not inherit the bridge process's ambient Codex credentials. +Docker turns sharing one account home run one at a time while their private MCP config is installed. +Before a jailed turn whose deadline extends past access-token expiry, the bridge asks the installed Codex app-server to refresh the persistent account, then checks the selected account and token lifetime. +The jail receives the current per-turn MCP configuration, including changed Runtime attachment endpoints. +When a jailed turn declares no MCP servers, its account settings are retained without ambient MCP servers. +The bridge removes its temporary auth link and MCP configuration after the turn. One-shot MCP homes are removed entirely. Native session files remain with the bridge data directory; deleting a session mapping does not erase its transcripts. Previously deleted temporary rollouts cannot be recovered by this change. diff --git a/src/backends/codex-auth.ts b/src/backends/codex-auth.ts new file mode 100644 index 0000000..98fa9cc --- /dev/null +++ b/src/backends/codex-auth.ts @@ -0,0 +1,244 @@ +import { existsSync, lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { createInterface } from 'node:readline' +import { hostSpawner } from '../executors/host.js' +import { terminateSpawned } from '../executors/process-tree.js' +import { BackendError } from './types.js' +import { scopeCodexTurnConfig } from '../codex-config.js' + +interface FileAuth { + auth_mode?: unknown + tokens?: { + id_token?: unknown + account_id?: unknown + access_token?: unknown + refresh_token?: unknown + } + last_refresh?: unknown + [key: string]: unknown +} + +const OTHER_FILE_AUTH_MODES = new Set([ + 'apikey', 'agentIdentity', 'personalAccessToken', 'bedrockApiKey', 'bedrockAccessKeys', 'headers', +]) + +function authFileExists(path: string): boolean { + try { lstatSync(path); return true } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false + throw new BackendError('Codex account auth.json cannot be inspected', 'not_configured') + } +} + +function readFileAuth(path: string): FileAuth { + let auth: FileAuth + try { auth = JSON.parse(readFileSync(path, 'utf8')) as FileAuth } catch { + throw new BackendError('Codex account auth.json is missing or invalid', 'not_configured') + } + if (!auth || typeof auth !== 'object' || Array.isArray(auth)) { + throw new BackendError('Codex account auth.json is invalid', 'not_configured') + } + return auth +} + +/** Only an unconfined file-backed ChatGPT turn can rotate this host account during inference. */ +export function isCodexSubscriptionAuth(path: string | undefined): boolean { + if (!path || !authFileExists(path)) return false + return classifyFileAuth(readFileAuth(path)) === 'subscription' +} + +/** Codex exec selects these environment credentials before persisted auth.json. */ +export function hasCodexEnvAuth(env: NodeJS.ProcessEnv): boolean { + return Boolean(env.CODEX_API_KEY?.trim() || env.CODEX_ACCESS_TOKEN?.trim()) +} + +function classifyFileAuth(auth: FileAuth): 'subscription' | 'external-access' | 'other' { + const mode = auth.auth_mode ?? ( + auth.personal_access_token != null ? 'personalAccessToken' + : auth.bedrock_api_key != null ? 'bedrockApiKey' + : auth.bedrock_access_keys != null ? 'bedrockAccessKeys' + : auth.OPENAI_API_KEY != null ? 'apikey' : 'chatgpt' + ) + if (mode === 'chatgpt') return 'subscription' + if (mode === 'chatgptAuthTokens') { + if (typeof auth.tokens?.id_token !== 'string' || typeof auth.tokens.access_token !== 'string' + || typeof auth.tokens.account_id !== 'string' + || (auth.tokens.refresh_token !== undefined && auth.tokens.refresh_token !== '') + || typeof auth.last_refresh !== 'string' || !Number.isFinite(Date.parse(auth.last_refresh))) { + throw new BackendError('Externally managed Codex auth must contain access-only tokens', 'not_configured') + } + return 'external-access' + } + if (auth.tokens != null) { + if (typeof auth.tokens !== 'object' || Array.isArray(auth.tokens) || 'refresh_token' in auth.tokens) { + throw new BackendError('Codex auth mode cannot expose token data to the jail', 'not_configured') + } + } + if (typeof mode === 'string' && OTHER_FILE_AUTH_MODES.has(mode)) return 'other' + throw new BackendError('Codex account auth mode is unrecognized', 'not_configured') +} + +function accessExpiry(token: unknown): number { + if (typeof token !== 'string') return NaN + try { + const payload = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString()) as { exp?: unknown } + return typeof payload.exp === 'number' ? payload.exp * 1000 : NaN + } catch { + return NaN + } +} + +/** Ask the installed Codex app-server to refresh its own persistent account. */ +async function refreshAccountWithCodex(bin: string, authPath: string, signal: AbortSignal): Promise { + const home = dirname(authPath) + const env: NodeJS.ProcessEnv = { CODEX_HOME: home } + for (const key of [ + 'HOME', 'USER', 'LOGNAME', 'PATH', 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS', + 'SSL_CERT_FILE', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', + ]) { + if (process.env[key]) env[key] = process.env[key] + } + const bounded = AbortSignal.any([signal, AbortSignal.timeout(30_000)]) + const spawned = await hostSpawner(bin, ['-c', 'cli_auth_credentials_store="file"', 'app-server', '--stdio'], { + cwd: home, + env, + signal: bounded, + stdio: ['pipe', 'pipe', 'pipe'], + envPassthroughKeys: ['SSL_CERT_FILE', 'HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY'], + }) + const child = spawned.child + child.stderr?.on('data', () => { /* Drain diagnostics without logging credentials. */ }) + const onAbort = (): void => { void terminateSpawned(spawned) } + bounded.addEventListener('abort', onAbort, { once: true }) + try { + if (!child.stdin || !child.stdout) throw new Error('Codex account RPC has no stdio') + const lines = createInterface({ input: child.stdout })[Symbol.asyncIterator]() + const send = (message: Record): void => { + child.stdin!.write(`${JSON.stringify(message)}\n`) + } + const response = async (id: number): Promise> => { + for (;;) { + bounded.throwIfAborted() + let onReadAbort: (() => void) | undefined + const aborted = new Promise((_, reject) => { + onReadAbort = () => reject(bounded.reason) + bounded.addEventListener('abort', onReadAbort, { once: true }) + if (bounded.aborted) onReadAbort() + }) + let next: Awaited> + try { next = await Promise.race([lines.next(), aborted]) } finally { + if (onReadAbort) bounded.removeEventListener('abort', onReadAbort) + } + if (next.done) throw new Error('Codex account RPC closed early') + let value: Record + try { value = JSON.parse(next.value) as Record } catch { continue } + if (value.id === id) return value + } + } + send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'cli-bridge', version: '0.3.0' } } }) + if (!('result' in await response(1))) throw new Error('Codex account RPC initialization failed') + send({ method: 'initialized', params: {} }) + send({ id: 2, method: 'account/read', params: { refreshToken: true } }) + if (!('result' in await response(2))) throw new Error('Codex account RPC refresh failed') + } catch { + throw new BackendError('Codex account refresh did not complete', signal.aborted ? 'aborted' : 'upstream') + } finally { + bounded.removeEventListener('abort', onAbort) + const termination = await terminateSpawned(spawned) + spawned.release() + if (termination !== 'stopped') { + throw new BackendError('Codex account refresh process termination is unconfirmed', 'upstream') + } + } +} + +/** + * A jailed Codex receives an access-only credential. The refresh capability + * stays in the trusted account home, outside the agent-writable jail. + */ +export async function prepareCodexJailAuth( + bin: string, + authPath: string | undefined, + configPath: string | undefined, + turnTimeoutMs: number, + signal: AbortSignal, + readConfine: boolean, + configAlreadyScoped: boolean, +): Promise<{ homePath: string; subscription: boolean; cleanup(): void }> { + // API-key and Router launches may authenticate entirely from the child env. + // Their selected home need not contain an auth.json file. + if (!authPath || !authFileExists(authPath)) return writeJailHome(null, configPath, false, configAlreadyScoped) + const auth = readFileAuth(authPath) + const accountId = auth.tokens?.account_id + const mode = classifyFileAuth(auth) + if (mode === 'subscription') { + if (process.platform !== 'linux' || !readConfine) { + throw new BackendError('Jailed Codex subscription turns require Linux fs-jail read confinement', 'not_configured') + } + if (!Number.isSafeInteger(turnTimeoutMs) || turnTimeoutMs < 1 || turnTimeoutMs > 4 * 60 * 60 * 1000) { + throw new BackendError('Jailed Codex subscription turns require a bounded deadline', 'not_configured') + } + if (typeof accountId !== 'string' || !accountId || typeof auth.tokens?.refresh_token !== 'string' + || !auth.tokens.refresh_token) { + throw new BackendError('Codex subscription account has no refreshable auth', 'not_configured') + } + // Native Codex proactively refreshes five minutes before expiry. + const requiredUntil = Date.now() + turnTimeoutMs + 6 * 60_000 + if (!(accessExpiry(auth.tokens.access_token) > requiredUntil)) { + await refreshAccountWithCodex(bin, authPath, signal) + } + const current = readFileAuth(authPath) + if (classifyFileAuth(current) !== 'subscription' || current.tokens?.account_id !== accountId + || !(accessExpiry(current.tokens?.access_token) > requiredUntil)) { + throw new BackendError('Codex account access token will expire during the jailed turn', 'upstream') + } + if (typeof current.tokens?.id_token !== 'string' || typeof current.tokens.access_token !== 'string' + || typeof current.last_refresh !== 'string' || !Number.isFinite(Date.parse(current.last_refresh))) { + throw new BackendError('Codex subscription account is missing access credentials', 'not_configured') + } + return writeJailHome({ + auth_mode: 'chatgpt', + tokens: { + id_token: current.tokens.id_token, + access_token: current.tokens.access_token, + account_id: accountId, + refresh_token: '', + }, + last_refresh: current.last_refresh, + }, configPath, true, configAlreadyScoped) + } + if (mode === 'external-access') { + return writeJailHome({ + auth_mode: 'chatgptAuthTokens', + tokens: { + id_token: auth.tokens!.id_token, + access_token: auth.tokens!.access_token, + account_id: auth.tokens!.account_id, + refresh_token: '', + }, + last_refresh: auth.last_refresh, + }, configPath, false, configAlreadyScoped) + } + return writeJailHome(auth, configPath, false, configAlreadyScoped) +} + +function writeJailHome(auth: FileAuth | null, configPath: string | undefined, subscription: boolean, configAlreadyScoped: boolean): { + homePath: string + subscription: boolean + cleanup(): void +} { + const homePath = mkdtempSync(join(tmpdir(), 'cli-bridge-codex-jail-')) + try { + if (auth) writeFileSync(join(homePath, 'auth.json'), JSON.stringify(auth), { mode: 0o600 }) + const config = configPath && existsSync(configPath) ? readFileSync(configPath, 'utf8') : '\n' + writeFileSync(join(homePath, 'config.toml'), configAlreadyScoped ? config : scopeCodexTurnConfig(config, ''), { mode: 0o600 }) + return { + homePath, + subscription, + cleanup: () => rmSync(homePath, { recursive: true, force: true }), + } + } catch (error) { + rmSync(homePath, { recursive: true, force: true }) + throw error + } +} diff --git a/src/backends/codex.ts b/src/backends/codex.ts index 150f9d3..dd30e2c 100644 --- a/src/backends/codex.ts +++ b/src/backends/codex.ts @@ -25,7 +25,7 @@ * again, adjust `extractText` below rather than the whole pipeline. */ -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { createHash } from 'node:crypto' import { existsSync } from 'node:fs' import { ensurePrivateDataDirectory } from '../runtime/single-instance.js' @@ -35,6 +35,7 @@ import { BackendError, BRIDGE_RESERVED_FAILURE_CODES, terminalOutcome } from './ import { assertModeSupported } from '../modes.js' import type { SessionRecord } from '../sessions/store.js' import { + codexMcpConfigText, materializeMcpServersForCodex, profileExecutionIdentity, provisionProfileWorkspace, @@ -49,6 +50,7 @@ import { describeCliExit, resolveSpawnerCwd, type Spawner } from '../executors/t import { readProcessLines, waitForProcessClose } from './process-lines.js' import { BoundedDiagnosticBuffer } from './diagnostic-buffer.js' import { terminateSpawned } from '../executors/process-tree.js' +import { hasCodexEnvAuth, isCodexSubscriptionAuth, prepareCodexJailAuth } from './codex-auth.js' import { nativeReasoningControl } from '@tangle-network/agent-interface' import { assertRouterCodexExecution, @@ -71,6 +73,7 @@ export class CodexBackend implements Backend { readonly name = 'codex' readonly defaultExecutionTimeoutMs: number private readonly spawner: Spawner + private authPreparationTail: Promise = Promise.resolve() constructor(private readonly opts: CodexBackendOptions) { this.defaultExecutionTimeoutMs = opts.timeoutMs this.spawner = opts.spawner ?? scopedHostSpawner @@ -89,6 +92,14 @@ export class CodexBackend implements Backend { req: ChatRequest, session: SessionRecord | null, signal: AbortSignal, + ): AsyncIterable { + yield* this.chatOwned(req, session, signal) + } + + private async *chatOwned( + req: ChatRequest, + session: SessionRecord | null, + signal: AbortSignal, ): AsyncIterable { const cwd = resolveSpawnerCwd(this.spawner, req.cwd ?? session?.cwd ?? undefined) // Codex has `--sandbox` flags but we haven't verified end-to-end that @@ -158,6 +169,7 @@ export class CodexBackend implements Backend { cwd, profileExecutionIdentity(req, session, 'codex', reasoningEffort), ) + const launchEnv = { ...process.env, ...provisioned.env } args.push(...provisioned.flags) let routerVersion: string | undefined if (router) { @@ -182,11 +194,15 @@ export class CodexBackend implements Backend { } } - // Session leases own this directory's read/execute/update interval. - // Keep native state across turns, but regenerate MCP config and auth each time. + // Session leases own native state; the account turn lease owns auth rotation. + // Keep native state across turns, but regenerate MCP config and auth links. const mcpServers = resolveMcpServers(req, session) + const dockerExecution = this.spawner.executionEnvironment === 'docker' + // The container's mounted home owns per-slot credentials and native state. + // MCP settings travel through stdin into that private home, never argv. + const dockerCodexConfig = dockerExecution ? codexMcpConfigText(mcpServers) ?? '' : undefined const externalId = req.session_id ?? session?.externalId - const nativeHome = this.opts.stateDir && externalId + const nativeHome = !dockerExecution && this.opts.stateDir && externalId ? join(this.opts.stateDir, createHash('sha256').update(externalId).digest('hex')) : undefined const legacySession = nativeHome && session?.internalId && !existsSync(nativeHome) @@ -197,27 +213,66 @@ export class CodexBackend implements Backend { 'parse_error', ) } - const codexHome = materializeMcpServersForCodex( - mcpServers, - resolveCodexAuthPath(), - nativeHome && !legacySession - ? ensurePrivateDataDirectory(nativeHome) - : undefined, - ) + const authSourcePath = resolveCodexAuthPath() + // Only an unconfined file-backed ChatGPT turn can rotate the shared host + // credential during inference. Use the effective launch env after profile + // materialization; Codex exec prefers env API keys and access tokens. + const hostSubscription = !req.jailSpec + && !dockerExecution + && !router + && !hasCodexEnvAuth(launchEnv) + && isCodexSubscriptionAuth(authSourcePath) + const releaseHostAuth = hostSubscription ? await this.acquireAuthPreparation(signal) : undefined + let codexHome: ReturnType + try { + codexHome = dockerExecution ? null : materializeMcpServersForCodex( + mcpServers, + authSourcePath, + nativeHome && !legacySession + ? ensurePrivateDataDirectory(nativeHome) + : undefined, + ) + } catch (error) { + releaseHostAuth?.() + throw error + } + let jailHome: Awaited> | undefined + try { + if (req.jailSpec && !dockerExecution) { + // Only canonical auth preparation can rotate the account. Access-only + // jailed turns may run concurrently after their private seeds exist. + const releasePreparation = await this.acquireAuthPreparation(signal) + try { + jailHome = await prepareCodexJailAuth( + this.opts.bin, + hasCodexEnvAuth(launchEnv) ? undefined : authSourcePath, + codexHome ? join(codexHome.homePath, 'config.toml') + : authSourcePath ? join(dirname(authSourcePath), 'config.toml') : undefined, + req.execution?.timeoutMs ?? this.opts.timeoutMs, + signal, + req.jailSpec.readConfine === true, + Boolean(codexHome), + ) + } finally { + releasePreparation() + } + if (req.jailSpec.readConfine) req.jailSpec.requireEnforcement = true + // The jail seed has no refresh capability. File storage must win over + // an account config that would otherwise select an unrelated keyring. + args.splice(1, 0, '-c', 'cli_auth_credentials_store="file"') + } + } catch (error) { + try { codexHome?.cleanup() } finally { releaseHostAuth?.() } + throw error + } - // When MCP passthrough is active, the synthetic CODEX_HOME (selected MCP config - // + copied auth) is the source of truth. Register it as the jail's codex auth - // source so a CONFINED run gets it surfaced inside the jail with CODEX_HOME - // redirected there. Seeded WRITABLE: codex must write PATH aliases, - // app-server state, and session - // rollouts inside its home before it can run at all. The jail applies this - // only when it actually wraps; on docker/fallback paths the host - // `CODEX_HOME` env below is used unchanged. - if (req.jailSpec && codexHome) { + // Seed only an access-only credential into the writable jail home. The + // source account file is never mounted or promoted from agent-writable state. + if (req.jailSpec && jailHome) { req.jailSpec.authSources = [ ...(req.jailSpec.authSources ?? []).filter((s) => s.envVar !== 'CODEX_HOME'), { - source: codexHome.homePath, + source: jailHome.homePath, jailRel: '.codex', mode: 'seed-writable', only: ['auth.json', 'config.toml'], @@ -256,16 +311,29 @@ export class CodexBackend implements Backend { stdio: ['ignore', 'pipe', 'pipe'], cwd, env: { - ...process.env, - ...provisioned.env, + ...launchEnv, + ...(dockerExecution ? { + CODEX_HOME: undefined, + ...(this.spawner.dockerOauthMode === 'per-slot' ? { + CODEX_API_KEY: provisioned.env.CODEX_API_KEY, + CODEX_ACCESS_TOKEN: provisioned.env.CODEX_ACCESS_TOKEN, + } : {}), + } : {}), + ...(jailHome?.subscription ? { + OPENAI_API_KEY: undefined, + OPENAI_BASE_URL: undefined, + CODEX_ACCESS_TOKEN: undefined, + CODEX_API_KEY: undefined, + } : {}), ...(codexHome ? { CODEX_HOME: codexHome.homePath } : {}), ...(router ? { TANGLE_ROUTER_CREDENTIAL: router.key } : {}), }, - ...(req.session_id ? { sessionId: req.session_id } : {}), + ...(externalId ? { sessionId: externalId } : {}), ...(req.jailSpec ? { jail: req.jailSpec } : {}), ...(req.childLineage ? { lineageEnv: req.childLineage } : {}), ...(req.acquireDeadlineMs !== undefined ? { acquireDeadlineMs: req.acquireDeadlineMs } : {}), ...(req.admissionClass ? { admissionClass: req.admissionClass } : {}), + ...(dockerCodexConfig !== undefined ? { dockerCodexConfig } : {}), }) // A custom spawner may settle after cancellation even if it ignores the // signal. Do not read a late child's output or report a normal stop. @@ -276,7 +344,11 @@ export class CodexBackend implements Backend { throw new BackendError(`Codex launch aborted during spawn; termination ${outcome}`, 'aborted') } } catch (error) { - try { codexHome?.cleanup() } finally { record?.close(signal.aborted ? 'aborted' : 'failed') } + try { codexHome?.cleanup() } finally { + try { jailHome?.cleanup() } finally { + try { record?.close(signal.aborted ? 'aborted' : 'failed') } finally { releaseHostAuth?.() } + } + } throw error } const child = spawned.child @@ -414,14 +486,41 @@ export class CodexBackend implements Backend { await finishRecord() } finally { try { releaseSpawner() } finally { - try { codexHome?.cleanup() } finally { - record?.close(signal.aborted ? 'aborted' : recordOutcome) + try { + try { codexHome?.cleanup() } finally { jailHome?.cleanup() } + } finally { + try { record?.close(signal.aborted ? 'aborted' : recordOutcome) } finally { releaseHostAuth?.() } } } } } } + private async acquireAuthPreparation(signal: AbortSignal): Promise<() => void> { + const prior = this.authPreparationTail + let release!: () => void + const own = new Promise((resolve) => { release = resolve }) + this.authPreparationTail = prior.then(() => own) + let onAbort: (() => void) | undefined + try { + await Promise.race([ + prior, + new Promise((_, reject) => { + onAbort = () => reject(new BackendError('Codex account preparation aborted while queued', 'aborted')) + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) onAbort() + }), + ]) + if (signal.aborted) throw new BackendError('Codex account preparation aborted while queued', 'aborted') + return release + } catch (error) { + release() + throw error + } finally { + if (onAbort) signal.removeEventListener('abort', onAbort) + } + } + private flattenPrompt(messages: ChatRequest['messages']): string { if (messages.length === 1) return contentToText(messages[0]?.content ?? '') return messages.map((m) => `[${m.role}] ${contentToText(m.content)}`).join('\n\n') diff --git a/src/backends/profile-support.ts b/src/backends/profile-support.ts index 6922829..8b6f5e6 100644 --- a/src/backends/profile-support.ts +++ b/src/backends/profile-support.ts @@ -12,10 +12,11 @@ import { renameSync, rmdirSync, rmSync, + symlinkSync, writeFileSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { basename, join, posix } from 'node:path' +import { basename, join, posix, resolve } from 'node:path' import type { AgentProfile, AgentProfileConfigValue, @@ -1957,15 +1958,15 @@ export function materializeEmptyMcpConfig(): MaterializedMcpConfig { * * `authSourcePath` is the path to the user's persistent `auth.json` * (default `~/.codex/auth.json`). Codex looks up the session's bearer - * token here. We copy it into the temp dir so the spawned codex still - * authenticates as the operator. The copy is deleted at cleanup. + * token here. The host turn links to this file so a native token refresh + * persists immediately. Confined turns use a separate access-only seed. * * stdio servers — written as `command = "..."` + optional `args`/`env`. * http servers (spec.type === 'http' with `url`) — written as * `url = "..."` + optional `headers`/`bearer_token_env_var`. * * A supplied sessionHome retains native state under the bridge data directory. - * Cleanup removes only this turn's config and auth; one-shot homes are removed entirely. + * Cleanup removes only this turn's config and auth link; one-shot homes are removed entirely. * Returns null when no usable servers remain and no session home was supplied. */ export interface MaterializedCodexHome { @@ -1983,6 +1984,51 @@ export function materializeMcpServersForCodex( ): MaterializedCodexHome | null { if (!specs && !sessionHome) return null + const { lines, serverNames } = codexMcpConfig(specs) + if (serverNames.length === 0 && !sessionHome) return null + + // Codex aborts if CODEX_HOME is under the system tmpdir on some + // platforms — use the user's HOME/.cache as a stable parent. + const baseDir = sessionHome ?? mkdtempSync(join(stableTmpRoot(), 'cli-bridge-codex-')) + // A session keeps native rollouts and indexes, never the previous turn's auth link. + for (const name of ['config.toml', 'auth.json']) rmSync(join(baseDir, name), { force: true }) + writeFileSync(join(baseDir, 'config.toml'), lines.join('\n\n') + '\n', { mode: 0o600 }) + + if (authSourcePath) { + try { + if (existsSync(authSourcePath)) symlinkSync(resolve(authSourcePath), join(baseDir, 'auth.json')) + } catch { + // Best-effort: Codex without auth.json reports its own auth error. + } + } + + return { + homePath: baseDir, + serverNames, + cleanup: () => { + try { + if (sessionHome) { + for (const name of ['config.toml', 'auth.json']) rmSync(join(baseDir, name), { force: true }) + } else { + rmSync(baseDir, { recursive: true, force: true }) + } + } catch { + // best-effort + } + }, + } +} + +/** Docker Codex keeps its mounted auth home; write this MCP config through stdin. */ +export function codexMcpConfigText(specs: Record | null): string | null { + const { lines, serverNames } = codexMcpConfig(specs) + return serverNames.length ? `${lines.join('\n\n')}\n` : null +} + +function codexMcpConfig(specs: Record | null): { + lines: string[] + serverNames: string[] +} { const lines: string[] = [] const serverNames: string[] = [] for (const [name, spec] of Object.entries(specs ?? {})) { @@ -2026,41 +2072,7 @@ export function materializeMcpServersForCodex( lines.push(block.join('\n')) serverNames.push(name) } - if (serverNames.length === 0 && !sessionHome) return null - - // Codex aborts if CODEX_HOME is under the system tmpdir on some - // platforms — use the user's HOME/.cache as a stable parent. - const baseDir = sessionHome ?? mkdtempSync(join(stableTmpRoot(), 'cli-bridge-codex-')) - // A session keeps native rollouts and indexes, never the previous turn's credentials. - for (const name of ['config.toml', 'auth.json']) rmSync(join(baseDir, name), { force: true }) - writeFileSync(join(baseDir, 'config.toml'), lines.join('\n\n') + '\n') - - if (authSourcePath) { - try { - const auth = readFileMaybe(authSourcePath) - if (auth !== null) writeFileSync(join(baseDir, 'auth.json'), auth) - } catch { - // Best-effort: codex without auth.json will fail to call the - // model. Surface that as an upstream error from the backend - // rather than silently swallowing it here. - } - } - - return { - homePath: baseDir, - serverNames, - cleanup: () => { - try { - if (sessionHome) { - for (const name of ['config.toml', 'auth.json']) rmSync(join(baseDir, name), { force: true }) - } else { - rmSync(baseDir, { recursive: true, force: true }) - } - } catch { - // best-effort - } - }, - } + return { lines, serverNames } } function stableTmpRoot(): string { diff --git a/src/codex-config.ts b/src/codex-config.ts new file mode 100644 index 0000000..fe6312e --- /dev/null +++ b/src/codex-config.ts @@ -0,0 +1,111 @@ +/** Keep account settings while excluding ambient MCP and project config layers. */ +export function scopeCodexTurnConfig(base: string, requested: string): string { + const kept: string[] = [] + let skipSection = false + let section = '' + let multilineQuote: '"""' | "'''" | null = null + for (const line of base.split('\n')) { + const insideMultiline = multilineQuote !== null + const header = insideMultiline ? null : /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$/.exec(line) + multilineQuote = nextMultilineQuote(line, multilineQuote) + if (header) { + section = header[1]!.trim() + const root = tomlRootKey(section) + skipSection = root === 'mcp_servers' || root === 'projects' + } + if (skipSection) continue + const trimmed = line.trimStart() + if (!insideMultiline && !header && trimmed.startsWith('[')) throw new Error('Codex base config has unsupported table syntax') + if (section === '' && !insideMultiline && !header && trimmed && !trimmed.startsWith('#') + && trimmed.includes('=') && ['mcp_servers', 'projects'].includes(tomlRootKey(trimmed))) { + if (!singleLineTomlValue(trimmed.slice(trimmed.indexOf('=') + 1))) { + throw new Error('Codex base config has unsupported multiline MCP or project key') + } + continue + } + kept.push(line) + } + return `${kept.join('\n').trimEnd()}\n${requested ? `\n${requested}` : ''}` +} + +/** Track strings across lines so table-like text in a setting is not a header. */ +function nextMultilineQuote( + line: string, + initial: '"""' | "'''" | null, +): '"""' | "'''" | null { + let multiline = initial + let quoted: '"' | "'" | null = null + for (let i = 0; i < line.length; i++) { + const char = line[i] + if (multiline) { + if (multiline === '"""' && char === '\\') { i++; continue } + if (line.startsWith(multiline, i)) { multiline = null; i += 2 } + continue + } + if (quoted) { + if (quoted === '"' && char === '\\') { i++; continue } + if (char === quoted) quoted = null + continue + } + if (char === '#') break + if (line.startsWith('"""', i)) { multiline = '"""'; i += 2; continue } + if (line.startsWith("'''", i)) { multiline = "'''"; i += 2; continue } + if (char === '"' || char === "'") quoted = char + } + return multiline +} + +/** A dotted key can be removed only when its entire value ends on this line. */ +function singleLineTomlValue(value: string): boolean { + const trimmed = value.trimStart() + if (trimmed.startsWith('"""') || trimmed.startsWith("'''")) return false + let quote: '"' | "'" | null = null + let escaped = false + let depth = 0 + for (const char of trimmed) { + if (quote) { + if (quote === '"' && !escaped && char === '\\') { escaped = true; continue } + if (!escaped && char === quote) quote = null + escaped = false + continue + } + if (char === '#') break + if (char === '"' || char === "'") { quote = char; continue } + if (char === '[' || char === '{') depth++ + if (char === ']' || char === '}') depth-- + if (depth < 0) return false + } + return quote === null && depth === 0 +} + +/** Read the first TOML key component; refuse syntax we cannot classify safely. */ +function tomlRootKey(text: string): string { + const input = text.trimStart() + let key: string + let rest: string + if (input.startsWith('"')) { + let end = 1 + for (; end < input.length; end++) { + if (input[end] === '\\') { end++; continue } + if (input[end] === '"') break + } + try { key = JSON.parse(input.slice(0, end + 1)) as string } catch { + throw new Error('Codex base config has unsupported quoted key syntax') + } + rest = input.slice(end + 1).trimStart() + } else if (input.startsWith("'")) { + const end = input.indexOf("'", 1) + if (end < 0) throw new Error('Codex base config has unsupported quoted key syntax') + key = input.slice(1, end) + rest = input.slice(end + 1).trimStart() + } else { + const match = /^[A-Za-z0-9_-]+/.exec(input) + if (!match) throw new Error('Codex base config has unsupported key syntax') + key = match[0] + rest = input.slice(key.length).trimStart() + } + if (rest && !rest.startsWith('.') && !rest.startsWith('=')) { + throw new Error('Codex base config has unsupported key syntax') + } + return key +} diff --git a/src/executors/container-pool.ts b/src/executors/container-pool.ts index cd485a0..f0caec3 100644 --- a/src/executors/container-pool.ts +++ b/src/executors/container-pool.ts @@ -60,6 +60,7 @@ */ import { execFile } from 'node:child_process' +import { createHash } from 'node:crypto' import { promisify } from 'node:util' import { assertDockerNetworkName } from './docker-network.js' import { containerShell, dockerCli, type DockerCli } from './docker-cli.js' @@ -83,6 +84,8 @@ export interface ContainerPoolOptions { * requires re-`claude /login` per slot on first run. */ oauthMode: 'share' | 'per-slot' + /** Keep a retained session on one credential slot across turns and restarts. */ + strictSessionAffinity?: boolean /** Bind paths for the `share` mode. Each entry is `host:container`. */ shareMounts?: string[] /** @@ -203,6 +206,7 @@ const DEFAULTS = { interface Waiter { sessionId: string | undefined + slotIndex?: number resolve: (slot: SlotState) => boolean reject: (err: Error) => void timer: NodeJS.Timeout @@ -305,6 +309,12 @@ export class ContainerPool { } } + resolveAcquireDeadlineMs(requestedDeadlineMs?: number): number { + return requestedDeadlineMs === undefined + ? this.acquireDeadlineMs + : Math.max(1, Math.min(requestedDeadlineMs, this.maxAcquireDeadlineMs)) + } + /** * Take a slot, waiting up to `requestedDeadlineMs` capped by the pool ceiling. */ @@ -312,17 +322,20 @@ export class ContainerPool { if (this.destroyed) throw new Error('container pool destroyed') signal?.throwIfAborted() this.counters.acquires += 1 - const deadlineMs = requestedDeadlineMs === undefined - ? this.acquireDeadlineMs - : Math.max(1, Math.min(requestedDeadlineMs, this.maxAcquireDeadlineMs)) + const deadlineMs = this.resolveAcquireDeadlineMs(requestedDeadlineMs) + const strictSlot = this.opts.strictSessionAffinity && sessionId + ? this.slots[createHash('sha256').update(sessionId).digest().readUInt32BE(0) % this.slots.length] + : undefined + if (strictSlot?.dead) throw new Error(`container-pool: retained session slot ${strictSlot.index} is dead`) + if (strictSlot && !strictSlot.busy) return await this.handOut(strictSlot, sessionId, signal) // Sticky preference: prefer a free, non-dead slot that last served // this session. - if (sessionId) { + if (!strictSlot && sessionId) { const sticky = this.slots.find((s) => !s.busy && !s.dead && s.lastSession === sessionId) if (sticky) return await this.handOut(sticky, sessionId, signal) } - const free = this.slots.find((s) => !s.busy && !s.dead) + const free = strictSlot ? undefined : this.slots.find((s) => !s.busy && !s.dead) if (free) return await this.handOut(free, sessionId, signal) // All slots busy or dead — count alive slots so we don't queue @@ -382,6 +395,7 @@ export class ContainerPool { this.waiters.push({ settled: false, sessionId, + ...(strictSlot ? { slotIndex: strictSlot.index } : {}), resolve: (slot) => { if (waiter.settled) return false if (signal?.aborted) { @@ -629,7 +643,9 @@ export class ContainerPool { } // This slot could not be made usable. Another alive slot may still // serve the waiter; only when none exists does the waiter learn why. - const alternative = this.slots.find((s) => s !== slot && !s.busy && !s.dead) + const alternative = waiter.slotIndex === undefined + ? this.slots.find((s) => s !== slot && !s.busy && !s.dead) + : undefined if (alternative) { this.waiters.unshift(waiter) this.serveWaiterWith(alternative) @@ -644,8 +660,10 @@ export class ContainerPool { /** Next waiter for this slot, preferring one whose session it last served. */ private takeWaiterFor(slot: SlotState): Waiter | undefined { if (this.waiters.length === 0) return undefined - const stickyIdx = this.waiters.findIndex((w) => w.sessionId && w.sessionId === slot.lastSession) - return this.waiters.splice(stickyIdx >= 0 ? stickyIdx : 0, 1)[0] + const eligible = (waiter: Waiter): boolean => waiter.slotIndex === undefined || waiter.slotIndex === slot.index + const stickyIdx = this.waiters.findIndex((w) => eligible(w) && w.sessionId && w.sessionId === slot.lastSession) + const firstIdx = stickyIdx >= 0 ? stickyIdx : this.waiters.findIndex(eligible) + return firstIdx >= 0 ? this.waiters.splice(firstIdx, 1)[0] : undefined } /** diff --git a/src/executors/docker.ts b/src/executors/docker.ts index 9ae8def..bc0ae61 100644 --- a/src/executors/docker.ts +++ b/src/executors/docker.ts @@ -25,6 +25,7 @@ import { killTree } from './process-tree.js' import { cwdPolicyFinding, ExecutorConfigurationError, + ExecutorSaturatedError, type ExecutorFinding, type ExecutorReadiness, type SpawnOpts, @@ -32,11 +33,14 @@ import { type Spawner, } from './types.js' import { withLineageEnv } from '../trace/lineage.js' +import { scopeCodexTurnConfig } from '../codex-config.js' const execFileAsync = promisify(execFile) export interface DockerSpawnerOptions { pool: ContainerPool + oauthMode?: 'share' | 'per-slot' + containerConfigDir?: string /** * If the CLI binary lives at a non-standard path inside the * container, set this prefix. Most images install /usr/local/bin/... @@ -73,18 +77,95 @@ export function createDockerSpawner(opts: DockerSpawnerOptions): Spawner { ...(opts.envPrefix ? { envPrefix: opts.envPrefix } : {}), } const cli = opts.cli ?? dockerCli + let sharedCodexTail: Promise = Promise.resolve() + let sharedCodexPending = 0 + const acquireSharedCodex = async (signal: AbortSignal | undefined, deadlineMs: number): Promise<() => void> => { + const maxQueue = opts.pool.snapshot?.().max_queue ?? 4 + if (sharedCodexPending >= maxQueue + 1) { + throw new ExecutorSaturatedError('docker-codex-shared-auth', { + in_flight: 1, max: 1, queued: sharedCodexPending - 1, deadline_ms: 0, + }, `Docker Codex shared auth queue full (depth=${sharedCodexPending - 1}/${maxQueue})`) + } + sharedCodexPending += 1 + const prior = sharedCodexTail + let resolveOwn!: () => void + let released = false + const release = (): void => { + if (released) return + released = true + sharedCodexPending -= 1 + resolveOwn() + } + const own = new Promise((resolve) => { resolveOwn = resolve }) + sharedCodexTail = prior.then(() => own) + const onAbort = (): void => { release() } + let rejectAbort: ((reason: unknown) => void) | undefined + const aborted = new Promise((_, reject) => { rejectAbort = reject }) + const timeout = setTimeout(() => { + release() + rejectAbort?.(new ExecutorSaturatedError('docker-codex-shared-auth', { + in_flight: 1, max: 1, queued: sharedCodexPending - 1, deadline_ms: deadlineMs, + }, `Docker Codex shared auth acquire timeout after ${deadlineMs}ms`)) + }, deadlineMs).unref() + const abortQueued = (): void => { + onAbort() + rejectAbort?.(signal?.reason) + } + signal?.addEventListener('abort', abortQueued, { once: true }) + try { + if (signal?.aborted) abortQueued() + await Promise.race([prior, aborted]) + signal?.throwIfAborted() + return release + } catch (error) { + release() + throw error + } finally { + clearTimeout(timeout) + signal?.removeEventListener('abort', abortQueued) + } + } const spawner: Spawner = async (bin, args, spawnOpts) => { const cwd = assertDockerWorkspaceCwd(opts.workspaceRoot, spawnOpts.cwd, naming) - const slot = await opts.pool.acquire(spawnOpts.sessionId, spawnOpts.acquireDeadlineMs, spawnOpts.signal) + // A shared OAuth mount has one config.toml across every pool slot. Include + // no-MCP turns in the lease so none can read a sibling's temporary headers. + const acquisitionStartedAt = Date.now() + const acquireDeadlineMs = opts.pool.resolveAcquireDeadlineMs?.(spawnOpts.acquireDeadlineMs) + ?? Math.max(1, spawnOpts.acquireDeadlineMs ?? 60_000) + const releaseShared = opts.backend === 'codex' && (opts.oauthMode ?? 'share') === 'share' + ? await acquireSharedCodex(spawnOpts.signal, acquireDeadlineMs) : undefined + let slot: Awaited> + try { + const remainingMs = acquireDeadlineMs - (Date.now() - acquisitionStartedAt) + if (remainingMs <= 0) throw new ExecutorSaturatedError('docker-codex-shared-auth', { + in_flight: 1, max: 1, queued: 0, deadline_ms: acquireDeadlineMs, + }, `Docker Codex shared auth acquire timeout after ${acquireDeadlineMs}ms`) + slot = await opts.pool.acquire(spawnOpts.sessionId, remainingMs, spawnOpts.signal) + } catch (error) { + releaseShared?.() + throw error + } let released = false let terminationFinished = false let terminationPromise: Promise | null = null + let restoreCodexConfig: (() => Promise) | undefined const releaseNow = (): void => { if (released) return released = true slot.release() + releaseShared?.() } try { + spawnOpts.signal?.throwIfAborted() + if (opts.backend === 'codex' && opts.containerConfigDir) { + await recoverDockerCodexConfig(slot.containerId, opts.containerConfigDir) + } + if (spawnOpts.dockerCodexConfig !== undefined) { + if (!opts.containerConfigDir) throw new Error('Docker Codex config directory is missing') + restoreCodexConfig = await installDockerCodexConfig( + slot.containerId, opts.containerConfigDir, spawnOpts.dockerCodexConfig, + ) + } spawnOpts.signal?.throwIfAborted() const dockerArgs = buildDockerExecArgs( slot.containerId, @@ -104,7 +185,8 @@ export function createDockerSpawner(opts: DockerSpawnerOptions): Spawner { slot.containerId, opts.restartContainer ?? restartDockerContainer, cli, - ).then(() => { + ).then(async () => { + await restoreCodexConfig?.() terminationFinished = true }).catch((error) => { terminationPromise = null @@ -130,8 +212,8 @@ export function createDockerSpawner(opts: DockerSpawnerOptions): Spawner { // here, so the slot was never returned and /health could not recover // even though the pool knew how to rebuild it. void opts.pool.recycleHeldSlot(slot.containerId) + .then(releaseNow) .catch(() => {}) - .finally(releaseNow) }) } child.once('close', release) @@ -168,7 +250,17 @@ export function createDockerSpawner(opts: DockerSpawnerOptions): Spawner { } return result } catch (err) { - releaseNow() + let safeToRelease = true + try { + if (restoreCodexConfig) await restoreCodexConfig() + else if (opts.backend === 'codex' && opts.containerConfigDir) { + await recoverDockerCodexConfig(slot.containerId, opts.containerConfigDir) + } + } catch { + safeToRelease = false + try { await opts.pool.recycleHeldSlot(slot.containerId); safeToRelease = true } catch {} + } + if (safeToRelease) releaseNow() throw err } } @@ -236,9 +328,72 @@ export function createDockerSpawner(opts: DockerSpawnerOptions): Spawner { return { cwd, findings } } spawner.executionEnvironment = 'docker' + spawner.dockerOauthMode = opts.oauthMode ?? 'share' return spawner } +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'` +} + +async function installDockerCodexConfig( + containerId: string, + configDir: string, + config: string, +): Promise<() => Promise> { + const dir = shellQuote(configDir) + const paths = `d=${dir}; p="$d/config.toml"; b="$d/.cli-bridge-config.backup"; m="$d/.cli-bridge-config.absent"` + const base = await readDockerCodexConfig(containerId, configDir) + const merged = scopeCodexTurnConfig(base, config) + const prepare = `set -eu; ${paths}; if [ -e "$p" ] || [ -L "$p" ]; then cp -Pp "$p" "$b"; if [ ! -L "$b" ]; then chmod 600 "$b"; fi; else umask 077; : > "$m"; fi; rm -f "$p"; umask 077; cat > "$p"` + try { + await dockerExecWithStdin(containerId, prepare, merged) + } catch { + await recoverDockerCodexConfig(containerId, configDir).catch(() => {}) + throw new Error('Docker Codex MCP config could not be installed') + } + let restorePromise: Promise | undefined + return () => { + restorePromise ??= recoverDockerCodexConfig(containerId, configDir) + return restorePromise + } +} + +async function readDockerCodexConfig(containerId: string, configDir: string): Promise { + const dir = shellQuote(configDir) + try { + const { stdout } = await execFileAsync('docker', [ + 'exec', containerId, 'sh', '-c', `set -eu; d=${dir}; if [ -f "$d/config.toml" ]; then cat "$d/config.toml"; fi`, + ], { timeout: 10_000, maxBuffer: 2 * 1024 * 1024 }) + return stdout + } catch { + throw new Error('Docker Codex base config cannot be read') + } +} + +async function recoverDockerCodexConfig(containerId: string, configDir: string): Promise { + const dir = shellQuote(configDir) + const script = `set -eu; d=${dir}; p="$d/config.toml"; b="$d/.cli-bridge-config.backup"; m="$d/.cli-bridge-config.absent"; if [ -e "$b" ] || [ -L "$b" ]; then mv -f "$b" "$p"; elif [ -e "$m" ]; then rm -f "$p"; fi; rm -f "$m"` + try { + await execFileAsync('docker', ['exec', containerId, 'sh', '-c', script], { timeout: 10_000 }) + } catch { + throw new Error('Docker Codex MCP config recovery failed') + } +} + +async function dockerExecWithStdin(containerId: string, script: string, input: string): Promise { + const child = spawn('docker', ['exec', '-i', containerId, 'sh', '-c', script], { + stdio: ['pipe', 'ignore', 'ignore'], + signal: AbortSignal.timeout(10_000), + }) + child.stdin.on('error', () => {}) + child.stdin.end(input) + await new Promise((resolve, reject) => { + child.once('error', () => reject(new Error('Docker Codex config write failed'))) + child.once('close', (code) => code === 0 ? resolve() : reject(new Error('Docker Codex config write failed'))) + }) +} + /** * Stop one Docker-backed request with container-level certainty. * diff --git a/src/executors/types.ts b/src/executors/types.ts index 3fd653a..a302b95 100644 --- a/src/executors/types.ts +++ b/src/executors/types.ts @@ -90,6 +90,8 @@ export interface SpawnOpts { cwd?: string /** Env to set on the child. */ env?: NodeJS.ProcessEnv + /** Codex MCP config written through stdin into the acquired Docker slot. */ + dockerCodexConfig?: string /** * Exact names in `env` that the backend resolved for THIS request and that * must reach the child even though the host sanitizer's allowlist does not @@ -280,6 +282,8 @@ export interface Spawner { * executor because credential safety cannot depend on guessing its behavior. */ executionEnvironment?: 'host' | 'docker' | 'test-double' + /** Docker credential mount ownership; per-slot homes must not inherit host Codex auth. */ + dockerOauthMode?: 'share' | 'per-slot' } /** diff --git a/src/server.ts b/src/server.ts index 03065de..1cb3df8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -219,6 +219,7 @@ async function buildExecutorForBackend( image: cfg.image, namePrefix: cfg.namePrefix ?? `cli-bridge-${cfg.name}-pool`, oauthMode: cfg.oauthMode ?? 'share', + strictSessionAffinity: cfg.name === 'codex' && cfg.oauthMode === 'per-slot', memory, cpus, maxQueueDepth, @@ -312,6 +313,8 @@ async function buildExecutorForBackend( return createDockerSpawner({ pool, + oauthMode: cfg.oauthMode ?? 'share', + containerConfigDir: cfg.containerConfigDir, backend: cfg.name, envPrefix: cfg.name.toUpperCase(), ...(cfg.workspaceRoot ? { workspaceRoot: cfg.workspaceRoot } : {}), diff --git a/tests/codex-backend.test.ts b/tests/codex-backend.test.ts index 5f0bd39..be8cb16 100644 --- a/tests/codex-backend.test.ts +++ b/tests/codex-backend.test.ts @@ -417,7 +417,7 @@ describe('CodexBackend model translation', () => { }) describe('CodexBackend jailed MCP visibility', () => { - it('registers the synthetic CODEX_HOME as a writable seed so a confined codex reads its MCP config', async () => { + it('registers the synthetic CODEX_HOME as a writable access-only seed so a confined codex reads its MCP config', async () => { // The MCP stanzas live in the synthetic CODEX_HOME's config.toml. Under // an fs-jail that home must arrive INSIDE the jail (seed-writable, the // jail copies it to /.codex and redirects CODEX_HOME) — the host @@ -430,30 +430,64 @@ describe('CodexBackend jailed MCP visibility', () => { // The synthetic home is removed in chat()'s finally, so its config must // be captured while the subprocess is (fake-)running — exactly when the // real jail would seed it. + const sourceHome = mkdtempSync(join(tmpdir(), 'codex-jail-auth-source-')) + const previousHome = process.env.CODEX_HOME + const previousAccess = process.env.CODEX_ACCESS_TOKEN + const previousApiKey = process.env.CODEX_API_KEY + const accessToken = `header.${Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 })).toString('base64url')}.sig` + writeFileSync(join(sourceHome, 'auth.json'), JSON.stringify({ + auth_mode: 'chatgpt', + tokens: { account_id: 'fixture-account', id_token: 'fixture-id', access_token: accessToken, refresh_token: 'fixture-refresh' }, + last_refresh: new Date().toISOString(), + OPENAI_API_KEY: 'fixture-extra-secret', + }), { mode: 0o600 }) + process.env.CODEX_HOME = sourceHome + delete process.env.CODEX_ACCESS_TOKEN + delete process.env.CODEX_API_KEY let seededConfig: string | null = null + let seededAuth: Record | null = null + let spawnedEnv: NodeJS.ProcessEnv | undefined const inner = codexSpawner([THREAD, MESSAGE_ITEM, TURN_DONE]) const backend = new CodexBackend({ bin: 'codex', timeoutMs: 5_000, spawner: async (bin, args, opts) => { + spawnedEnv = opts.env const source = (jailSpec.authSources ?? []).find((s) => s.envVar === 'CODEX_HOME')?.source - if (source) seededConfig = readFileSync(join(source, 'config.toml'), 'utf8') + if (source) { + seededConfig = readFileSync(join(source, 'config.toml'), 'utf8') + seededAuth = JSON.parse(readFileSync(join(source, 'auth.json'), 'utf8')) as Record + } return inner(bin, args, opts) }, }) - await collect(backend.chat( - { - ...request(), - mcp: { mcpServers: { coordination: { command: '/bin/true' } } }, - jailSpec, - }, - null, - new AbortController().signal, - )) - const codexSources = (jailSpec.authSources ?? []).filter((s) => s.envVar === 'CODEX_HOME') - expect(codexSources).toHaveLength(1) - expect(codexSources[0]).toMatchObject({ jailRel: '.codex', mode: 'seed-writable' }) - expect(seededConfig).toContain('[mcp_servers.coordination]') - expect(seededConfig).toContain('command = "/bin/true"') + try { + await collect(backend.chat( + { + ...request(), + mcp: { mcpServers: { coordination: { command: '/bin/true' } } }, + jailSpec, + }, + null, + new AbortController().signal, + )) + const codexSources = (jailSpec.authSources ?? []).filter((s) => s.envVar === 'CODEX_HOME') + expect(codexSources).toHaveLength(1) + expect(codexSources[0]).toMatchObject({ jailRel: '.codex', mode: 'seed-writable' }) + expect(seededConfig).toContain('[mcp_servers.coordination]') + expect(seededConfig).toContain('command = "/bin/true"') + expect(seededAuth).toMatchObject({ auth_mode: 'chatgpt', tokens: { account_id: 'fixture-account', refresh_token: '' } }) + expect(seededAuth).not.toHaveProperty('OPENAI_API_KEY') + expect(spawnedEnv?.CODEX_ACCESS_TOKEN).toBeUndefined() + expect(spawnedEnv?.CODEX_API_KEY).toBeUndefined() + } finally { + if (previousHome === undefined) delete process.env.CODEX_HOME + else process.env.CODEX_HOME = previousHome + if (previousAccess === undefined) delete process.env.CODEX_ACCESS_TOKEN + else process.env.CODEX_ACCESS_TOKEN = previousAccess + if (previousApiKey === undefined) delete process.env.CODEX_API_KEY + else process.env.CODEX_API_KEY = previousApiKey + rmSync(sourceHome, { recursive: true, force: true }) + } }) })