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..2494c2deed 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,56 @@ 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/], + ['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); + }); + + 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 15004bee07..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)); @@ -1835,6 +1837,33 @@ 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. 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 { + 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 }; }