From 190c6add7b7c0519cfddf41c09cf806257604440 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 27 Sep 2026 16:35:15 -0600 Subject: [PATCH 01/14] fix(codex): retain selected subscription auth across turns --- README.md | 11 +- src/backends/codex-auth.ts | 177 ++++++++++++++++++++++++++++++++ src/backends/codex.ts | 100 +++++++++++++++--- src/backends/profile-support.ts | 20 ++-- tests/codex-backend.test.ts | 65 +++++++++--- 5 files changed, 329 insertions(+), 44 deletions(-) create mode 100644 src/backends/codex-auth.ts diff --git a/README.md b/README.md index e1f3c76..be41d24 100644 --- a/README.md +++ b/README.md @@ -620,7 +620,16 @@ and `env` round-trip through the materialised config file unchanged New recorded 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 also serializes turns on one bridge process while that process uses one selected account home. +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`. +Other Codex auth modes retain their existing credential behavior. +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. +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..bb9b188 --- /dev/null +++ b/src/backends/codex-auth.ts @@ -0,0 +1,177 @@ +import { existsSync, 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' + +interface FileAuth { + auth_mode?: unknown + tokens?: { + id_token?: unknown + account_id?: unknown + access_token?: unknown + refresh_token?: unknown + } + last_refresh?: unknown + [key: string]: unknown +} + +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 +} + +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, + configPath: string | undefined, + turnTimeoutMs: number, + signal: AbortSignal, + readConfine: boolean, +): Promise<{ homePath: string; subscription: boolean; cleanup(): void }> { + const auth = readFileAuth(authPath) + const accountId = auth.tokens?.account_id + if (auth.auth_mode === 'chatgpt') { + 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 (current.auth_mode !== 'chatgpt' || 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') { + 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: '', + }, + ...(typeof current.last_refresh === 'string' ? { last_refresh: current.last_refresh } : {}), + }, configPath, true) + } + return writeJailHome(auth, configPath, false) +} + +function writeJailHome(auth: FileAuth, configPath: string | undefined, subscription: boolean): { + homePath: string + subscription: boolean + cleanup(): void +} { + const homePath = mkdtempSync(join(tmpdir(), 'cli-bridge-codex-jail-')) + try { + writeFileSync(join(homePath, 'auth.json'), JSON.stringify(auth), { mode: 0o600 }) + const config = configPath && existsSync(configPath) ? readFileSync(configPath) : '\n' + writeFileSync(join(homePath, 'config.toml'), 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..aed546b 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' @@ -49,6 +49,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 { prepareCodexJailAuth } from './codex-auth.js' import { nativeReasoningControl } from '@tangle-network/agent-interface' import { assertRouterCodexExecution, @@ -71,6 +72,7 @@ export class CodexBackend implements Backend { readonly name = 'codex' readonly defaultExecutionTimeoutMs: number private readonly spawner: Spawner + private authTurnTail: Promise = Promise.resolve() constructor(private readonly opts: CodexBackendOptions) { this.defaultExecutionTimeoutMs = opts.timeoutMs this.spawner = opts.spawner ?? scopedHostSpawner @@ -89,6 +91,46 @@ export class CodexBackend implements Backend { req: ChatRequest, session: SessionRecord | null, signal: AbortSignal, + ): AsyncIterable { + // Codex's refresh token rotates once. Keep one bridge turn per selected + // account in flight so two private jail copies cannot spend it together. + const releaseAuthTurn = await this.acquireAuthTurn(signal) + try { + yield* this.chatOwned(req, session, signal) + } finally { + releaseAuthTurn() + } + } + + private async acquireAuthTurn(signal: AbortSignal): Promise<() => void> { + const prior = this.authTurnTail + let release!: () => void + const own = new Promise((resolve) => { release = resolve }) + this.authTurnTail = prior.then(() => own) + let onAbort: (() => void) | undefined + try { + await Promise.race([ + prior, + new Promise((_, reject) => { + onAbort = () => reject(new BackendError('Codex account turn aborted while queued', 'aborted')) + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) onAbort() + }), + ]) + if (signal.aborted) throw new BackendError('Codex account turn aborted while queued', 'aborted') + return release + } catch (error) { + release() + throw error + } finally { + if (onAbort) signal.removeEventListener('abort', onAbort) + } + } + + 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 @@ -182,8 +224,8 @@ 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 externalId = req.session_id ?? session?.externalId const nativeHome = this.opts.stateDir && externalId @@ -197,27 +239,43 @@ export class CodexBackend implements Backend { 'parse_error', ) } + const authSourcePath = resolveCodexAuthPath() const codexHome = materializeMcpServersForCodex( mcpServers, - resolveCodexAuthPath(), + authSourcePath, nativeHome && !legacySession ? ensurePrivateDataDirectory(nativeHome) : undefined, ) + let jailHome: Awaited> | undefined + try { + if (req.jailSpec && this.spawner.executionEnvironment !== 'docker') { + if (!authSourcePath) throw new BackendError('Jailed Codex requires a selected CODEX_HOME', 'not_configured') + jailHome = await prepareCodexJailAuth( + this.opts.bin, + authSourcePath, + codexHome ? join(codexHome.homePath, 'config.toml') : join(dirname(authSourcePath), 'config.toml'), + req.execution?.timeoutMs ?? this.opts.timeoutMs, + signal, + req.jailSpec.readConfine === true, + ) + 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) { + codexHome?.cleanup() + 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'], @@ -258,6 +316,12 @@ export class CodexBackend implements Backend { env: { ...process.env, ...provisioned.env, + ...(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 } : {}), }, @@ -276,7 +340,9 @@ 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 { record?.close(signal.aborted ? 'aborted' : 'failed') } + } throw error } const child = spawned.child @@ -414,7 +480,9 @@ export class CodexBackend implements Backend { await finishRecord() } finally { try { releaseSpawner() } finally { - try { codexHome?.cleanup() } finally { + try { + try { codexHome?.cleanup() } finally { jailHome?.cleanup() } + } finally { record?.close(signal.aborted ? 'aborted' : recordOutcome) } } diff --git a/src/backends/profile-support.ts b/src/backends/profile-support.ts index 6922829..51ca349 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 { @@ -2031,18 +2032,15 @@ export function materializeMcpServersForCodex( // 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. + // 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') + writeFileSync(join(baseDir, 'config.toml'), lines.join('\n\n') + '\n', { mode: 0o600 }) if (authSourcePath) { try { - const auth = readFileMaybe(authSourcePath) - if (auth !== null) writeFileSync(join(baseDir, 'auth.json'), auth) + if (existsSync(authSourcePath)) symlinkSync(resolve(authSourcePath), join(baseDir, 'auth.json')) } 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. + // Best-effort: Codex without auth.json reports its own auth error. } } diff --git a/tests/codex-backend.test.ts b/tests/codex-backend.test.ts index 5f0bd39..431b9be 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,63 @@ 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' }, + OPENAI_API_KEY: 'fixture-extra-secret', + }), { mode: 0o600 }) + process.env.CODEX_HOME = sourceHome + process.env.CODEX_ACCESS_TOKEN = 'fixture-ambient-access' + process.env.CODEX_API_KEY = 'fixture-ambient-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 }) + } }) }) From f0ddbf5e7eac2882086acab987b206f095d57229 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 27 Sep 2026 16:44:13 -0600 Subject: [PATCH 02/14] fix(codex): limit account lease to preparation --- README.md | 2 +- src/backends/codex.ts | 90 ++++++++++++++++++++----------------------- 2 files changed, 42 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index be41d24..88be69a 100644 --- a/README.md +++ b/README.md @@ -620,7 +620,7 @@ and `env` round-trip through the materialised config file unchanged New recorded 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. -The Codex backend also serializes turns on one bridge process while that process uses one selected account home. +The Codex backend serializes selected account preparation and refresh on one bridge process; access-only jailed turns can run concurrently. 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. diff --git a/src/backends/codex.ts b/src/backends/codex.ts index aed546b..166abed 100644 --- a/src/backends/codex.ts +++ b/src/backends/codex.ts @@ -72,7 +72,7 @@ export class CodexBackend implements Backend { readonly name = 'codex' readonly defaultExecutionTimeoutMs: number private readonly spawner: Spawner - private authTurnTail: Promise = Promise.resolve() + private authPreparationTail: Promise = Promise.resolve() constructor(private readonly opts: CodexBackendOptions) { this.defaultExecutionTimeoutMs = opts.timeoutMs this.spawner = opts.spawner ?? scopedHostSpawner @@ -91,46 +91,6 @@ export class CodexBackend implements Backend { req: ChatRequest, session: SessionRecord | null, signal: AbortSignal, - ): AsyncIterable { - // Codex's refresh token rotates once. Keep one bridge turn per selected - // account in flight so two private jail copies cannot spend it together. - const releaseAuthTurn = await this.acquireAuthTurn(signal) - try { - yield* this.chatOwned(req, session, signal) - } finally { - releaseAuthTurn() - } - } - - private async acquireAuthTurn(signal: AbortSignal): Promise<() => void> { - const prior = this.authTurnTail - let release!: () => void - const own = new Promise((resolve) => { release = resolve }) - this.authTurnTail = prior.then(() => own) - let onAbort: (() => void) | undefined - try { - await Promise.race([ - prior, - new Promise((_, reject) => { - onAbort = () => reject(new BackendError('Codex account turn aborted while queued', 'aborted')) - signal.addEventListener('abort', onAbort, { once: true }) - if (signal.aborted) onAbort() - }), - ]) - if (signal.aborted) throw new BackendError('Codex account turn aborted while queued', 'aborted') - return release - } catch (error) { - release() - throw error - } finally { - if (onAbort) signal.removeEventListener('abort', onAbort) - } - } - - 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 @@ -251,14 +211,21 @@ export class CodexBackend implements Backend { try { if (req.jailSpec && this.spawner.executionEnvironment !== 'docker') { if (!authSourcePath) throw new BackendError('Jailed Codex requires a selected CODEX_HOME', 'not_configured') - jailHome = await prepareCodexJailAuth( - this.opts.bin, - authSourcePath, - codexHome ? join(codexHome.homePath, 'config.toml') : join(dirname(authSourcePath), 'config.toml'), - req.execution?.timeoutMs ?? this.opts.timeoutMs, - signal, - req.jailSpec.readConfine === true, - ) + // 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, + authSourcePath, + codexHome ? join(codexHome.homePath, 'config.toml') : join(dirname(authSourcePath), 'config.toml'), + req.execution?.timeoutMs ?? this.opts.timeoutMs, + signal, + req.jailSpec.readConfine === true, + ) + } 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. @@ -490,6 +457,31 @@ export class CodexBackend implements Backend { } } + 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') From b9a575a7864d39585a35dc52be7aba48e57e2276 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 27 Sep 2026 16:46:32 -0600 Subject: [PATCH 03/14] fix(codex): lease only shared host subscription turns --- README.md | 1 + src/backends/codex-auth.ts | 6 ++++++ src/backends/codex.ts | 21 ++++++++++++++++++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 88be69a..47222e7 100644 --- a/README.md +++ b/README.md @@ -621,6 +621,7 @@ and `env` round-trip through the materialised config file unchanged New recorded 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. 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. diff --git a/src/backends/codex-auth.ts b/src/backends/codex-auth.ts index bb9b188..32afcd2 100644 --- a/src/backends/codex-auth.ts +++ b/src/backends/codex-auth.ts @@ -29,6 +29,12 @@ function readFileAuth(path: string): FileAuth { 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) return false + try { return readFileAuth(path).auth_mode === 'chatgpt' } catch { return false } +} + function accessExpiry(token: unknown): number { if (typeof token !== 'string') return NaN try { diff --git a/src/backends/codex.ts b/src/backends/codex.ts index 166abed..c549d3d 100644 --- a/src/backends/codex.ts +++ b/src/backends/codex.ts @@ -49,7 +49,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 { prepareCodexJailAuth } from './codex-auth.js' +import { isCodexSubscriptionAuth, prepareCodexJailAuth } from './codex-auth.js' import { nativeReasoningControl } from '@tangle-network/agent-interface' import { assertRouterCodexExecution, @@ -91,6 +91,25 @@ export class CodexBackend implements Backend { req: ChatRequest, session: SessionRecord | null, signal: AbortSignal, + ): AsyncIterable { + // Unconfined ChatGPT may rotate the canonical file at any point in its + // native turn. API-key, Router, Docker and access-only jail turns do not. + const hostSubscription = !req.jailSpec + && this.spawner.executionEnvironment !== 'docker' + && process.env.BRIDGE_ROUTER_RECEIPTS_REQUIRED !== '1' + && isCodexSubscriptionAuth(resolveCodexAuthPath()) + const releaseHostAuth = hostSubscription ? await this.acquireAuthPreparation(signal) : undefined + try { + yield* this.chatOwned(req, session, signal) + } finally { + releaseHostAuth?.() + } + } + + 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 From c199aa92771983b1b1fd163b8bb79e7f7d2d6979 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 27 Sep 2026 16:53:17 -0600 Subject: [PATCH 04/14] fix(codex): preserve env-authenticated jailed launches --- README.md | 3 ++- src/backends/codex-auth.ts | 40 +++++++++++++++++++++++++++++++------- src/backends/codex.ts | 4 ++-- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 47222e7..c7ae4c4 100644 --- a/README.md +++ b/README.md @@ -627,7 +627,8 @@ ChatGPT subscription turns in an enforced `fs-jail` receive only their selected 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`. -Other Codex auth modes retain their existing credential behavior. +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`. 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. The bridge removes its temporary auth link and MCP configuration after the turn. diff --git a/src/backends/codex-auth.ts b/src/backends/codex-auth.ts index 32afcd2..ea7972c 100644 --- a/src/backends/codex-auth.ts +++ b/src/backends/codex-auth.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +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' @@ -18,6 +18,17 @@ interface FileAuth { [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 { @@ -31,8 +42,20 @@ function readFileAuth(path: string): FileAuth { /** Only an unconfined file-backed ChatGPT turn can rotate this host account during inference. */ export function isCodexSubscriptionAuth(path: string | undefined): boolean { - if (!path) return false - try { return readFileAuth(path).auth_mode === 'chatgpt' } catch { return false } + if (!path || !authFileExists(path)) return false + return classifyFileAuth(readFileAuth(path)) === 'subscription' +} + +function classifyFileAuth(auth: FileAuth): 'subscription' | 'other' { + if (auth.auth_mode === 'chatgpt') return 'subscription' + 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 auth.auth_mode === 'string' && OTHER_FILE_AUTH_MODES.has(auth.auth_mode)) return 'other' + if ((auth.auth_mode === undefined || auth.auth_mode === null) && typeof auth.OPENAI_API_KEY === 'string') return 'other' + throw new BackendError('Codex account auth mode is unrecognized', 'not_configured') } function accessExpiry(token: unknown): number { @@ -115,15 +138,18 @@ async function refreshAccountWithCodex(bin: string, authPath: string, signal: Ab */ export async function prepareCodexJailAuth( bin: string, - authPath: string, + authPath: string | undefined, configPath: string | undefined, turnTimeoutMs: number, signal: AbortSignal, readConfine: 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) const auth = readFileAuth(authPath) const accountId = auth.tokens?.account_id - if (auth.auth_mode === 'chatgpt') { + if (classifyFileAuth(auth) === 'subscription') { if (process.platform !== 'linux' || !readConfine) { throw new BackendError('Jailed Codex subscription turns require Linux fs-jail read confinement', 'not_configured') } @@ -161,14 +187,14 @@ export async function prepareCodexJailAuth( return writeJailHome(auth, configPath, false) } -function writeJailHome(auth: FileAuth, configPath: string | undefined, subscription: boolean): { +function writeJailHome(auth: FileAuth | null, configPath: string | undefined, subscription: boolean): { homePath: string subscription: boolean cleanup(): void } { const homePath = mkdtempSync(join(tmpdir(), 'cli-bridge-codex-jail-')) try { - writeFileSync(join(homePath, 'auth.json'), JSON.stringify(auth), { mode: 0o600 }) + if (auth) writeFileSync(join(homePath, 'auth.json'), JSON.stringify(auth), { mode: 0o600 }) const config = configPath && existsSync(configPath) ? readFileSync(configPath) : '\n' writeFileSync(join(homePath, 'config.toml'), config, { mode: 0o600 }) return { diff --git a/src/backends/codex.ts b/src/backends/codex.ts index c549d3d..75b48fb 100644 --- a/src/backends/codex.ts +++ b/src/backends/codex.ts @@ -229,7 +229,6 @@ export class CodexBackend implements Backend { let jailHome: Awaited> | undefined try { if (req.jailSpec && this.spawner.executionEnvironment !== 'docker') { - if (!authSourcePath) throw new BackendError('Jailed Codex requires a selected CODEX_HOME', 'not_configured') // 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) @@ -237,7 +236,8 @@ export class CodexBackend implements Backend { jailHome = await prepareCodexJailAuth( this.opts.bin, authSourcePath, - codexHome ? join(codexHome.homePath, 'config.toml') : join(dirname(authSourcePath), 'config.toml'), + codexHome ? join(codexHome.homePath, 'config.toml') + : authSourcePath ? join(dirname(authSourcePath), 'config.toml') : undefined, req.execution?.timeoutMs ?? this.opts.timeoutMs, signal, req.jailSpec.readConfine === true, From e4de67405e17b4f0ff2535d8a4552ff698f3e48e Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 27 Sep 2026 16:58:22 -0600 Subject: [PATCH 05/14] fix(codex): preserve installed auth mode semantics --- src/backends/codex-auth.ts | 44 ++++++++++++++++++++++++++++++------- tests/codex-backend.test.ts | 1 + 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/backends/codex-auth.ts b/src/backends/codex-auth.ts index ea7972c..b4d28c5 100644 --- a/src/backends/codex-auth.ts +++ b/src/backends/codex-auth.ts @@ -46,15 +46,29 @@ export function isCodexSubscriptionAuth(path: string | undefined): boolean { return classifyFileAuth(readFileAuth(path)) === 'subscription' } -function classifyFileAuth(auth: FileAuth): 'subscription' | 'other' { - if (auth.auth_mode === 'chatgpt') return 'subscription' +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 auth.auth_mode === 'string' && OTHER_FILE_AUTH_MODES.has(auth.auth_mode)) return 'other' - if ((auth.auth_mode === undefined || auth.auth_mode === null) && typeof auth.OPENAI_API_KEY === 'string') return 'other' + if (typeof mode === 'string' && OTHER_FILE_AUTH_MODES.has(mode)) return 'other' throw new BackendError('Codex account auth mode is unrecognized', 'not_configured') } @@ -149,7 +163,8 @@ export async function prepareCodexJailAuth( if (!authPath || !authFileExists(authPath)) return writeJailHome(null, configPath, false) const auth = readFileAuth(authPath) const accountId = auth.tokens?.account_id - if (classifyFileAuth(auth) === 'subscription') { + 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') } @@ -166,11 +181,12 @@ export async function prepareCodexJailAuth( await refreshAccountWithCodex(bin, authPath, signal) } const current = readFileAuth(authPath) - if (current.auth_mode !== 'chatgpt' || current.tokens?.account_id !== accountId + 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') { + 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({ @@ -181,9 +197,21 @@ export async function prepareCodexJailAuth( account_id: accountId, refresh_token: '', }, - ...(typeof current.last_refresh === 'string' ? { last_refresh: current.last_refresh } : {}), + last_refresh: current.last_refresh, }, configPath, true) } + 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) + } return writeJailHome(auth, configPath, false) } diff --git a/tests/codex-backend.test.ts b/tests/codex-backend.test.ts index 431b9be..fc8386c 100644 --- a/tests/codex-backend.test.ts +++ b/tests/codex-backend.test.ts @@ -438,6 +438,7 @@ describe('CodexBackend jailed MCP visibility', () => { 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 From 6c78567dc84f497aa515b2d09562910972bede00 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 27 Sep 2026 17:29:39 -0600 Subject: [PATCH 06/14] fix(codex): honor effective auth and Docker account homes --- README.md | 5 + src/backends/codex-auth.ts | 5 + src/backends/codex.ts | 73 +++++++++------ src/backends/profile-support.ts | 78 +++++++++------- src/executors/docker.ts | 156 +++++++++++++++++++++++++++++++- src/executors/types.ts | 4 + src/server.ts | 2 + tests/codex-backend.test.ts | 4 +- 8 files changed, 262 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index c7ae4c4..1686d3b 100644 --- a/README.md +++ b/README.md @@ -629,6 +629,11 @@ The bridge refuses a ChatGPT subscription turn in `write-jail` because that mode 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 MCP settings through a private per-turn config file. +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. The bridge removes its temporary auth link and MCP configuration after the turn. diff --git a/src/backends/codex-auth.ts b/src/backends/codex-auth.ts index b4d28c5..620c950 100644 --- a/src/backends/codex-auth.ts +++ b/src/backends/codex-auth.ts @@ -46,6 +46,11 @@ export function isCodexSubscriptionAuth(path: string | undefined): boolean { 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' diff --git a/src/backends/codex.ts b/src/backends/codex.ts index 75b48fb..11ac0a1 100644 --- a/src/backends/codex.ts +++ b/src/backends/codex.ts @@ -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,7 +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 { isCodexSubscriptionAuth, prepareCodexJailAuth } from './codex-auth.js' +import { hasCodexEnvAuth, isCodexSubscriptionAuth, prepareCodexJailAuth } from './codex-auth.js' import { nativeReasoningControl } from '@tangle-network/agent-interface' import { assertRouterCodexExecution, @@ -92,18 +93,7 @@ export class CodexBackend implements Backend { session: SessionRecord | null, signal: AbortSignal, ): AsyncIterable { - // Unconfined ChatGPT may rotate the canonical file at any point in its - // native turn. API-key, Router, Docker and access-only jail turns do not. - const hostSubscription = !req.jailSpec - && this.spawner.executionEnvironment !== 'docker' - && process.env.BRIDGE_ROUTER_RECEIPTS_REQUIRED !== '1' - && isCodexSubscriptionAuth(resolveCodexAuthPath()) - const releaseHostAuth = hostSubscription ? await this.acquireAuthPreparation(signal) : undefined - try { - yield* this.chatOwned(req, session, signal) - } finally { - releaseHostAuth?.() - } + yield* this.chatOwned(req, session, signal) } private async *chatOwned( @@ -179,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) { @@ -206,6 +197,10 @@ export class CodexBackend implements Backend { // 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) : null const externalId = req.session_id ?? session?.externalId const nativeHome = this.opts.stateDir && externalId ? join(this.opts.stateDir, createHash('sha256').update(externalId).digest('hex')) @@ -219,23 +214,38 @@ export class CodexBackend implements Backend { ) } const authSourcePath = resolveCodexAuthPath() - const codexHome = materializeMcpServersForCodex( - mcpServers, - authSourcePath, - nativeHome && !legacySession - ? ensurePrivateDataDirectory(nativeHome) - : undefined, - ) + // 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 && this.spawner.executionEnvironment !== 'docker') { + 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, - authSourcePath, + hasCodexEnvAuth(launchEnv) ? undefined : authSourcePath, codexHome ? join(codexHome.homePath, 'config.toml') : authSourcePath ? join(dirname(authSourcePath), 'config.toml') : undefined, req.execution?.timeoutMs ?? this.opts.timeoutMs, @@ -251,7 +261,7 @@ export class CodexBackend implements Backend { args.splice(1, 0, '-c', 'cli_auth_credentials_store="file"') } } catch (error) { - codexHome?.cleanup() + try { codexHome?.cleanup() } finally { releaseHostAuth?.() } throw error } @@ -300,8 +310,14 @@ 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, @@ -316,6 +332,7 @@ export class CodexBackend implements Backend { ...(req.childLineage ? { lineageEnv: req.childLineage } : {}), ...(req.acquireDeadlineMs !== undefined ? { acquireDeadlineMs: req.acquireDeadlineMs } : {}), ...(req.admissionClass ? { admissionClass: req.admissionClass } : {}), + ...(dockerCodexConfig ? { 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. @@ -327,7 +344,9 @@ export class CodexBackend implements Backend { } } catch (error) { try { codexHome?.cleanup() } finally { - try { jailHome?.cleanup() } finally { record?.close(signal.aborted ? 'aborted' : 'failed') } + try { jailHome?.cleanup() } finally { + try { record?.close(signal.aborted ? 'aborted' : 'failed') } finally { releaseHostAuth?.() } + } } throw error } @@ -469,7 +488,7 @@ export class CodexBackend implements Backend { try { try { codexHome?.cleanup() } finally { jailHome?.cleanup() } } finally { - record?.close(signal.aborted ? 'aborted' : recordOutcome) + try { record?.close(signal.aborted ? 'aborted' : recordOutcome) } finally { releaseHostAuth?.() } } } } diff --git a/src/backends/profile-support.ts b/src/backends/profile-support.ts index 51ca349..8b6f5e6 100644 --- a/src/backends/profile-support.ts +++ b/src/backends/profile-support.ts @@ -1984,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 ?? {})) { @@ -2027,38 +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 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 - } - }, - } + return { lines, serverNames } } function stableTmpRoot(): string { diff --git a/src/executors/docker.ts b/src/executors/docker.ts index 9ae8def..d174945 100644 --- a/src/executors/docker.ts +++ b/src/executors/docker.ts @@ -37,6 +37,8 @@ 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 +75,66 @@ export function createDockerSpawner(opts: DockerSpawnerOptions): Spawner { ...(opts.envPrefix ? { envPrefix: opts.envPrefix } : {}), } const cli = opts.cli ?? dockerCli + let sharedCodexTail: Promise = Promise.resolve() + const acquireSharedCodex = async (signal: AbortSignal | undefined): Promise<() => void> => { + const prior = sharedCodexTail + let release!: () => void + const own = new Promise((resolve) => { release = resolve }) + sharedCodexTail = prior.then(() => own) + const onAbort = (): void => { release() } + let rejectAbort: ((reason: unknown) => void) | undefined + const aborted = new Promise((_, reject) => { rejectAbort = reject }) + 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 { + 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 releaseShared = opts.backend === 'codex' && (opts.oauthMode ?? 'share') === 'share' + ? await acquireSharedCodex(spawnOpts.signal) : undefined + let slot: Awaited> + try { + slot = await opts.pool.acquire(spawnOpts.sessionId, spawnOpts.acquireDeadlineMs, 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) { + 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 +154,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 +181,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 +219,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 +297,96 @@ 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 = replaceCodexMcpTables(base, config) + const prepare = `set -eu; ${paths}; if [ -e "$p" ]; then cp -p "$p" "$b"; chmod 600 "$b"; 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') + } +} + +function replaceCodexMcpTables(base: string, requested: string): string { + const names = new Set([...requested.matchAll(/^\[mcp_servers\.([A-Za-z0-9_-]+)\]$/gm)].map((match) => match[1])) + if (names.size === 0) throw new Error('Docker Codex MCP config has no server table') + const kept: string[] = [] + let skipSection = false + let section = '' + for (const line of base.split('\n')) { + const header = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$/.exec(line) + if (header) { + section = header[1]!.trim() + const server = /^mcp_servers\.(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))(?:\.|$)/.exec(section) + skipSection = !!server && names.has(server[1] ?? server[2] ?? server[3]!) + } + if (skipSection) continue + if (section === '' && /^\s*mcp_servers\s*=/.test(line)) { + throw new Error('Docker Codex base config uses an inline MCP table that cannot be replaced safely') + } + if (section === '' && [...names].some((name) => new RegExp(`^\\s*mcp_servers\\.(?:"${name}"|'${name}'|${name})(?:\\.|\\s*=)`).test(line))) continue + if (section === 'mcp_servers' && [...names].some((name) => new RegExp(`^\\s*(?:"${name}"|'${name}'|${name})(?:\\.|\\s*=)`).test(line))) continue + kept.push(line) + } + return `${kept.join('\n').trimEnd()}\n\n${requested}` +} + +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 [ -f "$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..bb1d05f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -312,6 +312,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 fc8386c..be8cb16 100644 --- a/tests/codex-backend.test.ts +++ b/tests/codex-backend.test.ts @@ -442,8 +442,8 @@ describe('CodexBackend jailed MCP visibility', () => { OPENAI_API_KEY: 'fixture-extra-secret', }), { mode: 0o600 }) process.env.CODEX_HOME = sourceHome - process.env.CODEX_ACCESS_TOKEN = 'fixture-ambient-access' - process.env.CODEX_API_KEY = 'fixture-ambient-api-key' + 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 From 9c3d76154143bfde569f353f44e5b45ae6437da2 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 27 Sep 2026 17:43:00 -0600 Subject: [PATCH 07/14] fix(codex): bound Docker session and MCP authority --- README.md | 8 +++-- src/backends/codex.ts | 8 ++--- src/executors/container-pool.ts | 34 +++++++++++++++----- src/executors/docker.ts | 56 +++++++++++++++++++++++---------- src/server.ts | 1 + 5 files changed, 77 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 1686d3b..0bde80f 100644 --- a/README.md +++ b/README.md @@ -618,7 +618,7 @@ 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. 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. @@ -631,7 +631,11 @@ Recognized non-ChatGPT file auth modes retain their existing credential behavior 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 MCP settings through a private per-turn config file. +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. +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. diff --git a/src/backends/codex.ts b/src/backends/codex.ts index 11ac0a1..85b84f2 100644 --- a/src/backends/codex.ts +++ b/src/backends/codex.ts @@ -200,9 +200,9 @@ export class CodexBackend implements Backend { 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) : null + 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) @@ -327,12 +327,12 @@ export class CodexBackend implements Backend { ...(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 ? { dockerCodexConfig } : {}), + ...(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. 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 d174945..7a180d5 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, @@ -76,14 +77,35 @@ export function createDockerSpawner(opts: DockerSpawnerOptions): Spawner { } const cli = opts.cli ?? dockerCli let sharedCodexTail: Promise = Promise.resolve() - const acquireSharedCodex = async (signal: AbortSignal | undefined): Promise<() => void> => { + 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 release!: () => void - const own = new Promise((resolve) => { release = resolve }) + 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) @@ -98,6 +120,7 @@ export function createDockerSpawner(opts: DockerSpawnerOptions): Spawner { release() throw error } finally { + clearTimeout(timeout) signal?.removeEventListener('abort', abortQueued) } } @@ -105,11 +128,18 @@ export function createDockerSpawner(opts: DockerSpawnerOptions): Spawner { const cwd = assertDockerWorkspaceCwd(opts.workspaceRoot, spawnOpts.cwd, naming) // 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) : undefined + ? await acquireSharedCodex(spawnOpts.signal, acquireDeadlineMs) : undefined let slot: Awaited> try { - slot = await opts.pool.acquire(spawnOpts.sessionId, spawnOpts.acquireDeadlineMs, spawnOpts.signal) + 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 @@ -129,7 +159,7 @@ export function createDockerSpawner(opts: DockerSpawnerOptions): Spawner { if (opts.backend === 'codex' && opts.containerConfigDir) { await recoverDockerCodexConfig(slot.containerId, opts.containerConfigDir) } - if (spawnOpts.dockerCodexConfig) { + 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, @@ -341,8 +371,6 @@ async function readDockerCodexConfig(containerId: string, configDir: string): Pr } function replaceCodexMcpTables(base: string, requested: string): string { - const names = new Set([...requested.matchAll(/^\[mcp_servers\.([A-Za-z0-9_-]+)\]$/gm)].map((match) => match[1])) - if (names.size === 0) throw new Error('Docker Codex MCP config has no server table') const kept: string[] = [] let skipSection = false let section = '' @@ -350,18 +378,14 @@ function replaceCodexMcpTables(base: string, requested: string): string { const header = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$/.exec(line) if (header) { section = header[1]!.trim() - const server = /^mcp_servers\.(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))(?:\.|$)/.exec(section) - skipSection = !!server && names.has(server[1] ?? server[2] ?? server[3]!) + const root = section.replace(/^(?:"mcp_servers"|'mcp_servers')(?=\.|$)/, 'mcp_servers') + skipSection = root === 'mcp_servers' || root.startsWith('mcp_servers.') } if (skipSection) continue - if (section === '' && /^\s*mcp_servers\s*=/.test(line)) { - throw new Error('Docker Codex base config uses an inline MCP table that cannot be replaced safely') - } - if (section === '' && [...names].some((name) => new RegExp(`^\\s*mcp_servers\\.(?:"${name}"|'${name}'|${name})(?:\\.|\\s*=)`).test(line))) continue - if (section === 'mcp_servers' && [...names].some((name) => new RegExp(`^\\s*(?:"${name}"|'${name}'|${name})(?:\\.|\\s*=)`).test(line))) continue + if (section === '' && /^\s*(?:mcp_servers|"mcp_servers"|'mcp_servers')(?:\s*=|\.)/.test(line)) continue kept.push(line) } - return `${kept.join('\n').trimEnd()}\n\n${requested}` + return `${kept.join('\n').trimEnd()}\n${requested ? `\n${requested}` : ''}` } async function recoverDockerCodexConfig(containerId: string, configDir: string): Promise { diff --git a/src/server.ts b/src/server.ts index bb1d05f..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, From e288147cc5ba4de58fb33200e243c07e6c7edfcc Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 27 Sep 2026 17:46:16 -0600 Subject: [PATCH 08/14] fix(codex): refuse unrecognized Docker MCP keys --- README.md | 1 + src/executors/docker.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0bde80f..202d7bf 100644 --- a/README.md +++ b/README.md @@ -633,6 +633,7 @@ For Codex exec, a nonempty `CODEX_API_KEY` or `CODEX_ACCESS_TOKEN` takes precede 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. 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. diff --git a/src/executors/docker.ts b/src/executors/docker.ts index 7a180d5..3048c51 100644 --- a/src/executors/docker.ts +++ b/src/executors/docker.ts @@ -378,11 +378,18 @@ function replaceCodexMcpTables(base: string, requested: string): string { const header = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$/.exec(line) if (header) { section = header[1]!.trim() - const root = section.replace(/^(?:"mcp_servers"|'mcp_servers')(?=\.|$)/, 'mcp_servers') + if (section.includes('\\')) throw new Error('Docker Codex base config has unsupported escaped table keys') + const root = section.replace(/\s*\.\s*/g, '.') + .replace(/^(?:"mcp_servers"|'mcp_servers')(?=\.|$)/, 'mcp_servers') skipSection = root === 'mcp_servers' || root.startsWith('mcp_servers.') } if (skipSection) continue - if (section === '' && /^\s*(?:mcp_servers|"mcp_servers"|'mcp_servers')(?:\s*=|\.)/.test(line)) continue + if (section === '' && /^\s*(?:mcp_servers|"mcp_servers"|'mcp_servers')(?:\s*=|\s*\.)/.test(line)) continue + // Refuse any MCP form the narrow rewrite did not recognize. Codex accepts + // quoted and spaced TOML keys; passing one through grants ambient tools. + if (line.includes('mcp_servers') || /^\s*["'][^"']*\\/.test(line)) { + throw new Error('Docker Codex base config has unsupported MCP key syntax') + } kept.push(line) } return `${kept.join('\n').trimEnd()}\n${requested ? `\n${requested}` : ''}` From a68dcfde27108fd6fea92545f767f6205b45a1a4 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 27 Sep 2026 17:50:09 -0600 Subject: [PATCH 09/14] fix(codex): parse Docker config key roots --- src/executors/docker.ts | 47 ++++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/src/executors/docker.ts b/src/executors/docker.ts index 3048c51..6c9c895 100644 --- a/src/executors/docker.ts +++ b/src/executors/docker.ts @@ -378,23 +378,50 @@ function replaceCodexMcpTables(base: string, requested: string): string { const header = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$/.exec(line) if (header) { section = header[1]!.trim() - if (section.includes('\\')) throw new Error('Docker Codex base config has unsupported escaped table keys') - const root = section.replace(/\s*\.\s*/g, '.') - .replace(/^(?:"mcp_servers"|'mcp_servers')(?=\.|$)/, 'mcp_servers') - skipSection = root === 'mcp_servers' || root.startsWith('mcp_servers.') + skipSection = tomlRootKey(section) === 'mcp_servers' } if (skipSection) continue - if (section === '' && /^\s*(?:mcp_servers|"mcp_servers"|'mcp_servers')(?:\s*=|\s*\.)/.test(line)) continue - // Refuse any MCP form the narrow rewrite did not recognize. Codex accepts - // quoted and spaced TOML keys; passing one through grants ambient tools. - if (line.includes('mcp_servers') || /^\s*["'][^"']*\\/.test(line)) { - throw new Error('Docker Codex base config has unsupported MCP key syntax') - } + const trimmed = line.trimStart() + if (!header && trimmed.startsWith('[')) throw new Error('Docker Codex base config has unsupported table syntax') + if (section === '' && !header && trimmed && !trimmed.startsWith('#') + && trimmed.includes('=') && tomlRootKey(trimmed) === 'mcp_servers') continue kept.push(line) } return `${kept.join('\n').trimEnd()}\n${requested ? `\n${requested}` : ''}` } +/** 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('Docker 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('Docker 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('Docker 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('Docker Codex base config has unsupported key syntax') + } + return key +} + 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 [ -f "$b" ]; then mv -f "$b" "$p"; elif [ -e "$m" ]; then rm -f "$p"; fi; rm -f "$m"` From ec607eabe4fbcb17b96feb704b0357313eec845c Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 27 Sep 2026 17:53:40 -0600 Subject: [PATCH 10/14] fix(codex): scope jailed MCP config to each turn --- README.md | 1 + src/backends/codex-auth.ts | 16 +++++++----- src/backends/codex.ts | 1 + src/codex-config.ts | 52 +++++++++++++++++++++++++++++++++++++ src/executors/docker.ts | 53 +------------------------------------- 5 files changed, 64 insertions(+), 59 deletions(-) create mode 100644 src/codex-config.ts diff --git a/README.md b/README.md index 202d7bf..5ef195b 100644 --- a/README.md +++ b/README.md @@ -641,6 +641,7 @@ Per-slot Docker turns do not inherit the bridge process's ambient Codex credenti 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. diff --git a/src/backends/codex-auth.ts b/src/backends/codex-auth.ts index 620c950..b3749fc 100644 --- a/src/backends/codex-auth.ts +++ b/src/backends/codex-auth.ts @@ -5,6 +5,7 @@ import { createInterface } from 'node:readline' import { hostSpawner } from '../executors/host.js' import { terminateSpawned } from '../executors/process-tree.js' import { BackendError } from './types.js' +import { replaceCodexMcpTables } from '../codex-config.js' interface FileAuth { auth_mode?: unknown @@ -162,10 +163,11 @@ export async function prepareCodexJailAuth( 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) + if (!authPath || !authFileExists(authPath)) return writeJailHome(null, configPath, false, configAlreadyScoped) const auth = readFileAuth(authPath) const accountId = auth.tokens?.account_id const mode = classifyFileAuth(auth) @@ -203,7 +205,7 @@ export async function prepareCodexJailAuth( refresh_token: '', }, last_refresh: current.last_refresh, - }, configPath, true) + }, configPath, true, configAlreadyScoped) } if (mode === 'external-access') { return writeJailHome({ @@ -215,12 +217,12 @@ export async function prepareCodexJailAuth( refresh_token: '', }, last_refresh: auth.last_refresh, - }, configPath, false) + }, configPath, false, configAlreadyScoped) } - return writeJailHome(auth, configPath, false) + return writeJailHome(auth, configPath, false, configAlreadyScoped) } -function writeJailHome(auth: FileAuth | null, configPath: string | undefined, subscription: boolean): { +function writeJailHome(auth: FileAuth | null, configPath: string | undefined, subscription: boolean, configAlreadyScoped: boolean): { homePath: string subscription: boolean cleanup(): void @@ -228,8 +230,8 @@ function writeJailHome(auth: FileAuth | null, configPath: string | undefined, su 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) : '\n' - writeFileSync(join(homePath, 'config.toml'), config, { mode: 0o600 }) + const config = configPath && existsSync(configPath) ? readFileSync(configPath, 'utf8') : '\n' + writeFileSync(join(homePath, 'config.toml'), configAlreadyScoped ? config : replaceCodexMcpTables(config, ''), { mode: 0o600 }) return { homePath, subscription, diff --git a/src/backends/codex.ts b/src/backends/codex.ts index 85b84f2..dd30e2c 100644 --- a/src/backends/codex.ts +++ b/src/backends/codex.ts @@ -251,6 +251,7 @@ export class CodexBackend implements Backend { req.execution?.timeoutMs ?? this.opts.timeoutMs, signal, req.jailSpec.readConfine === true, + Boolean(codexHome), ) } finally { releasePreparation() diff --git a/src/codex-config.ts b/src/codex-config.ts new file mode 100644 index 0000000..e4d8a7b --- /dev/null +++ b/src/codex-config.ts @@ -0,0 +1,52 @@ +/** Keep account settings while giving a turn only its declared MCP servers. */ +export function replaceCodexMcpTables(base: string, requested: string): string { + const kept: string[] = [] + let skipSection = false + let section = '' + for (const line of base.split('\n')) { + const header = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$/.exec(line) + if (header) { + section = header[1]!.trim() + skipSection = tomlRootKey(section) === 'mcp_servers' + } + if (skipSection) continue + const trimmed = line.trimStart() + if (!header && trimmed.startsWith('[')) throw new Error('Codex base config has unsupported table syntax') + if (section === '' && !header && trimmed && !trimmed.startsWith('#') + && trimmed.includes('=') && tomlRootKey(trimmed) === 'mcp_servers') continue + kept.push(line) + } + return `${kept.join('\n').trimEnd()}\n${requested ? `\n${requested}` : ''}` +} + +/** 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/docker.ts b/src/executors/docker.ts index 6c9c895..ecdd797 100644 --- a/src/executors/docker.ts +++ b/src/executors/docker.ts @@ -33,6 +33,7 @@ import { type Spawner, } from './types.js' import { withLineageEnv } from '../trace/lineage.js' +import { replaceCodexMcpTables } from '../codex-config.js' const execFileAsync = promisify(execFile) @@ -370,58 +371,6 @@ async function readDockerCodexConfig(containerId: string, configDir: string): Pr } } -function replaceCodexMcpTables(base: string, requested: string): string { - const kept: string[] = [] - let skipSection = false - let section = '' - for (const line of base.split('\n')) { - const header = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$/.exec(line) - if (header) { - section = header[1]!.trim() - skipSection = tomlRootKey(section) === 'mcp_servers' - } - if (skipSection) continue - const trimmed = line.trimStart() - if (!header && trimmed.startsWith('[')) throw new Error('Docker Codex base config has unsupported table syntax') - if (section === '' && !header && trimmed && !trimmed.startsWith('#') - && trimmed.includes('=') && tomlRootKey(trimmed) === 'mcp_servers') continue - kept.push(line) - } - return `${kept.join('\n').trimEnd()}\n${requested ? `\n${requested}` : ''}` -} - -/** 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('Docker 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('Docker 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('Docker 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('Docker Codex base config has unsupported key syntax') - } - return key -} - 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 [ -f "$b" ]; then mv -f "$b" "$p"; elif [ -e "$m" ]; then rm -f "$p"; fi; rm -f "$m"` From 2ec1c97d55b479d5c7d5102c7bd51925a315abf4 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 27 Sep 2026 18:05:32 -0600 Subject: [PATCH 11/14] fix(codex): exclude trusted project config from scoped turns --- README.md | 1 + src/backends/codex-auth.ts | 4 ++-- src/codex-config.ts | 37 +++++++++++++++++++++++++++++++++---- src/executors/docker.ts | 4 ++-- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5ef195b..bf9a047 100644 --- a/README.md +++ b/README.md @@ -634,6 +634,7 @@ The bridge preserves that effective credential in a jail and does not seed an un 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. diff --git a/src/backends/codex-auth.ts b/src/backends/codex-auth.ts index b3749fc..98fa9cc 100644 --- a/src/backends/codex-auth.ts +++ b/src/backends/codex-auth.ts @@ -5,7 +5,7 @@ import { createInterface } from 'node:readline' import { hostSpawner } from '../executors/host.js' import { terminateSpawned } from '../executors/process-tree.js' import { BackendError } from './types.js' -import { replaceCodexMcpTables } from '../codex-config.js' +import { scopeCodexTurnConfig } from '../codex-config.js' interface FileAuth { auth_mode?: unknown @@ -231,7 +231,7 @@ function writeJailHome(auth: FileAuth | null, configPath: string | undefined, su 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 : replaceCodexMcpTables(config, ''), { mode: 0o600 }) + writeFileSync(join(homePath, 'config.toml'), configAlreadyScoped ? config : scopeCodexTurnConfig(config, ''), { mode: 0o600 }) return { homePath, subscription, diff --git a/src/codex-config.ts b/src/codex-config.ts index e4d8a7b..494fbe2 100644 --- a/src/codex-config.ts +++ b/src/codex-config.ts @@ -1,5 +1,5 @@ -/** Keep account settings while giving a turn only its declared MCP servers. */ -export function replaceCodexMcpTables(base: string, requested: string): string { +/** 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 = '' @@ -7,18 +7,47 @@ export function replaceCodexMcpTables(base: string, requested: string): string { const header = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$/.exec(line) if (header) { section = header[1]!.trim() - skipSection = tomlRootKey(section) === 'mcp_servers' + const root = tomlRootKey(section) + skipSection = root === 'mcp_servers' || root === 'projects' } if (skipSection) continue const trimmed = line.trimStart() if (!header && trimmed.startsWith('[')) throw new Error('Codex base config has unsupported table syntax') if (section === '' && !header && trimmed && !trimmed.startsWith('#') - && trimmed.includes('=') && tomlRootKey(trimmed) === 'mcp_servers') continue + && 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}` : ''}` } +/** 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() diff --git a/src/executors/docker.ts b/src/executors/docker.ts index ecdd797..0320b8a 100644 --- a/src/executors/docker.ts +++ b/src/executors/docker.ts @@ -33,7 +33,7 @@ import { type Spawner, } from './types.js' import { withLineageEnv } from '../trace/lineage.js' -import { replaceCodexMcpTables } from '../codex-config.js' +import { scopeCodexTurnConfig } from '../codex-config.js' const execFileAsync = promisify(execFile) @@ -344,7 +344,7 @@ async function installDockerCodexConfig( 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 = replaceCodexMcpTables(base, config) + const merged = scopeCodexTurnConfig(base, config) const prepare = `set -eu; ${paths}; if [ -e "$p" ]; then cp -p "$p" "$b"; chmod 600 "$b"; else umask 077; : > "$m"; fi; rm -f "$p"; umask 077; cat > "$p"` try { await dockerExecWithStdin(containerId, prepare, merged) From c3f66477e635e3a7387714aff1ddc9fce3a146a4 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 27 Sep 2026 18:11:36 -0600 Subject: [PATCH 12/14] fix(codex): preserve multiline account settings in scoped config --- src/codex-config.ts | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/codex-config.ts b/src/codex-config.ts index 494fbe2..fe6312e 100644 --- a/src/codex-config.ts +++ b/src/codex-config.ts @@ -3,8 +3,11 @@ 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 header = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$/.exec(line) + 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) @@ -12,8 +15,8 @@ export function scopeCodexTurnConfig(base: string, requested: string): string { } if (skipSection) continue const trimmed = line.trimStart() - if (!header && trimmed.startsWith('[')) throw new Error('Codex base config has unsupported table syntax') - if (section === '' && !header && trimmed && !trimmed.startsWith('#') + 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') @@ -25,6 +28,33 @@ export function scopeCodexTurnConfig(base: string, requested: string): string { 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() From 2b6e10cd26f63ebb4dfbb42ec6b8450a9192a462 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 27 Sep 2026 18:20:37 -0600 Subject: [PATCH 13/14] fix(codex): preserve symlinked Docker account config --- src/executors/docker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/executors/docker.ts b/src/executors/docker.ts index 0320b8a..ec9ca71 100644 --- a/src/executors/docker.ts +++ b/src/executors/docker.ts @@ -345,7 +345,7 @@ async function installDockerCodexConfig( 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" ]; then cp -p "$p" "$b"; chmod 600 "$b"; else umask 077; : > "$m"; fi; rm -f "$p"; umask 077; cat > "$p"` + 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 { From cb3303fdf674c21453aa2e71bccc3e3dc956ede9 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 27 Sep 2026 18:26:13 -0600 Subject: [PATCH 14/14] fix(codex): restore dangling Docker config links --- src/executors/docker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/executors/docker.ts b/src/executors/docker.ts index ec9ca71..bc0ae61 100644 --- a/src/executors/docker.ts +++ b/src/executors/docker.ts @@ -373,7 +373,7 @@ async function readDockerCodexConfig(containerId: string, configDir: string): Pr 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 [ -f "$b" ]; then mv -f "$b" "$p"; elif [ -e "$m" ]; then rm -f "$p"; fi; rm -f "$m"` + 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 {