Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion cli/__tests__/adapters.claude.environment.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 23 additions & 5 deletions cli/__tests__/attach.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
32 changes: 28 additions & 4 deletions cli/__tests__/host-sandbox-preflight.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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',
Expand Down
18 changes: 16 additions & 2 deletions cli/__tests__/workspace-trust.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
66 changes: 61 additions & 5 deletions cli/__tests__/workspace-turn-run-loop.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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,
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
33 changes: 31 additions & 2 deletions cli/src/commands/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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));

Expand Down Expand Up @@ -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',
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
}
// 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.
Expand Down
Loading
Loading