From 9619d4383461a2dd613ece9aee6c7f095ebe85d3 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:32:44 -0700 Subject: [PATCH 1/6] fix(cli): public Codex seats on Linux reach the model on codex 0.164 codex 0.164 extracts its Linux sandbox helper (arg0) under $CODEX_HOME/tmp/arg0-linux and re-execs it inside bwrap. A public seat's CODEX_HOME lived under ~/.commonly, which the public permission profile denies, and an explicit deny beats any narrower read grant in codex's bwrap. So the helper's execvp failed with EACCES at session bootstrap, before the first model call. On Linux the public Codex home now lives under $XDG_STATE_HOME/commonly/codex-homes (default ~/.local/state). An existing home is moved over with its sessions, and a workspace that contains the home is refused. The profile grants read on just the arg0-linux folder and the native codex binary (resolved through the npm launcher's platform package), which model commands need to run under the helper. It grants nothing on the rest of the home. macOS is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ENddpGzhE7S8qjDSYAMuu --- cli/__tests__/adapters.codex.test.mjs | 140 +++++++++++++++++++++++++- cli/src/lib/adapters/codex.js | 120 ++++++++++++++++++++-- 2 files changed, 251 insertions(+), 9 deletions(-) diff --git a/cli/__tests__/adapters.codex.test.mjs b/cli/__tests__/adapters.codex.test.mjs index b5b474a8a4..b58e3d254f 100644 --- a/cli/__tests__/adapters.codex.test.mjs +++ b/cli/__tests__/adapters.codex.test.mjs @@ -13,7 +13,7 @@ import { jest } from '@jest/globals'; import { EventEmitter } from 'events'; -import { existsSync, readFileSync, statSync } from 'fs'; +import { existsSync, readFileSync, realpathSync, statSync } from 'fs'; import { lstat, mkdir, @@ -21,6 +21,7 @@ import { readFile, readlink, rm, + symlink, writeFile, } from 'fs/promises'; import { tmpdir } from 'os'; @@ -32,7 +33,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 +1082,138 @@ 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('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('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 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..2efe0e78be 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,22 +280,71 @@ 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. +// 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 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; + return join(stateHome && isAbsolute(stateHome) ? stateHome : join(home, '.local', 'state'), + 'commonly', 'codex-homes'); +}; + +const contains = (outer, inner) => inner === outer || inner.startsWith(outer.endsWith(sep) ? outer : outer + sep); + const preparePublicCodexHome = async (ctx) => { 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'); } + // A Linux seat that ran before the move keeps its sessions, so a resumed + // thread still resumes. Across filesystems it starts fresh instead. + 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; + } + } + } + await mkdir(publicHome, { recursive: true, mode: 0o700 }); await chmod(publicHome, 0o700); + if (!ctx._publicCodexHome && homesRoot !== LEGACY_CODEX_HOMES(ctx._homeDir || homedir())) { + const workspacePath = pathResolve(ctx.cwd || process.cwd()); + const [workspace, realHome] = await Promise.all([ + realpath(workspacePath).catch(() => workspacePath), realpath(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.`); + } + } const sourceAuth = join(operatorHome, 'auth.json'); const targetAuth = join(publicHome, 'auth.json'); @@ -312,7 +367,52 @@ 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); + const binary = (ctx._codexNativeBinary || codexNativeBinary)(ctx.env || process.env); + 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 +480,7 @@ const publicPermissionProfileFlags = ( mode, commitAttributionEnv = null, mcpReadDirectories = [], + codexHelperReadPaths = [], ) => { if (!PUBLIC_SANDBOX_MODES.has(mode)) { throw new Error( @@ -422,6 +523,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 +574,7 @@ const buildArgs = ({ effort = null, commitAttributionEnv = null, mcpReadDirectories = [], + codexHelperReadPaths = [], }) => { const publicSandbox = publicSandboxMode !== null; // `--dangerously-bypass-approvals-and-sandbox` disables codex CLI's @@ -495,6 +598,7 @@ const buildArgs = ({ publicSandboxMode, commitAttributionEnv, mcpReadDirectories, + codexHelperReadPaths, ), ] : ['--dangerously-bypass-approvals-and-sandbox']; @@ -707,6 +811,7 @@ export default { const publicSandboxMode = sandbox?.trust === 'public' ? sandbox?.mode ?? 'workspace' : null; + const publicCodexHome = publicSandboxMode !== null ? await preparePublicCodexHome(ctx) : null; const args = buildArgs({ sessionId: ctx.sessionId || null, prompt: fullPrompt, @@ -717,6 +822,7 @@ 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 @@ -732,7 +838,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); } From 940d59cc7ad0ad78c572eb96200bd24b74b0f87a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:45:24 -0700 Subject: [PATCH 2/6] fix(cli): check the workspace before moving a public Codex home The containment check ran after the legacy home was renamed into place, so a workspace refused for containing the home had already received the seat's sessions. The check now runs first, against the real path of the home's nearest existing ancestor, before anything is created or moved. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ENddpGzhE7S8qjDSYAMuu --- cli/__tests__/adapters.codex.test.mjs | 13 +++++++++ cli/src/lib/adapters/codex.js | 40 ++++++++++++++++++++------- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/cli/__tests__/adapters.codex.test.mjs b/cli/__tests__/adapters.codex.test.mjs index b58e3d254f..1ed7ecfedc 100644 --- a/cli/__tests__/adapters.codex.test.mjs +++ b/cli/__tests__/adapters.codex.test.mjs @@ -1170,6 +1170,19 @@ describe('codex adapter — the public Codex home on Linux', () => { 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 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') }; diff --git a/cli/src/lib/adapters/codex.js b/cli/src/lib/adapters/codex.js index 2efe0e78be..a82a91cb69 100644 --- a/cli/src/lib/adapters/codex.js +++ b/cli/src/lib/adapters/codex.js @@ -306,6 +306,23 @@ const publicCodexHomesRoot = (ctx) => { const contains = (outer, inner) => inner === outer || inner.startsWith(outer.endsWith(sep) ? outer : outer + sep); +// 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) { + const parent = dirname(current); + if (err?.code !== 'ENOENT' || parent === current) return join(current, ...rest); + rest.unshift(basename(current)); + current = parent; + } + } +}; + const preparePublicCodexHome = async (ctx) => { const operatorHome = ctx.env?.CODEX_HOME || process.env.CODEX_HOME @@ -318,6 +335,19 @@ const preparePublicCodexHome = async (ctx) => { 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 workspacePath = pathResolve(ctx.cwd || process.cwd()); + const [workspace, realHome] = await Promise.all([ + realpath(workspacePath).catch(() => workspacePath), 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. const legacyHome = join(LEGACY_CODEX_HOMES(ctx._homeDir || homedir()), identityHash); @@ -335,16 +365,6 @@ const preparePublicCodexHome = async (ctx) => { await mkdir(publicHome, { recursive: true, mode: 0o700 }); await chmod(publicHome, 0o700); - if (!ctx._publicCodexHome && homesRoot !== LEGACY_CODEX_HOMES(ctx._homeDir || homedir())) { - const workspacePath = pathResolve(ctx.cwd || process.cwd()); - const [workspace, realHome] = await Promise.all([ - realpath(workspacePath).catch(() => workspacePath), realpath(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.`); - } - } const sourceAuth = join(operatorHome, 'auth.json'); const targetAuth = join(publicHome, 'auth.json'); From 50b64473bf0a79816d7c32f60d2ab54808f0e154 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:08:52 -0700 Subject: [PATCH 3/6] fix(cli): fail closed when a public Codex home cannot be resolved realpathOfNearest walked up past any realpath error, so EACCES or ELOOP left the containment check comparing a path nobody resolved. Only a missing path (ENOENT, ENOTDIR) walks up now; anything else is thrown. A new test refuses a workspace reached through a symlinked state directory, which only resolving the link reveals. Without it, a check that skipped resolution passed on Linux hosts whose tmpdir is not itself a link. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ENddpGzhE7S8qjDSYAMuu --- cli/__tests__/adapters.codex.test.mjs | 14 ++++++++++++++ cli/src/lib/adapters/codex.js | 5 ++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/cli/__tests__/adapters.codex.test.mjs b/cli/__tests__/adapters.codex.test.mjs index 1ed7ecfedc..9d366e4af2 100644 --- a/cli/__tests__/adapters.codex.test.mjs +++ b/cli/__tests__/adapters.codex.test.mjs @@ -1183,6 +1183,20 @@ describe('codex adapter — the public Codex home on Linux', () => { 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') }; diff --git a/cli/src/lib/adapters/codex.js b/cli/src/lib/adapters/codex.js index a82a91cb69..0339f2db7b 100644 --- a/cli/src/lib/adapters/codex.js +++ b/cli/src/lib/adapters/codex.js @@ -315,8 +315,11 @@ const realpathOfNearest = async (path) => { 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 (err?.code !== 'ENOENT' || parent === current) return join(current, ...rest); + if (parent === current) return join(current, ...rest); rest.unshift(basename(current)); current = parent; } From a38e153fa9a57daccbb97206c1110932784f021d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:22:21 -0700 Subject: [PATCH 4/6] fix(cli): refuse a Linux state home under ~/.commonly; resolve the workspace fail-closed An XDG_STATE_HOME under ~/.commonly put the public Codex home back inside the tree the public profile denies, bringing back the bootstrap failure the move exists to fix. It is now refused before anything is created. The workspace side of the containment check fell back to its own lexical path on any realpath error. Now it does so only for a missing path, as realpathOfNearest already does for the home. A move that crosses filesystems still starts a fresh home, as before, but now says so: the old sessions stay where they were and will not resume. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ENddpGzhE7S8qjDSYAMuu --- cli/__tests__/adapters.codex.test.mjs | 7 +++++++ cli/src/lib/adapters/codex.js | 28 ++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/cli/__tests__/adapters.codex.test.mjs b/cli/__tests__/adapters.codex.test.mjs index 9d366e4af2..5b3f9b3fb2 100644 --- a/cli/__tests__/adapters.codex.test.mjs +++ b/cli/__tests__/adapters.codex.test.mjs @@ -1153,6 +1153,13 @@ describe('codex adapter — the public Codex home on Linux', () => { 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('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')); diff --git a/cli/src/lib/adapters/codex.js b/cli/src/lib/adapters/codex.js index 0339f2db7b..8544155ccf 100644 --- a/cli/src/lib/adapters/codex.js +++ b/cli/src/lib/adapters/codex.js @@ -296,16 +296,23 @@ const statOrNull = async (path) => { // 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 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; - return join(stateHome && isAbsolute(stateHome) ? stateHome : join(home, '.local', 'state'), + 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. + if (contains(pathResolve(home, '.commonly'), pathResolve(root))) { + throw new Error(`XDG_STATE_HOME (${stateHome}) puts the public Codex home under ${join(home, '.commonly')}, ` + + 'which the public sandbox denies on Linux. Point it elsewhere or unset it.'); + } + return root; }; -const contains = (outer, inner) => inner === outer || inner.startsWith(outer.endsWith(sep) ? outer : outer + sep); - // The real path of a path that may not exist yet: its nearest existing // ancestor resolved, with the rest appended. const realpathOfNearest = async (path) => { @@ -343,7 +350,12 @@ const preparePublicCodexHome = async (ctx) => { if (!ctx._publicCodexHome && homesRoot !== LEGACY_CODEX_HOMES(ctx._homeDir || homedir())) { const workspacePath = pathResolve(ctx.cwd || process.cwd()); const [workspace, realHome] = await Promise.all([ - realpath(workspacePath).catch(() => workspacePath), realpathOfNearest(publicHome), + // 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 ` @@ -352,7 +364,9 @@ const preparePublicCodexHome = async (ctx) => { } // A Linux seat that ran before the move keeps its sessions, so a resumed - // thread still resumes. Across filesystems it starts fresh instead. + // 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); @@ -362,6 +376,10 @@ const preparePublicCodexHome = async (ctx) => { 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.', + ); } } } From 4ce46f3a4fcb0bcfde6c82a570d6784cca0d917e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:31:32 -0700 Subject: [PATCH 5/6] fix(cli): refuse a state home that reaches ~/.commonly through a symlink The name check caught an XDG_STATE_HOME spelled under ~/.commonly but not one that resolves there. Both paths are now resolved and compared before anything is created or migrated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013ENddpGzhE7S8qjDSYAMuu --- cli/__tests__/adapters.codex.test.mjs | 19 ++++++++++++++++++- cli/src/lib/adapters/codex.js | 16 +++++++++++----- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/cli/__tests__/adapters.codex.test.mjs b/cli/__tests__/adapters.codex.test.mjs index 5b3f9b3fb2..5be39236f9 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, realpathSync, statSync } from 'fs'; +import { + existsSync, readdirSync, readFileSync, realpathSync, statSync, +} from 'fs'; import { lstat, mkdir, @@ -1160,6 +1162,21 @@ describe('codex adapter — the public Codex home 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')); diff --git a/cli/src/lib/adapters/codex.js b/cli/src/lib/adapters/codex.js index 8544155ccf..60fc6604aa 100644 --- a/cli/src/lib/adapters/codex.js +++ b/cli/src/lib/adapters/codex.js @@ -298,6 +298,11 @@ 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); @@ -305,11 +310,9 @@ const publicCodexHomesRoot = (ctx) => { 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. - if (contains(pathResolve(home, '.commonly'), pathResolve(root))) { - throw new Error(`XDG_STATE_HOME (${stateHome}) puts the public Codex home under ${join(home, '.commonly')}, ` - + 'which the public sandbox denies on Linux. Point it elsewhere or unset it.'); - } + // 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; }; @@ -348,6 +351,9 @@ const preparePublicCodexHome = async (ctx) => { // 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. From ca3382592d749e43a84e6c8ce07f8f0bb8517b9e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:49:16 -0700 Subject: [PATCH 6/6] fix(cli): resolve the public Codex helper binary from the child's PATH The helper grant names the codex binary, so it is looked up on the env the child actually runs with (process env plus forwarded MCP env), not the pre-overlay one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DRK3nqxjeAV7xL8uhg --- cli/__tests__/adapters.codex.test.mjs | 22 ++++++++++++++++++++++ cli/src/lib/adapters/codex.js | 13 ++++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/cli/__tests__/adapters.codex.test.mjs b/cli/__tests__/adapters.codex.test.mjs index 5be39236f9..9011653fa3 100644 --- a/cli/__tests__/adapters.codex.test.mjs +++ b/cli/__tests__/adapters.codex.test.mjs @@ -1123,6 +1123,28 @@ describe('codex adapter — the public Codex home on Linux', () => { 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; diff --git a/cli/src/lib/adapters/codex.js b/cli/src/lib/adapters/codex.js index 60fc6604aa..97352a9c3b 100644 --- a/cli/src/lib/adapters/codex.js +++ b/cli/src/lib/adapters/codex.js @@ -336,7 +336,7 @@ const realpathOfNearest = async (path) => { } }; -const preparePublicCodexHome = async (ctx) => { +const preparePublicCodexHome = async (ctx, childEnv) => { const operatorHome = ctx.env?.CODEX_HOME || process.env.CODEX_HOME || join(homedir(), '.codex'); @@ -425,7 +425,8 @@ const preparePublicCodexHome = async (ctx) => { const helperDirectory = join(publicHome, 'tmp', 'arg0-linux'); await mkdir(helperDirectory, { recursive: true, mode: 0o700 }); helperReadPaths.push(helperDirectory); - const binary = (ctx._codexNativeBinary || codexNativeBinary)(ctx.env || process.env); + // 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 }; @@ -858,7 +859,10 @@ export default { const publicSandboxMode = sandbox?.trust === 'public' ? sandbox?.mode ?? 'workspace' : null; - const publicCodexHome = publicSandboxMode !== null ? await preparePublicCodexHome(ctx) : 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, @@ -871,8 +875,7 @@ export default { 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