From d0811061d1d957d289c127bcf69574633172ea45 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:30:53 -0700 Subject: [PATCH 1/2] feat(cli): Claude Code on Linux answers @mentions in invite-only pods (TASK-027) #2784 gave Linux a workspace jail for Claude seats, but no seat could reach it: the host check refused claude under bwrap for every trust, and a first run off macOS always chose public trust. A first-run Claude seat on Linux now gets workspace trust when every pod it answers in is confirmed invite-only, as on macOS. The host check lets a workspace Claude seat through to the bubblewrap probe and refuses any other Claude seat under bwrap, so public trust stays refused on Linux. Native Windows still gets public trust. It fails closed in three places: - A turn that runs public because its pod turned public, or because its pods could not be read, is refused before anything is read or spawned. The model never runs, nothing is posted, and a human's message is handed to another listener rather than retried. - A bubblewrap probe that fails, for any reason, refuses the run. - A claude binary the jail cannot carry (a bare name, a dangling link) is refused at spawn, before the login is copied, where it used to be left out of the binds silently. The public bwrap branch in claude.js stays, but no agent run reaches it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A9iDVr7rVKi8xSjpN14CU7 --- .../adapters.claude.environment.test.mjs | 34 +++++++++++- cli/__tests__/attach.test.mjs | 28 ++++++++-- cli/__tests__/host-sandbox-preflight.test.mjs | 32 +++++++++-- cli/__tests__/workspace-trust.test.mjs | 18 ++++++- .../workspace-turn-run-loop.test.mjs | 53 +++++++++++++++++-- cli/src/commands/agent.js | 17 ++++++ cli/src/lib/adapters/claude.js | 28 ++++++++-- cli/src/lib/sandbox/host-check.js | 22 +++++--- cli/src/lib/workspace-trust.js | 9 ++-- 9 files changed, 208 insertions(+), 33 deletions(-) diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 0a088b65fb..456398f059 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -1414,16 +1414,48 @@ describe('claude adapter — a workspace seat on Linux', () => { let operatorClaude; const login = '{"claudeAiOauth":{"accessToken":"test-only"}}\n'; + let claudeInstall; + // `which claude` answers with a real file, as on a host with Claude Code + // installed; the jail binds its folder. + const whichFinds = (bin) => spawnSync.mockImplementation((cmd) => (cmd === 'which' + ? { status: 0, stdout: `${bin}\n` } : undefined)); + beforeEach(() => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-linux-ws-')); state = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-linux-state-')); operatorClaude = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-linux-login-')); + claudeInstall = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-claude-linux-install-')); + fs.writeFileSync(path.join(claudeInstall, 'claude'), '#!/bin/sh\n', { mode: 0o755 }); fs.writeFileSync(path.join(operatorClaude, '.credentials.json'), login, { mode: 0o600 }); + whichFinds(path.join(claudeInstall, 'claude')); Object.defineProperty(process, 'platform', { value: 'linux' }); }); afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform }); - for (const dir of [cwd, state, operatorClaude]) fs.rmSync(dir, { recursive: true, force: true }); + spawnSync.mockReset(); + for (const dir of [cwd, state, operatorClaude, claudeInstall]) fs.rmSync(dir, { recursive: true, force: true }); + }); + + // TASK-027: a jail that cannot carry claude fails closed before the login + // is copied, never spawning a namespace claude cannot start in. + test.each([ + ['not on PATH', () => spawnSync.mockImplementation(() => undefined), /`claude` could not be resolved/], + ['a dangling link', () => whichFinds(path.join(os.tmpdir(), 'cli-claude-missing', 'claude')), /cli-claude-missing\/claude` could not be resolved/], + ])('refuses when claude is %s, before the login is copied', async (_label, arrange, message) => { + arrange(); + const { impl, calls } = childImpl(); + await expect(linuxSpawn(impl)).rejects.toThrow(message); + expect(calls).toHaveLength(0); + expect(fs.existsSync(path.join(state, '.claude', '.credentials.json'))).toBe(false); + }); + + test('binds the folder claude is installed in, read-only', async () => { + const { impl, calls } = childImpl(); + await linuxSpawn(impl); + expect(calls).toHaveLength(1); + const outer = calls[0].args.slice(0, calls[0].args.indexOf('--')); + const binds = outer.flatMap((arg, i) => (/^--ro-bind/.test(arg) ? [outer[i + 1]] : [])); + expect(binds).toContain(fs.realpathSync(claudeInstall)); }); // Plays the child: it looks for its login under the HOME it was given and diff --git a/cli/__tests__/attach.test.mjs b/cli/__tests__/attach.test.mjs index 85105d7a25..0b094c1150 100644 --- a/cli/__tests__/attach.test.mjs +++ b/cli/__tests__/attach.test.mjs @@ -904,14 +904,32 @@ describe('bootstrapAgentRecordFromEnv', () => { expect(lines.some((line) => /your home directory\. To let it read a project/.test(line))).toBe(true); }); - test('on Linux the seat keeps the public default and says why, since its adapter refuses workspace there', async () => { - const { record, lines } = await run({ response: withVisibility(false), platform: 'linux' }); + // #2784 runs a workspace seat under bwrap on Linux; agent run checks that + // the host can start the jail before the token file is written. + test('on Linux, every pod private: the seat reads that project, and takes its grants', async () => { + const { record } = await run({ response: withVisibility(false), platform: 'linux' }); + expect(record.environment.sandbox).toEqual({ trust: 'workspace' }); + expect(record.workspacePath).toBe(fs.realpathSync(repo)); + const granted = await run({ response: withVisibility(false), platform: 'linux', allow: ['write'] }); + expect(granted.record.environment.sandbox).toEqual({ trust: 'workspace', allow: ['write'] }); + }); + + test('on Linux a public pod keeps the public default, which agent run then refuses for Claude', async () => { + const { record, lines } = await run({ response: withVisibility(true), platform: 'linux' }); + expect(record.environment.sandbox).toEqual({ trust: 'public' }); + expect(record.workspacePath).toBeNull(); + expect(lines.some((line) => /scratch folder, because a pod it answers in is publicly readable/ + .test(line))).toBe(true); + }); + + test('on native Windows the seat keeps the public default and says why', async () => { + const { record, lines } = await run({ response: withVisibility(false), platform: 'win32' }); expect(record.environment.sandbox).toEqual({ trust: 'public' }); expect(record.workspacePath).toBeNull(); - expect(lines.some((line) => /scratch folder, because an agent works in its folder only on macOS/ + expect(lines.some((line) => /scratch folder, because an agent works in its folder only on macOS and Linux/ .test(line))).toBe(true); - await expect(run({ response: withVisibility(false), platform: 'linux', allow: ['write'] })) - .rejects.toThrow(/--allow write applies only .* only on macOS for now/); + await expect(run({ response: withVisibility(false), platform: 'win32', allow: ['write'] })) + .rejects.toThrow(/--allow write applies only .* only on macOS and Linux for now/); }); test('a codex seat keeps its baseline and hears nothing about workspaces', async () => { diff --git a/cli/__tests__/host-sandbox-preflight.test.mjs b/cli/__tests__/host-sandbox-preflight.test.mjs index ee161ebe1c..430e727908 100644 --- a/cli/__tests__/host-sandbox-preflight.test.mjs +++ b/cli/__tests__/host-sandbox-preflight.test.mjs @@ -75,16 +75,27 @@ describe('hostSandboxRefusal', () => { // Ubuntu run, 2026-10-16: under bwrap a confined spawn could not even start // Claude Code (`execvp …/.local/bin/claude: No such file or directory`). - test('Claude Code on Linux (WSL included) stops at start with its options, before any detection', () => { + // A public Linux seat still stops here: only the workspace jail (#2784) + // carries Claude's install and login, and it never runs for a public pod. + test('a public Claude Code seat on Linux (WSL included) stops at start with its options, before any detection', () => { const message = hostSandboxRefusal({ environment: connectPage, adapterName: 'claude', platform: 'linux', detectBwrap: never, detectSeatbelt: never, }); - expect(message).toMatch(/on Linux \(WSL included\) that sandbox\ncannot run Claude Code yet/); + expect(message).toMatch(/On Linux \(WSL included\) it does that only\nfor an agent whose pods are all invite-only/); + expect(message).toContain('From an invite-only pod'); expect(message).toContain('`claude mcp add` command'); expect(message).toContain('"Run it here"'); expect(message).not.toContain('bubblewrap'); }); + test('a Claude Code bwrap seat with no workspace trust is refused on Linux the same way', () => { + for (const sandbox of [{ mode: 'bwrap' }, { trust: 'public', mode: 'bwrap' }, { trust: 'internal' }]) { + expect(hostSandboxRefusal({ + environment: { sandbox }, adapterName: 'claude', platform: 'linux', detectBwrap: never, detectSeatbelt: never, + })).toMatch(/pods are all invite-only/); + } + }); + test('on Linux, a missing bubblewrap stops an OpenCode seat with the install line', () => { const args = { environment: connectPage, adapterName: 'opencode', platform: 'linux', detectSeatbelt: never }; expect(hostSandboxRefusal({ ...args, detectBwrap: available })).toBeNull(); @@ -100,14 +111,27 @@ describe('hostSandboxRefusal', () => { const args = { environment: workspace, adapterName: 'claude' }; expect(hostSandboxRefusal({ ...args, platform: 'win32', detectBwrap: never, detectSeatbelt: never })) .toMatch(/no sandbox for Windows yet/); - expect(hostSandboxRefusal({ ...args, platform: 'linux', detectBwrap: never, detectSeatbelt: never })) - .toMatch(/cannot run Claude Code yet/); + expect(hostSandboxRefusal({ ...args, platform: 'linux', detectBwrap: available, detectSeatbelt: never })) + .toBeNull(); expect(hostSandboxRefusal({ ...args, platform: 'darwin', detectBwrap: never, detectSeatbelt: available })).toBeNull(); expect(hostSandboxRefusal({ ...args, platform: 'darwin', detectBwrap: never, detectSeatbelt: missing('sandbox-exec preflight failed: exit 1'), })).toMatch(/^This seat runs in a macOS Seatbelt sandbox \(sandbox\.mode=workspace\)/); }); + // TASK-027: the bubblewrap probe is the gate for a workspace Claude seat on + // Linux. Every way it can fail is a refusal, never a run without the jail. + test('a workspace Claude seat on Linux runs only when bubblewrap can start a namespace', () => { + const args = { environment: { sandbox: { trust: 'workspace', allow: ['write'] } }, adapterName: 'claude', platform: 'linux', detectSeatbelt: never }; + expect(hostSandboxRefusal({ ...args, detectBwrap: available })).toBeNull(); + expect(hostSandboxRefusal({ ...args, detectBwrap: missing('bwrap not found on PATH. Install bubblewrap: `sudo apt install bubblewrap` (Debian/Ubuntu, WSL).') })) + .toMatch(/^This seat runs in a bubblewrap sandbox, which is not available here\. bwrap not found on PATH/); + expect(hostSandboxRefusal({ ...args, detectBwrap: missing('bubblewrap is installed but cannot start a sandbox on this machine (setting up uid map: Permission denied).') })) + .toMatch(/cannot start a sandbox on this machine \(setting up uid map: Permission denied\)/); + expect(hostSandboxRefusal({ ...args, detectBwrap: missing('bwrap detection failed: spawnSync bwrap EACCES') })) + .toMatch(/not available here\. bwrap detection failed/); + }); + test('a legacy internal trust reads as public', () => { expect(hostSandboxRefusal({ environment: { sandbox: { trust: 'internal' } }, adapterName: 'claude', platform: 'win32', diff --git a/cli/__tests__/workspace-trust.test.mjs b/cli/__tests__/workspace-trust.test.mjs index 8192d83ce3..0dc4beaad2 100644 --- a/cli/__tests__/workspace-trust.test.mjs +++ b/cli/__tests__/workspace-trust.test.mjs @@ -115,10 +115,24 @@ describe('first-run workspace', () => { }); }); - test('off macOS a claude seat keeps the public default, which its adapter can run', () => { + test('on Linux a claude seat whose pods are all private reads its project too (bwrap, #2784)', () => { expect(firstRunWorkspace({ adapterName: 'claude', installations: [priv], startDir: repo, home, platform: 'linux', - })).toEqual({ workspacePath: null, reason: 'an agent works in its folder only on macOS for now' }); + })).toEqual({ workspacePath: realpathSync(repo), reason: null }); + }); + + test('on Linux a pod whose visibility is unreported keeps the public default', () => { + const result = firstRunWorkspace({ + adapterName: 'claude', installations: [priv, { joinPolicy: 'invite-only' }], startDir: repo, home, platform: 'linux', + }); + expect(result.workspacePath).toBeNull(); + expect(result.reason).toMatch(/did not report whether it is/); + }); + + test('on native Windows a claude seat keeps the public default', () => { + expect(firstRunWorkspace({ + adapterName: 'claude', installations: [priv], startDir: repo, home, platform: 'win32', + })).toEqual({ workspacePath: null, reason: 'an agent works in its folder only on macOS and Linux for now' }); }); test('every other adapter has nothing to decide, since workspace is claude-only', () => { diff --git a/cli/__tests__/workspace-turn-run-loop.test.mjs b/cli/__tests__/workspace-turn-run-loop.test.mjs index 7a7c9b7395..2f7276616d 100644 --- a/cli/__tests__/workspace-turn-run-loop.test.mjs +++ b/cli/__tests__/workspace-turn-run-loop.test.mjs @@ -36,7 +36,9 @@ const drain = async () => { }; let eventCount = 0; -const runOneTurn = async ({ environment, installations, newSessionId }) => { +const runOneTurn = async ({ + environment, installations, newSessionId, adapterName = 'stub', spawns = 1, +}) => { eventCount += 1; const eventId = `ws-event-${eventCount}`; const get = jest.fn(async (route) => { @@ -57,13 +59,14 @@ const runOneTurn = async ({ environment, installations, newSessionId }) => { if (route.endsWith('/context')) return { focus: { podId: 'pod-ws', revision: 0, focus: null } }; return {}; }); - createClient.mockReturnValue({ get, post: jest.fn().mockResolvedValue({}) }); + const post = jest.fn().mockResolvedValue({}); + createClient.mockReturnValue({ get, post }); const spawn = jest.fn().mockResolvedValue({ text: 'done', newSessionId }); const logs = []; const { stop } = performRun({ instanceUrl: 'http://localhost:5000', token: 'cm_agent_workspace', - adapter: { name: 'stub', detect: stubAdapter.detect, spawn }, + adapter: { name: adapterName, detect: stubAdapter.detect, spawn }, agentName: 'workspace-stub', environment, workspacePath: workspaceDir, @@ -72,8 +75,8 @@ const runOneTurn = async ({ environment, installations, newSessionId }) => { }); await drain(); stop(); - expect(spawn).toHaveBeenCalledTimes(1); - return { ctx: spawn.mock.calls[0][1], get, logs }; + expect(spawn).toHaveBeenCalledTimes(spawns); + return { ctx: spawn.mock.calls[0]?.[1], get, post, logs }; }; describe('performRun workspace turn placement', () => { @@ -159,3 +162,43 @@ describe('performRun workspace turn placement', () => { expect(degraded.ctx.sessionId).toBeFalsy(); }); }); + +// TASK-027: Claude Code on Linux has only the workspace jail, so a workspace +// turn placed public is refused before anything is read or spawned, never +// run in the public jail that cannot carry Claude's install or login. +describe('performRun workspace turn placement for Claude Code on Linux', () => { + const originalPlatform = process.platform; + beforeEach(() => { + jest.clearAllMocks(); + fs.rmSync(path.join(sessionsTmpDir, '.commonly'), { recursive: true, force: true }); + Object.defineProperty(process, 'platform', { value: 'linux' }); + }); + afterEach(() => Object.defineProperty(process, 'platform', { value: originalPlatform })); + + const posted = (post) => post.mock.calls.filter(([route]) => /\/messages$/.test(route)); + + test('a private turn spawns in the workspace', async () => { + const { ctx } = await runOneTurn({ + environment: { sandbox: { trust: 'workspace' } }, installations: [privatePod], adapterName: 'claude', + }); + expect(ctx.cwd).toBe(workspaceDir); + expect(ctx.environment.sandbox.trust).toBe('workspace'); + }); + + test.each([ + ['its pod turned public', [privatePod, { podId: 'pod-open', publicRead: true, communityListed: false }], + /because a pod it answers in is publicly readable/], + ['its pods could not be read', Object.assign(new Error('boom'), { status: 503 }), + /because its pods could not be read/], + ['a pod reports no visibility', [privatePod, { podId: 'pod-unknown', joinPolicy: 'invite-only' }], + /did not report whether it is/], + ])('a turn is refused when %s, with nothing read, spawned or posted', async (_label, installations, why) => { + const { get, post, logs } = await runOneTurn({ + environment: { sandbox: { trust: 'workspace' } }, installations, adapterName: 'claude', spawns: 0, + }); + expect(logs.some((line) => /^turn refused, because /.test(line) && why.test(line) + && /pods are all invite-only/.test(line))).toBe(true); + expect(get.mock.calls.some(([route]) => route === '/api/agents/runtime/memory')).toBe(false); + expect(posted(post)).toHaveLength(0); + }); +}); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 15004bee07..9b3e535f56 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -1835,6 +1835,23 @@ export const performRun = ({ event, eventPodId, prompt, preSpawnIds, snapshotMessages, claimKeeper, trigger, }) => { const placement = await placeTurn(); + // A workspace turn placed public (its pod turned public, or its pods could + // not be read) is confined as a public seat, which this host may not be + // able to do: Claude Code on Linux has only the workspace jail. Refuse it + // as the run start would, before anything is read or spawned. The model + // never runs and nothing is posted, and a human's message is handed to + // another listener (claim-outcome.js) rather than left for a retry. + if (placement.reason) { + const turnRefusal = hostSandboxRefusal({ environment: placement.environment, adapterName: adapter.name }); + if (turnRefusal) { + log(`turn refused, because ${placement.reason}: ${turnRefusal.replace(/\n\s*/g, ' ')}`); + return { + outcome: 'no_action', + refused: { reason: 'delivery-refused' }, + reason: 'host-cannot-confine-turn', + }; + } + } // TASK-440: a cold, large session starts fresh at wake instead of being // resumed. The restart note leads the prompt; memory still reaches the // fresh session through the adapter's preamble. diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index 3eff813427..e909f3b9d9 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -788,6 +788,25 @@ const resolveClaudePath = (env = process.env) => { return 'claude'; }; +// Where the claude this host would run is installed: the folder the Linux +// workspace jail has to bind, since it binds the system binary folders only. +// Null when the binary cannot be located (a bare name, a dangling link). +const resolveClaudeInstall = (env) => { + const claudeBin = resolveClaudePath(env); + if (!isAbsolute(claudeBin)) return { claudeBin, claudeInstall: null }; + try { + return { claudeBin, claudeInstall: dirname(realpathSync(claudeBin)) }; + } catch { + return { claudeBin, claudeInstall: null }; + } +}; + +// A jail that cannot carry claude would fail inside at every spawn. +const claudeInstallRefusal = (claudeBin) => new Error( + 'workspace Claude agents on Linux need Claude Code on PATH, so the sandbox can carry its install; ' + + `\`${claudeBin}\` could not be resolved`, +); + const prepareArgv = async (innerArgv, ctx) => { const env = ctx.environment; const claudeEnv = withClaudePath(ctx.claudeEnv); @@ -941,12 +960,9 @@ const prepareArgv = async (innerArgv, ctx) => { + `repository (run \`git init\` in ${ctx.cwd}), so its .git can be held read-only`); } } - const claudeBin = resolveClaudePath(claudeEnv); + const { claudeBin, claudeInstall } = resolveClaudeInstall(claudeEnv); + if (!claudeInstall) throw claudeInstallRefusal(claudeBin); const { statePath } = ctx.publicClaudeState; - let claudeInstall = null; - try { - claudeInstall = dirname(realpathSync(claudeBin)); - } catch { /* an unresolvable binary fails at spawn with its own error */ } const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, { workspacePath: ctx.cwd, workspaceReadOnly: !grants.includes('write'), @@ -1125,6 +1141,8 @@ export default { // jail never gets a copy of the login. const bwrap = (ctx._detectBwrap || detectBwrap)(); if (!bwrap.available) throw new Error(`workspace Claude agents require bwrap: ${bwrap.error}`); + const { claudeBin, claudeInstall } = resolveClaudeInstall(withClaudePath(ctx.env)); + if (!claudeInstall) throw claudeInstallRefusal(claudeBin); publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx); } if (publicTrust) { diff --git a/cli/src/lib/sandbox/host-check.js b/cli/src/lib/sandbox/host-check.js index bbcfbe39f0..09450e444a 100644 --- a/cli/src/lib/sandbox/host-check.js +++ b/cli/src/lib/sandbox/host-check.js @@ -46,15 +46,17 @@ export const windowsSandboxMessage = (adapterName) => { }; /** - * Claude Code on Linux (WSL included). The bubblewrap namespace binds the - * system binary folders only, and Claude Code installs under the person's - * home or /usr/local, so a confined spawn could not even start it; its login - * and settings live under that home too (Ubuntu run, 2026-10-16). Until the - * namespace carries what Claude needs, the seat stops here with its options. + * Claude Code on Linux (WSL included) for any seat but a workspace one. Only + * the workspace jail (#2784) carries what Claude needs: its install, a HOME + * of its own and a copy of the login. The public jail binds the system binary + * folders only, so a confined spawn could not even start Claude (Ubuntu run, + * 2026-10-16), and giving it the login would hand strangers a jail that holds + * it. A workspace seat needs every pod it answers in to be invite-only. */ export const linuxClaudeMessage = () => [ - 'Commonly runs a connected Claude Code inside a sandbox, and on Linux (WSL included) that sandbox', - 'cannot run Claude Code yet, so this agent cannot listen for @mentions here. Instead:', + 'Commonly runs a connected Claude Code inside a sandbox. On Linux (WSL included) it does that only', + 'for an agent whose pods are all invite-only, and this one is not, so it cannot listen for @mentions here. Instead:', + ' - From an invite-only pod: run the commands its Connect page shows, from your project\'s folder.', ' - Use Commonly from your own Claude Code sessions: the Connect page shows a `claude mcp add` command.', HOSTED_LINE, ].join('\n'); @@ -76,7 +78,11 @@ export const hostSandboxRefusal = ({ if (mode !== 'bwrap' && !SEATBELT_MODES.has(mode)) return null; if (platform === 'win32') return windowsSandboxMessage(adapterName); - if (mode === 'bwrap' && adapterName === 'claude') return linuxClaudeMessage(); + // A workspace claude seat falls through to the bubblewrap probe below; the + // probe failing, for any reason, is a refusal like any other adapter's. + if (mode === 'bwrap' && adapterName === 'claude' && sandbox.trust !== 'workspace') { + return linuxClaudeMessage(); + } if (mode === 'bwrap') { const bwrap = detectBwrap(); return bwrap.available ? null : `This seat runs in a bubblewrap sandbox, which is not available here. ${bwrap.error}`; diff --git a/cli/src/lib/workspace-trust.js b/cli/src/lib/workspace-trust.js index f720704a04..9a9d07d811 100644 --- a/cli/src/lib/workspace-trust.js +++ b/cli/src/lib/workspace-trust.js @@ -73,9 +73,10 @@ export const workspaceStartDirRefusal = (dir, { home = homedir() } = {}) => { }; const PUBLIC_POD_REASON = 'a pod it answers in is publicly readable or open to join, or did not report whether it is'; -// claude.js refuses a workspace seat under bwrap, so off macOS the default +// claude.js runs a workspace seat under Seatbelt on macOS and under bwrap on +// Linux (#2784). Native Windows has no sandbox for it, so there the default // would be a seat that fails every turn rather than one that reads a project. -const NOT_MACOS_REASON = 'an agent works in its folder only on macOS for now'; +const UNSUPPORTED_OS_REASON = 'an agent works in its folder only on macOS and Linux for now'; /** * Where a first-run claude seat may read (TASK-294), from three facts the @@ -90,7 +91,9 @@ export const firstRunWorkspace = ({ adapterName, installations, startDir, home, platform = process.platform, } = {}) => { if (adapterName !== 'claude') return null; - if (platform !== 'darwin') return { workspacePath: null, reason: NOT_MACOS_REASON }; + if (platform !== 'darwin' && platform !== 'linux') { + return { workspacePath: null, reason: UNSUPPORTED_OS_REASON }; + } if (!allPodsConfirmedPrivate(installations)) { return { workspacePath: null, reason: PUBLIC_POD_REASON }; } From 5388717b81091fcc287be3fcb7ac0b61b78074a0 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:38:56 -0700 Subject: [PATCH 2/2] fix(cli): retry a Linux Claude turn whose pod visibility could not be read A failed or timed-out read of the seat's pods was placed public and refused as delivery-refused, which hands a human's message on and, with no other listener left, drops it. A failed read is not a public pod: throw instead, so the event stays unacked and returns through the spawn-retry backoff. Nothing is read, spawned or posted either way; a confirmed-public pod is still refused for good. Answers CodeRabbit on #2820. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DPbsFurockEzieszpM --- .../workspace-turn-run-loop.test.mjs | 17 ++++++++++++++-- cli/src/commands/agent.js | 20 +++++++++++++++---- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/cli/__tests__/workspace-turn-run-loop.test.mjs b/cli/__tests__/workspace-turn-run-loop.test.mjs index 2f7276616d..2494c2deed 100644 --- a/cli/__tests__/workspace-turn-run-loop.test.mjs +++ b/cli/__tests__/workspace-turn-run-loop.test.mjs @@ -188,8 +188,6 @@ describe('performRun workspace turn placement for Claude Code on Linux', () => { test.each([ ['its pod turned public', [privatePod, { podId: 'pod-open', publicRead: true, communityListed: false }], /because a pod it answers in is publicly readable/], - ['its pods could not be read', Object.assign(new Error('boom'), { status: 503 }), - /because its pods could not be read/], ['a pod reports no visibility', [privatePod, { podId: 'pod-unknown', joinPolicy: 'invite-only' }], /did not report whether it is/], ])('a turn is refused when %s, with nothing read, spawned or posted', async (_label, installations, why) => { @@ -201,4 +199,19 @@ describe('performRun workspace turn placement for Claude Code on Linux', () => { expect(get.mock.calls.some(([route]) => route === '/api/agents/runtime/memory')).toBe(false); expect(posted(post)).toHaveLength(0); }); + + test('a failed read of its pods defers the turn for a retry instead of refusing it', async () => { + const { get, post, logs } = await runOneTurn({ + environment: { sandbox: { trust: 'workspace' } }, + installations: Object.assign(new Error('boom'), { status: 503 }), + adapterName: 'claude', + spawns: 0, + }); + expect(logs.some((line) => /turn deferred, because its pods could not be read: boom/.test(line) + && /retrying 1 held event/.test(line))).toBe(true); + expect(logs.some((line) => /^turn refused, because /.test(line))).toBe(false); + expect(post.mock.calls.some(([route]) => /\/ack$/.test(route))).toBe(false); + expect(get.mock.calls.some(([route]) => route === '/api/agents/runtime/memory')).toBe(false); + expect(posted(post)).toHaveLength(0); + }); }); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 9b3e535f56..4df329ab4f 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -1563,13 +1563,15 @@ export const performRun = ({ return { environment: seatEnvironment, cwd: agentCwd, binding: sessionBinding }; } let installations = null; + let readError = null; try { const identity = await client.get('/api/agents/runtime/installations', {}, { signal: AbortSignal.timeout(5000), }); installations = Array.isArray(identity?.installations) ? identity.installations : []; - } catch { + } catch (err) { installations = null; + readError = err; } const placement = workspaceTurnPlacement({ environment: seatEnvironment, workspacePath: agentCwd, installations, scratchDir: scratchCwd, @@ -1582,7 +1584,7 @@ export const performRun = ({ } if (!existsSync(placement.cwd)) mkdirSync(placement.cwd, { recursive: true }); const where = `${placement.environment.sandbox.trust}:${placement.cwd}`; - return { ...placement, binding: sessionBinding ? `${sessionBinding}:${where}` : where }; + return { ...placement, readError, binding: sessionBinding ? `${sessionBinding}:${where}` : where }; }; const batchLimit = Math.min(50, Math.max(1, Number(inboxBatchLimit) || 10)); @@ -1839,10 +1841,20 @@ export const performRun = ({ // not be read) is confined as a public seat, which this host may not be // able to do: Claude Code on Linux has only the workspace jail. Refuse it // as the run start would, before anything is read or spawned. The model - // never runs and nothing is posted, and a human's message is handed to - // another listener (claim-outcome.js) rather than left for a retry. + // never runs and nothing is posted. A public pod is a settled answer, so a + // human's message is handed to another listener (claim-outcome.js). A + // failed read is not: it throws, so the event stays unacked and comes back + // through the spawn-retry backoff instead of being refused for good. if (placement.reason) { const turnRefusal = hostSandboxRefusal({ environment: placement.environment, adapterName: adapter.name }); + if (turnRefusal && placement.readError) { + throw Object.assign( + new Error(`turn deferred, because its pods could not be read: ${placement.readError.message}`, { + cause: placement.readError, + }), + { status: placement.readError.status }, + ); + } if (turnRefusal) { log(`turn refused, because ${placement.reason}: ${turnRefusal.replace(/\n\s*/g, ' ')}`); return {