import { spawn } from 'node:child_process' import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import type { Environment } from './config.js' const AUTH_FILENAME = 'auth.json' const AUTH_LIMIT = 1024 * 1024 const SCRATCH_CONFIG = 'cli_auth_credentials_store = "file"\n' interface ManagedAuth { auth_mode: 'chatgpt' tokens: { access_token: string id_token: string refresh_token: string } last_refresh?: string } function object(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } /** * Validate the narrow auth shape Radial is willing to persist. A turn controls its scratch home, * so accepting arbitrary JSON back would turn that directory into cross-turn persistence. */ export function parseManagedCodexAuth(raw: string, source = AUTH_FILENAME): ManagedAuth { if (raw.length > AUTH_LIMIT) throw new Error(`${source} exceeds ${AUTH_LIMIT} bytes`) let value: unknown try { value = JSON.parse(raw) } catch { throw new Error(`${source} is not valid JSON`) } if (!object(value) || value.auth_mode !== 'chatgpt' || !object(value.tokens)) { throw new Error(`${source} is not a ChatGPT-managed Codex login (expected auth_mode "chatgpt")`) } const accessToken = value.tokens.access_token const idToken = value.tokens.id_token const refreshToken = value.tokens.refresh_token if ( typeof accessToken !== 'string' || accessToken.length === 0 || typeof idToken !== 'string' || idToken.length === 0 || typeof refreshToken !== 'string' || refreshToken.length === 0 ) { throw new Error(`${source} has an incomplete ChatGPT token bundle`) } return { auth_mode: 'chatgpt', tokens: { access_token: accessToken, id_token: idToken, refresh_token: refreshToken, }, ...(typeof value.last_refresh === 'string' ? { last_refresh: value.last_refresh } : {}), } } async function readAuth(path: string): Promise<{ raw: string; auth: ManagedAuth }> { const info = await lstat(path).catch((error: unknown) => { if (object(error) && error.code === 'ENOENT') { throw new Error(`No managed Codex login at ${path}; run "radiald codex login"`) } throw error }) if (!info.isFile() || info.isSymbolicLink()) throw new Error(`${path} must be a regular file`) const raw = await readFile(path, 'utf8') return { raw, auth: parseManagedCodexAuth(raw, path) } } async function atomicWrite(path: string, raw: string): Promise { const temporary = `${path}.tmp-${process.pid}` await writeFile(temporary, raw, { mode: 0o600 }) await chmod(temporary, 0o600) await rename(temporary, path) await chmod(path, 0o600) } export interface CodexAuthLease { /** Host directory mounted as the turn's otherwise-isolated CODEX_HOME. */ directory: string /** Token values scrubbed from live output and saved diagnostics. */ secrets: string[] /** Match the host owner of the 0600 scratch files on native Linux Docker bind mounts. */ uid: number gid: number /** Persist a valid refresh result, discard every other scratch file, and release serialization. */ finish(): Promise } /** * One refreshable ChatGPT login owned by a Radial instance. * * The master auth file is never mounted. Each turn receives only a scratch CODEX_HOME containing a * copy plus a daemon-authored config. A promise chain serializes leases because Codex refresh-token * rotation requires one job stream per auth copy. */ export class CodexAuthStore { readonly directory: string readonly path: string #tail: Promise = Promise.resolve() constructor(directory: string) { this.directory = directory this.path = join(directory, AUTH_FILENAME) } async validate(): Promise { await readAuth(this.path) await chmod(this.directory, 0o700) await chmod(this.path, 0o600) } async acquire(runDir: string): Promise { let release!: () => void const gate = new Promise((resolve) => { release = resolve }) const previous = this.#tail this.#tail = previous.then(() => gate) await previous const scratch = join(runDir, 'codex-home') try { const { raw, auth } = await readAuth(this.path) await mkdir(scratch, { recursive: true, mode: 0o700 }) await chmod(scratch, 0o700) await writeFile(join(scratch, AUTH_FILENAME), raw, { mode: 0o600 }) await chmod(join(scratch, AUTH_FILENAME), 0o600) await writeFile(join(scratch, 'config.toml'), SCRATCH_CONFIG, { mode: 0o600 }) await chmod(join(scratch, 'config.toml'), 0o600) let finished = false return { directory: scratch, secrets: [ auth.tokens.access_token, auth.tokens.id_token, auth.tokens.refresh_token, ], uid: process.getuid?.() ?? 1000, gid: process.getgid?.() ?? 1000, finish: async () => { if (finished) return finished = true try { const refreshed = await readAuth(join(scratch, AUTH_FILENAME)) // Persist only the validated auth document. Config, rollouts, caches, plugins and any // other turn-created files die with runDir and can never influence a later turn. await mkdir(this.directory, { recursive: true, mode: 0o700 }) await chmod(this.directory, 0o700) await atomicWrite(this.path, refreshed.raw) } finally { release() } }, } } catch (error) { release() throw error } } } /** Remove credentials that could silently override the dedicated ChatGPT login. */ export function codexLoginEnvironment(env: Environment = process.env): Record { const clean: Record = {} for (const [name, value] of Object.entries(env)) { if (value === undefined) continue if (name === 'CODEX_API_KEY' || name === 'CODEX_ACCESS_TOKEN' || name === 'OPENAI_API_KEY') continue clean[name] = value } return clean } function spawnInteractive(command: string, args: string[], env: Record): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { env, stdio: 'inherit' } as never) child.on('error', reject) child.on('close', (code) => { if (code === 0) resolve() else reject(new Error(`${command} ${args.join(' ')} exited ${code ?? -1}`)) }) }) } /** * Create a fresh login lineage for Radial. It deliberately does not copy ~/.codex/auth.json: * another Codex surface refreshing that copy could invalidate Radial's session. */ export async function loginManagedCodex( directory: string, options: { env?: Environment run?: (command: string, args: string[], env: Record) => Promise } = {}, ): Promise { const loginDir = join(directory, 'login') await rm(loginDir, { recursive: true, force: true }) await mkdir(loginDir, { recursive: true, mode: 0o700 }) await chmod(loginDir, 0o700) const env = { ...codexLoginEnvironment(options.env), CODEX_HOME: loginDir } try { await (options.run ?? spawnInteractive)( 'codex', ['login', '--device-auth', '-c', 'cli_auth_credentials_store="file"'], env, ) const { raw } = await readAuth(join(loginDir, AUTH_FILENAME)) await mkdir(directory, { recursive: true, mode: 0o700 }) await chmod(directory, 0o700) await atomicWrite(join(directory, AUTH_FILENAME), raw) } finally { await rm(loginDir, { recursive: true, force: true }) } }