diff --git a/cli/__tests__/adapters.codex.test.mjs b/cli/__tests__/adapters.codex.test.mjs index b5b474a8a4..9011653fa3 100644 --- a/cli/__tests__/adapters.codex.test.mjs +++ b/cli/__tests__/adapters.codex.test.mjs @@ -13,7 +13,9 @@ import { jest } from '@jest/globals'; import { EventEmitter } from 'events'; -import { existsSync, readFileSync, statSync } from 'fs'; +import { + existsSync, readdirSync, readFileSync, realpathSync, statSync, +} from 'fs'; import { lstat, mkdir, @@ -21,6 +23,7 @@ import { readFile, readlink, rm, + symlink, writeFile, } from 'fs/promises'; import { tmpdir } from 'os'; @@ -32,7 +35,7 @@ await jest.unstable_mockModule('child_process', () => ({ spawn: jest.fn(), })); -const codex = (await import('../src/lib/adapters/codex.js')).default; +const { default: codex, codexNativeBinary } = await import('../src/lib/adapters/codex.js'); /** * The value the LAUNCHER exported for bootstrap, planted explicitly rather than @@ -1081,3 +1084,209 @@ describe('codex: the carve-out for a reference the file channel cannot carry (TA expect(env.COMMONLY_AGENT_TOKEN).toBeUndefined(); }); }); + +// Codex 0.164 runs its file helper from $CODEX_HOME/tmp/arg0-linux inside +// bwrap, and a denied ancestor always wins, so on Linux a public home under +// ~/.commonly failed every session at bootstrap. These pin where it lives now. +describe('codex adapter — the public Codex home on Linux', () => { + const publicSpawn = async (fakeHome, { platform = 'linux', cwd, env = {}, extra = {} } = {}) => { + const { impl, calls } = makeSpawnImpl({ + stdoutChunks: ['{"type":"thread.started","thread_id":"sid-home"}\n'], + outputContents: 'ok', + }); + const baseEnv = { ...process.env, CODEX_HOME: join(fakeHome, '.codex'), ...env }; + if (!env.XDG_STATE_HOME) delete baseEnv.XDG_STATE_HOME; + await codex.spawn('hi', { + sessionId: null, + cwd, + environment: { model: 'test-model', sandbox: { trust: 'public', mode: 'workspace' } }, + env: baseEnv, + agentName: 'linux-public-seat', + _homeDir: fakeHome, + _platform: platform, + _codexNativeBinary: () => '/opt/codex-install/bin/codex', + _spawnImpl: impl, + ...extra, + }); + return calls; + }; + const filesystemFlag = (calls) => calls[0].args.find((a) => a.startsWith('permissions.commonly_public.filesystem=')); + + let fakeHome; + let project; + beforeEach(async () => { + fakeHome = await mkdtemp(join(tmpdir(), 'commonly-codex-fake-home-')); + project = join(fakeHome, 'project'); + await mkdir(project); + }); + afterEach(async () => { + await rm(fakeHome, { recursive: true, force: true }); + }); + + test('the helper grant names the codex binary found on the PATH the child gets', async () => { + const seen = []; + await publicSpawn(fakeHome, { + cwd: project, + extra: { + runtimeToken: 'cm_agent_secret', + _codexNativeBinary: (env) => { seen.push(env.PATH); return '/opt/codex-install/bin/codex'; }, + environment: { + model: 'test-model', + sandbox: { trust: 'public', mode: 'workspace' }, + mcp: [{ + name: 'local', + transport: 'stdio', + command: ['/usr/bin/true'], + env: { PATH: '/opt/forwarded/bin:${COMMONLY_AGENT_TOKEN}' }, + }], + }, + }, + }); + expect(seen).toEqual(['/opt/forwarded/bin:cm_agent_secret']); + }); + + test('on Linux the home is under the XDG state dir, outside the denied ~/.commonly', async () => { + const calls = await publicSpawn(fakeHome, { cwd: project }); + const home = calls[0].opts.env.CODEX_HOME; + expect(dirname(home)).toBe(join(fakeHome, '.local', 'state', 'commonly', 'codex-homes')); + expect(existsSync(home)).toBe(true); + expect(existsSync(join(fakeHome, '.commonly'))).toBe(false); + // The profile still denies ~/.commonly; the home is simply not under it. + expect(filesystemFlag(calls)).toContain('"~/.commonly"="deny"'); + }); + + test('on Linux the profile grants read on the arg0 helper folder and the codex binary, nothing wider', async () => { + const calls = await publicSpawn(fakeHome, { cwd: project }); + const home = calls[0].opts.env.CODEX_HOME; + const filesystem = filesystemFlag(calls); + expect(filesystem).toContain(`"${join(home, 'tmp', 'arg0-linux')}"="read"`); + expect(filesystem).toContain('"/opt/codex-install/bin/codex"="read"'); + expect(filesystem).not.toContain(`"${home}"=`); + expect(existsSync(join(home, 'tmp', 'arg0-linux'))).toBe(true); + }); + + test('on macOS no helper grant is added', async () => { + const calls = await publicSpawn(fakeHome, { platform: 'darwin', cwd: project }); + expect(filesystemFlag(calls)).not.toContain('arg0-linux'); + expect(filesystemFlag(calls)).not.toContain('/opt/codex-install'); + }); + + test('an absolute XDG_STATE_HOME is honoured', async () => { + const state = join(fakeHome, 'state-elsewhere'); + const calls = await publicSpawn(fakeHome, { cwd: project, env: { XDG_STATE_HOME: state } }); + expect(dirname(calls[0].opts.env.CODEX_HOME)).toBe(join(state, 'commonly', 'codex-homes')); + }); + + test('an XDG_STATE_HOME under ~/.commonly is refused before anything is created', async () => { + const state = join(fakeHome, '.commonly', 'state'); + await expect(publicSpawn(fakeHome, { cwd: project, env: { XDG_STATE_HOME: state } })) + .rejects.toThrow(/which the public sandbox denies on Linux/); + expect(existsSync(state)).toBe(false); + }); + + test('an XDG_STATE_HOME that reaches ~/.commonly through a symlink is refused too', async () => { + const first = await publicSpawn(fakeHome, { platform: 'darwin', cwd: project }); + const legacy = first[0].opts.env.CODEX_HOME; + await mkdir(join(legacy, 'sessions'), { recursive: true }); + await writeFile(join(legacy, 'sessions', 'thread.jsonl'), 'kept\n'); + const target = join(fakeHome, '.commonly', 'state'); + await mkdir(target, { recursive: true }); + await symlink(target, join(fakeHome, 'state-link')); + + await expect(publicSpawn(fakeHome, { cwd: project, env: { XDG_STATE_HOME: join(fakeHome, 'state-link') } })) + .rejects.toThrow(/which the public sandbox denies on Linux/); + expect(readFileSync(join(legacy, 'sessions', 'thread.jsonl'), 'utf8')).toBe('kept\n'); + expect(readdirSync(target)).toEqual([]); + }); + + test('on macOS the home stays under ~/.commonly', async () => { + const calls = await publicSpawn(fakeHome, { platform: 'darwin', cwd: project }); + expect(dirname(calls[0].opts.env.CODEX_HOME)).toBe(join(fakeHome, '.commonly', 'codex-homes')); + }); + + test('a home left under ~/.commonly by an earlier run moves, sessions included', async () => { + const first = await publicSpawn(fakeHome, { platform: 'darwin', cwd: project }); + const legacy = first[0].opts.env.CODEX_HOME; + await mkdir(join(legacy, 'sessions'), { recursive: true }); + await writeFile(join(legacy, 'sessions', 'thread.jsonl'), 'kept\n'); + + const calls = await publicSpawn(fakeHome, { cwd: project }); + const home = calls[0].opts.env.CODEX_HOME; + expect(readFileSync(join(home, 'sessions', 'thread.jsonl'), 'utf8')).toBe('kept\n'); + expect(existsSync(legacy)).toBe(false); + }); + + test('a refused workspace never receives the migrated sessions', async () => { + const first = await publicSpawn(fakeHome, { platform: 'darwin', cwd: project }); + const legacy = first[0].opts.env.CODEX_HOME; + await mkdir(join(legacy, 'sessions'), { recursive: true }); + await writeFile(join(legacy, 'sessions', 'thread.jsonl'), 'kept\n'); + + const state = join(project, 'state'); + await expect(publicSpawn(fakeHome, { cwd: project, env: { XDG_STATE_HOME: state } })) + .rejects.toThrow(/contains the agent's own Codex home/); + expect(readFileSync(join(legacy, 'sessions', 'thread.jsonl'), 'utf8')).toBe('kept\n'); + expect(existsSync(state)).toBe(false); + }); + + test('a refused workspace is found through a symlinked state directory', async () => { + const first = await publicSpawn(fakeHome, { platform: 'darwin', cwd: project }); + const legacy = first[0].opts.env.CODEX_HOME; + await mkdir(join(legacy, 'sessions'), { recursive: true }); + await writeFile(join(legacy, 'sessions', 'thread.jsonl'), 'kept\n'); + + // The state path names no part of the workspace; only resolving the link shows it is inside. + await symlink(project, join(fakeHome, 'link')); + await expect(publicSpawn(fakeHome, { cwd: project, env: { XDG_STATE_HOME: join(fakeHome, 'link', 'state') } })) + .rejects.toThrow(/contains the agent's own Codex home/); + expect(readFileSync(join(legacy, 'sessions', 'thread.jsonl'), 'utf8')).toBe('kept\n'); + expect(existsSync(join(project, 'state'))).toBe(false); + }); + + test('a workspace that contains the home is refused before Codex runs', async () => { + const { impl, calls } = makeSpawnImpl({ outputContents: 'ok' }); + const env = { ...process.env, CODEX_HOME: join(fakeHome, '.codex') }; + delete env.XDG_STATE_HOME; + await expect(codex.spawn('hi', { + sessionId: null, + cwd: fakeHome, + environment: { model: 'test-model', sandbox: { trust: 'public', mode: 'workspace' } }, + env, + agentName: 'linux-public-seat', + _homeDir: fakeHome, + _platform: 'linux', + _spawnImpl: impl, + })).rejects.toThrow(/contains the agent's own Codex home/); + expect(calls).toHaveLength(0); + }); +}); + +describe('codexNativeBinary — the binary `codex` on PATH runs', () => { + const targets = { x64: 'x86_64-unknown-linux-musl', arm64: 'aarch64-unknown-linux-musl' }; + let root; + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'commonly-codex-bin-')); }); + afterEach(async () => { await rm(root, { recursive: true, force: true }); }); + + (targets[process.arch] ? test : test.skip)('follows the npm launcher to its platform package binary', async () => { + const pkg = join(root, 'lib', 'node_modules', '@openai', 'codex'); + await mkdir(join(pkg, 'bin'), { recursive: true }); + await writeFile(join(pkg, 'bin', 'codex.js'), '#!/usr/bin/env node\n', { mode: 0o755 }); + const platformPkg = join(pkg, 'node_modules', '@openai', `codex-linux-${process.arch}`); + const vendorBin = join(platformPkg, 'vendor', targets[process.arch], 'bin'); + await mkdir(vendorBin, { recursive: true }); + await writeFile(join(platformPkg, 'package.json'), '{"name":"platform"}'); + await writeFile(join(vendorBin, 'codex'), '', { mode: 0o755 }); + await mkdir(join(root, 'bin')); + await symlink(join(pkg, 'bin', 'codex.js'), join(root, 'bin', 'codex')); + + expect(codexNativeBinary({ PATH: `/nonexistent:${join(root, 'bin')}` })) + .toBe(realpathSync(join(vendorBin, 'codex'))); + }); + + test('returns a native binary on PATH as itself, and null when there is none', async () => { + await mkdir(join(root, 'bin')); + await writeFile(join(root, 'bin', 'codex'), '', { mode: 0o755 }); + expect(codexNativeBinary({ PATH: join(root, 'bin') })).toBe(realpathSync(join(root, 'bin', 'codex'))); + expect(codexNativeBinary({ PATH: '/nonexistent' })).toBeNull(); + }); +}); diff --git a/cli/src/lib/adapters/codex.js b/cli/src/lib/adapters/codex.js index 51918e543b..97352a9c3b 100644 --- a/cli/src/lib/adapters/codex.js +++ b/cli/src/lib/adapters/codex.js @@ -43,7 +43,7 @@ import { spawnInOwnGroup, wantsOwnProcessGroup } from '@commonlyai/seats/process import { requireSeatModel, seatModelRequired } from './seat-model.js'; import { spawn as childSpawn, spawnSync } from 'child_process'; import { createHash } from 'crypto'; -import { accessSync, constants } from 'fs'; +import { accessSync, constants, existsSync, realpathSync } from 'fs'; import { chmod, lstat, @@ -51,6 +51,7 @@ import { mkdtemp, readFile, readlink, + realpath, rename, rm, symlink, @@ -58,10 +59,15 @@ import { writeFile, } from 'fs/promises'; import { homedir, tmpdir } from 'os'; +import { createRequire } from 'module'; import { + basename, + delimiter, dirname, + isAbsolute, join, resolve as pathResolve, + sep, } from 'path'; import { CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js'; import { supportedGitHooks } from '../commit-attribution.js'; @@ -274,20 +280,116 @@ const statOrNull = async (path) => { // therefore expose global private instructions even though shell reads of // ~/.codex are denied. Give every public wrapper identity its own persistent // Codex home for sessions/state, with only an auth symlink back to the -// operator credential. The symlink itself lives under ~/.commonly, which the -// permission profile denies to model-generated commands. -const preparePublicCodexHome = async (ctx) => { +// operator credential. On macOS the symlink lives under ~/.commonly, which the +// permission profile denies to model-generated commands; on Linux (below) its +// target, ~/.codex, is denied instead. +// +// On Linux the home cannot sit under ~/.commonly. Codex (0.164 and later) +// runs its file helper by exec'ing `$CODEX_HOME/tmp/arg0-linux/.../ +// codex-linux-sandbox` inside bwrap, and grants read on that one file, but a +// denied ancestor always wins, so the profile's ~/.commonly deny made every +// public session fail at bootstrap with "bwrap: execvp ... Permission denied". +// Outside every granted root the home is still unreadable to model commands: +// Codex binds the helper file alone, not the home around it (measured on +// codex-cli 0.164.0, where a home there reached the model call). That holds +// only while the workspace does not contain the home, so such a workspace is +// refused. macOS has no arg0 helper and keeps its homes where they were. +const LEGACY_CODEX_HOMES = (home) => join(home, '.commonly', 'codex-homes'); + +const contains = (outer, inner) => inner === outer || inner.startsWith(outer.endsWith(sep) ? outer : outer + sep); + +const stateHomeUnderCommonly = (ctx, home) => new Error( + `XDG_STATE_HOME (${ctx.env?.XDG_STATE_HOME}) puts the public Codex home under ${join(home, '.commonly')}, ` + + 'which the public sandbox denies on Linux. Point it elsewhere or unset it.', +); + +const publicCodexHomesRoot = (ctx) => { + const home = ctx._homeDir || homedir(); + if ((ctx._platform || process.platform) !== 'linux') return LEGACY_CODEX_HOMES(home); + const stateHome = ctx.env?.XDG_STATE_HOME; + const root = join(stateHome && isAbsolute(stateHome) ? stateHome : join(home, '.local', 'state'), + 'commonly', 'codex-homes'); + // An override that lands back under the denied tree would bring the + // bootstrap failure above back with it. Checked here by name, and again + // through symlinks before anything is created (preparePublicCodexHome). + if (contains(pathResolve(home, '.commonly'), pathResolve(root))) throw stateHomeUnderCommonly(ctx, home); + return root; +}; + +// The real path of a path that may not exist yet: its nearest existing +// ancestor resolved, with the rest appended. +const realpathOfNearest = async (path) => { + const rest = []; + let current = pathResolve(path); + for (;;) { + try { + return join(await realpath(current), ...rest); + } catch (err) { + // Only a missing path walks up. Anything else (EACCES, ELOOP) would leave + // the containment check comparing a path nobody resolved: fail closed. + if (err?.code !== 'ENOENT' && err?.code !== 'ENOTDIR') throw err; + const parent = dirname(current); + if (parent === current) return join(current, ...rest); + rest.unshift(basename(current)); + current = parent; + } + } +}; + +const preparePublicCodexHome = async (ctx, childEnv) => { const operatorHome = ctx.env?.CODEX_HOME || process.env.CODEX_HOME || join(homedir(), '.codex'); const identity = ctx.agentName || ctx.cwd || 'anonymous'; const identityHash = createHash('sha256').update(identity).digest('hex').slice(0, 20); - const publicHome = ctx._publicCodexHome - || join(homedir(), '.commonly', 'codex-homes', identityHash); + const homesRoot = publicCodexHomesRoot(ctx); + const publicHome = ctx._publicCodexHome || join(homesRoot, identityHash); if (pathResolve(publicHome) === pathResolve(operatorHome)) { throw new Error('public codex home must be isolated from the operator CODEX_HOME'); } + // Checked before anything is created or moved, so a refused workspace + // never receives the home or a migrated seat's sessions. + if (!ctx._publicCodexHome && homesRoot !== LEGACY_CODEX_HOMES(ctx._homeDir || homedir())) { + const home = ctx._homeDir || homedir(); + const [realRoot, realCommonly] = await Promise.all([realpathOfNearest(homesRoot), realpathOfNearest(join(home, '.commonly'))]); + if (contains(realCommonly, realRoot)) throw stateHomeUnderCommonly(ctx, home); + const workspacePath = pathResolve(ctx.cwd || process.cwd()); + const [workspace, realHome] = await Promise.all([ + // As in realpathOfNearest: only a missing workspace falls back to its own path. + realpath(workspacePath).catch((err) => { + if (err?.code === 'ENOENT' || err?.code === 'ENOTDIR') return workspacePath; + throw err; + }), + realpathOfNearest(publicHome), + ]); + if (contains(workspace, realHome)) { + throw new Error(`public codex agents cannot run in ${ctx.cwd}: it contains the agent's own ` + + `Codex home (${publicHome}), which the sandbox would then expose. Run it in a project folder.`); + } + } + + // A Linux seat that ran before the move keeps its sessions, so a resumed + // thread still resumes. Across filesystems it starts fresh instead, and + // says so: the old sessions stay where they were, untouched, and a copy + // that failed halfway would leave a home later runs never migrate again. + const legacyHome = join(LEGACY_CODEX_HOMES(ctx._homeDir || homedir()), identityHash); + if (!ctx._publicCodexHome && legacyHome !== publicHome && !(await statOrNull(publicHome))) { + const legacy = await statOrNull(legacyHome); + if (legacy?.isDirectory()) { + await mkdir(homesRoot, { recursive: true, mode: 0o700 }); + try { + await rename(legacyHome, publicHome); + } catch (err) { + if (err?.code !== 'EXDEV') throw err; + console.warn( + `[codex] ${legacyHome} is on another filesystem from ${publicHome}; starting a fresh Codex home. ` + + 'Earlier sessions stay in the old home and will not resume; move it there by hand to keep them.', + ); + } + } + } + await mkdir(publicHome, { recursive: true, mode: 0o700 }); await chmod(publicHome, 0o700); @@ -312,7 +414,53 @@ const preparePublicCodexHome = async (ctx) => { // fail closed with its normal "not logged in" error. await unlink(targetAuth); } - return publicHome; + // Model commands are sandboxed through the arg0 helper in this folder, a + // symlink to Codex's own binary, and unlike the file helper that path grants + // itself neither the folder nor the binary. Without both, every command + // fails with "bwrap: execvp ... No such file or directory" (measured on + // codex-cli 0.164.0 with a user-prefix npm install). Only where the home + // sits outside every deny can the folder's grant take effect. + const helperReadPaths = []; + if (homesRoot !== LEGACY_CODEX_HOMES(ctx._homeDir || homedir())) { + const helperDirectory = join(publicHome, 'tmp', 'arg0-linux'); + await mkdir(helperDirectory, { recursive: true, mode: 0o700 }); + helperReadPaths.push(helperDirectory); + // Looked up on the PATH the child gets, so the grant names the binary it runs. + const binary = (ctx._codexNativeBinary || codexNativeBinary)(childEnv); + if (binary) helperReadPaths.push(binary); + } + return { home: publicHome, helperReadPaths }; +}; + +// The native binary `codex` on PATH runs: itself when it is one, or the one +// the npm launcher (`bin/codex.js`) picks, resolved the way that launcher does. +// Only the Linux targets are named because only the Linux helper needs this. +const LINUX_CODEX_TARGETS = { x64: 'x86_64-unknown-linux-musl', arm64: 'aarch64-unknown-linux-musl' }; + +export const codexNativeBinary = (env) => { + const onPath = String(env?.PATH || '').split(delimiter).filter(Boolean) + .map((dir) => join(dir, 'codex')) + .find((candidate) => { + try { + accessSync(candidate, constants.X_OK); + return true; + } catch { + return false; + } + }); + if (!onPath) return null; + const real = realpathSync(onPath); + if (basename(real) !== 'codex.js') return real; + const target = LINUX_CODEX_TARGETS[process.arch]; + if (!target) return null; + const packageRoot = dirname(dirname(real)); + let vendorRoot = join(packageRoot, 'vendor'); + try { + const platformPackage = createRequire(real).resolve(`@openai/codex-linux-${process.arch}/package.json`); + vendorRoot = join(dirname(platformPackage), 'vendor'); + } catch { /* the launcher falls back to its own vendor folder too */ } + const binary = join(vendorRoot, target, 'bin', 'codex'); + return existsSync(binary) ? realpathSync(binary) : null; }; // A public seat ran with none of its declared skills: this adapter never read @@ -380,6 +528,7 @@ const publicPermissionProfileFlags = ( mode, commitAttributionEnv = null, mcpReadDirectories = [], + codexHelperReadPaths = [], ) => { if (!PUBLIC_SANDBOX_MODES.has(mode)) { throw new Error( @@ -422,6 +571,7 @@ const publicPermissionProfileFlags = ( ...(commitHooksPath ? [`${toml(commitHooksPath)}="read"`] : []), ...originalHookPaths.map((hookPath) => `${toml(hookPath)}="read"`), ...mcpReadDirectories.map((path) => `${toml(path)}="read"`), + ...codexHelperReadPaths.map((path) => `${toml(path)}="read"`), ].join(','); const shellEnvironment = [ 'PATH', 'HOME', 'TMPDIR', 'LANG', 'LC_*', @@ -472,6 +622,7 @@ const buildArgs = ({ effort = null, commitAttributionEnv = null, mcpReadDirectories = [], + codexHelperReadPaths = [], }) => { const publicSandbox = publicSandboxMode !== null; // `--dangerously-bypass-approvals-and-sandbox` disables codex CLI's @@ -495,6 +646,7 @@ const buildArgs = ({ publicSandboxMode, commitAttributionEnv, mcpReadDirectories, + codexHelperReadPaths, ), ] : ['--dangerously-bypass-approvals-and-sandbox']; @@ -707,6 +859,10 @@ export default { const publicSandboxMode = sandbox?.trust === 'public' ? sandbox?.mode ?? 'workspace' : null; + const childEnv = { ...(ctx.env || process.env), ...mcp.forwardedEnv }; + const publicCodexHome = publicSandboxMode !== null + ? await preparePublicCodexHome(ctx, childEnv) + : null; const args = buildArgs({ sessionId: ctx.sessionId || null, prompt: fullPrompt, @@ -717,9 +873,9 @@ export default { model: seatModel, effort: ctx.environment?.effort, commitAttributionEnv: ctx.commitAttributionEnv, + codexHelperReadPaths: publicCodexHome?.helperReadPaths || [], }); - const childEnv = { ...(ctx.env || process.env), ...mcp.forwardedEnv }; - // Derived from the process environment, so the bootstrap export has to be + // childEnv is derived from the process environment, so the bootstrap export has to be // taken back out: the declaration above moved our server onto the file, // and a value left here is a value codex's own children inherit. Kept only // when the substitution genuinely needed it (`forwardedEnv` names that @@ -732,7 +888,7 @@ export default { Object.assign(childEnv, ctx.commitAttributionEnv); } if (publicSandboxMode !== null) { - childEnv.CODEX_HOME = await preparePublicCodexHome(ctx); + childEnv.CODEX_HOME = publicCodexHome.home; await writePublicCodexInstructions(childEnv.CODEX_HOME, ctx); }