Something went wrong. Try again.
A collaborative coding-agent orchestrator for atproto radl.app
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220import { 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 * 1024const 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<string, unknown> { 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<void> { 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<void>}
/** * 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<void> = Promise.resolve()
constructor(directory: string) { this.directory = directory this.path = join(directory, AUTH_FILENAME) }
async validate(): Promise<void> { await readAuth(this.path) await chmod(this.directory, 0o700) await chmod(this.path, 0o600) }
async acquire(runDir: string): Promise<CodexAuthLease> { let release!: () => void const gate = new Promise<void>((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<string, string> { const clean: Record<string, string> = {} 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<string, string>): Promise<void> { 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<string, string>) => Promise<void> } = {},): Promise<void> { 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 }) }}