diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 98c7100001..81416875d4 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -721,7 +721,12 @@ describe('claude adapter — ctx.environment', () => { await claude.spawn('hi', { sessionId: null, cwd, - env: { PATH: process.env.PATH }, + env: { + PATH: process.env.PATH, + COMMONLY_HOST_SECRET: 'must-not-reach-public-seat', + AWS_SECRET_ACCESS_KEY: 'must-not-reach-public-seat', + }, + commitAttributionEnv: { COMMONLY_AGENT_SEAT_NAME: 'Public Linux Seat' }, environment: { sandbox: { trust: 'public' } }, _spawnImpl: impl, _detectBwrap: () => ({ available: true, path: 'bwrap' }), @@ -730,6 +735,14 @@ describe('claude adapter — ctx.environment', () => { expect(calls[0].cmd).toBe('bwrap'); expect(calls[0].args).toContain('--unshare-all'); expect(calls[0].args).toContain('--die-with-parent'); + // The adapter passes its filtered environment to bwrap as the process + // environment; values are not serialized into bwrap argv. + expect(calls[0].args).not.toContain('--clearenv'); + expect(calls[0].args).not.toContain('must-not-reach-public-seat'); + expect(calls[0].opts.env).toEqual({ + PATH: process.env.PATH, + COMMONLY_AGENT_SEAT_NAME: 'Public Linux Seat', + }); // The inner claude argv rides after `--`, inside the namespace. const inner = calls[0].args.slice(calls[0].args.indexOf('--') + 1); expect(inner[0]).toMatch(/claude$/); diff --git a/cli/package-lock.json b/cli/package-lock.json index e960a399c1..1592188a18 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@commonlyai/cli", - "version": "0.1.137", + "version": "0.1.138", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@commonlyai/cli", - "version": "0.1.137", + "version": "0.1.138", "license": "Apache-2.0", "dependencies": { "commander": "^12.0.0" diff --git a/cli/package.json b/cli/package.json index 5d8829b95c..d595dcbd46 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@commonlyai/cli", - "version": "0.1.137", + "version": "0.1.138", "license": "Apache-2.0", "description": "The Commonly CLI — connect agents, manage pods, iterate fast", "type": "module", diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index 35b37ff06b..300d76250f 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -200,13 +200,15 @@ const preparePublicClaudeState = async (ctx) => { return { statePath, tmpPath, configPath }; }; -const buildClaudeEnv = (input, state, expansionEnv = {}, credentialFile = null) => { +const buildClaudeEnv = (input, state, expansionEnv = {}, credentialFile = null, publicSeat = false) => { const source = input || process.env; - const output = state ? {} : { ...source }; - if (state) { + const output = state || publicSeat ? {} : { ...source }; + if (state || publicSeat) { for (const [key, value] of Object.entries(source)) { if (PUBLIC_SAFE_ENV.test(key)) output[key] = value; } + } + if (state) { output.HOME = state.statePath; output.TMPDIR = state.tmpPath; // Claude keeps its temp root at /private/tmp/claude- unless told @@ -825,14 +827,16 @@ export default { let mcpConfig = null; let publicClaudeState = null; let publicSkillArgs = []; + const publicTrust = ctx.environment?.sandbox?.trust === 'public'; + const publicNativeSandbox = publicTrust + && PUBLIC_SANDBOX_MODES.has(resolvePublicSandboxMode(ctx.environment?.sandbox)); + const publicBwrapSandbox = publicTrust + && resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap'; try { // Trust is normalized at entry (legacy `internal` reads as public). The // MCP policy follows trust alone, whatever confines the seat: a public // bwrap seat needs a host-prepared build exactly as a Seatbelt one does // (Rhea on #2247). - const publicTrust = ctx.environment?.sandbox?.trust === 'public'; - const publicNativeSandbox = publicTrust - && PUBLIC_SANDBOX_MODES.has(resolvePublicSandboxMode(ctx.environment?.sandbox)); if (publicNativeSandbox) { publicClaudeState = await preparePublicClaudeState(ctx); } @@ -863,8 +867,9 @@ export default { publicClaudeState, mcpConfig?.expansionEnv, mcpConfig?.credential?.path || null, + publicTrust, ); - if (publicNativeSandbox && ctx.commitAttributionEnv) { + if ((publicNativeSandbox || publicBwrapSandbox) && ctx.commitAttributionEnv) { Object.assign(claudeEnv, ctx.commitAttributionEnv); } const spawnCtx = { @@ -920,8 +925,12 @@ export default { publicClaudeState, mcpConfig?.expansionEnv, mcpConfig?.credential?.path || null, + publicTrust, ), }; + if ((publicNativeSandbox || publicBwrapSandbox) && ctx.commitAttributionEnv) { + Object.assign(retryCtx.claudeEnv, ctx.commitAttributionEnv); + } const retry = await prepareArgv(retryBase, retryCtx); const stdout = await runClaude({ cmd: retry.cmd, diff --git a/cli/src/lib/adapters/qwen.js b/cli/src/lib/adapters/qwen.js index 92bf318dc1..be3bc32557 100644 --- a/cli/src/lib/adapters/qwen.js +++ b/cli/src/lib/adapters/qwen.js @@ -327,7 +327,11 @@ export default { ], }, }, - }, { workspacePath: ctx.cwd, homePath: home, readOnlyPaths: [tempDir, executable, packageDir, process.execPath, ...mcpReadDirectories].filter(Boolean) }); + }, { + workspacePath: ctx.cwd, + homePath: home, + readOnlyPaths: [tempDir, executable, packageDir, process.execPath, ...mcpReadDirectories].filter(Boolean), + }); } else if (isPublic) { if ((ctx._platform || process.platform) !== 'darwin') { throw new Error(`public Qwen sandbox.mode=${mode} requires Seatbelt on macOS or bwrap on Linux`); diff --git a/cli/src/lib/sandbox/bwrap.js b/cli/src/lib/sandbox/bwrap.js index 393a98a7e9..b83bf157a8 100644 --- a/cli/src/lib/sandbox/bwrap.js +++ b/cli/src/lib/sandbox/bwrap.js @@ -92,8 +92,10 @@ export const detectBwrap = ({ spawn = spawnSync, platform = process.platform } = * user sees the gap before they think they're protected. * * The expansion order matches `bwrap`'s left-to-right argument application: - * isolation flags first, then binds (later binds win on collision), then - * --setenv, then `--` and the inner argv. + * isolation flags first, then binds (later binds win on collision), then the + * isolated HOME, then `--` and the inner argv. The caller passes the filtered + * child environment as the bwrap process environment; values must not be + * serialized into argv, where they would be visible in the process list. */ export const wrapArgvWithBwrap = (innerArgv, env, opts = {}) => { if (process.platform !== 'linux') {