@commonlyai/cli 0.1.184 → 0.1.186

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.184",
3
+ "version": "0.1.186",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -1563,13 +1563,15 @@ export const performRun = ({
1563
1563
  return { environment: seatEnvironment, cwd: agentCwd, binding: sessionBinding };
1564
1564
  }
1565
1565
  let installations = null;
1566
+ let readError = null;
1566
1567
  try {
1567
1568
  const identity = await client.get('/api/agents/runtime/installations', {}, {
1568
1569
  signal: AbortSignal.timeout(5000),
1569
1570
  });
1570
1571
  installations = Array.isArray(identity?.installations) ? identity.installations : [];
1571
- } catch {
1572
+ } catch (err) {
1572
1573
  installations = null;
1574
+ readError = err;
1573
1575
  }
1574
1576
  const placement = workspaceTurnPlacement({
1575
1577
  environment: seatEnvironment, workspacePath: agentCwd, installations, scratchDir: scratchCwd,
@@ -1582,7 +1584,7 @@ export const performRun = ({
1582
1584
  }
1583
1585
  if (!existsSync(placement.cwd)) mkdirSync(placement.cwd, { recursive: true });
1584
1586
  const where = `${placement.environment.sandbox.trust}:${placement.cwd}`;
1585
- return { ...placement, binding: sessionBinding ? `${sessionBinding}:${where}` : where };
1587
+ return { ...placement, readError, binding: sessionBinding ? `${sessionBinding}:${where}` : where };
1586
1588
  };
1587
1589
  const batchLimit = Math.min(50, Math.max(1, Number(inboxBatchLimit) || 10));
1588
1590
 
@@ -1835,6 +1837,33 @@ export const performRun = ({
1835
1837
  event, eventPodId, prompt, preSpawnIds, snapshotMessages, claimKeeper, trigger,
1836
1838
  }) => {
1837
1839
  const placement = await placeTurn();
1840
+ // A workspace turn placed public (its pod turned public, or its pods could
1841
+ // not be read) is confined as a public seat, which this host may not be
1842
+ // able to do: Claude Code on Linux has only the workspace jail. Refuse it
1843
+ // as the run start would, before anything is read or spawned. The model
1844
+ // never runs and nothing is posted. A public pod is a settled answer, so a
1845
+ // human's message is handed to another listener (claim-outcome.js). A
1846
+ // failed read is not: it throws, so the event stays unacked and comes back
1847
+ // through the spawn-retry backoff instead of being refused for good.
1848
+ if (placement.reason) {
1849
+ const turnRefusal = hostSandboxRefusal({ environment: placement.environment, adapterName: adapter.name });
1850
+ if (turnRefusal && placement.readError) {
1851
+ throw Object.assign(
1852
+ new Error(`turn deferred, because its pods could not be read: ${placement.readError.message}`, {
1853
+ cause: placement.readError,
1854
+ }),
1855
+ { status: placement.readError.status },
1856
+ );
1857
+ }
1858
+ if (turnRefusal) {
1859
+ log(`turn refused, because ${placement.reason}: ${turnRefusal.replace(/\n\s*/g, ' ')}`);
1860
+ return {
1861
+ outcome: 'no_action',
1862
+ refused: { reason: 'delivery-refused' },
1863
+ reason: 'host-cannot-confine-turn',
1864
+ };
1865
+ }
1866
+ }
1838
1867
  // TASK-440: a cold, large session starts fresh at wake instead of being
1839
1868
  // resumed. The restart note leads the prompt; memory still reaches the
1840
1869
  // fresh session through the adapter's preamble.
@@ -55,8 +55,10 @@ import {
55
55
  lstat,
56
56
  mkdir,
57
57
  mkdtemp,
58
+ open,
58
59
  readFile,
59
60
  readlink,
61
+ rename,
60
62
  rm,
61
63
  symlink,
62
64
  unlink,
@@ -208,6 +210,29 @@ const statOrNull = async (path) => {
208
210
  }
209
211
  };
210
212
 
213
+ // The non-secret account metadata Claude needs from ~/.claude.json to find its
214
+ // login, and nothing else from that file.
215
+ const claudeAuthMetadata = async () => {
216
+ const operatorConfigPath = join(homedir(), '.claude.json');
217
+ const sourceStat = await statOrNull(operatorConfigPath);
218
+ let source = {};
219
+ if (sourceStat) {
220
+ if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
221
+ throw new Error(`refusing non-regular operator Claude config: ${operatorConfigPath}`);
222
+ }
223
+ try {
224
+ source = JSON.parse(await readFile(operatorConfigPath, 'utf8'));
225
+ } catch (err) {
226
+ throw new Error(`failed to read operator Claude auth metadata: ${err.message}`);
227
+ }
228
+ }
229
+ const sanitized = { hasCompletedOnboarding: true };
230
+ for (const key of ['installMethod', 'userID', 'machineID', 'oauthAccount']) {
231
+ if (source[key] !== undefined) sanitized[key] = source[key];
232
+ }
233
+ return sanitized;
234
+ };
235
+
211
236
  // Claude's macOS OAuth credential lives in Keychain, but Claude also needs a
212
237
  // small amount of non-secret account metadata from ~/.claude.json to locate
213
238
  // it. Never bind or copy the operator's full file: it contains project paths,
@@ -248,35 +273,102 @@ const preparePublicClaudeState = async (ctx) => {
248
273
  await symlink(operatorKeychains, stateKeychains);
249
274
  }
250
275
 
251
- const operatorConfigPath = join(homedir(), '.claude.json');
252
- const sourceStat = await statOrNull(operatorConfigPath);
253
- let source = {};
254
- if (sourceStat) {
255
- if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) {
256
- throw new Error(`refusing non-regular operator Claude config: ${operatorConfigPath}`);
257
- }
258
- try {
259
- source = JSON.parse(await readFile(operatorConfigPath, 'utf8'));
260
- } catch (err) {
261
- throw new Error(`failed to read operator Claude auth metadata: ${err.message}`);
262
- }
263
- }
264
-
265
- const sanitized = {
266
- hasCompletedOnboarding: true,
267
- };
268
- for (const key of ['installMethod', 'userID', 'machineID', 'oauthAccount']) {
269
- if (source[key] !== undefined) sanitized[key] = source[key];
270
- }
271
276
  await writeFile(
272
277
  configPath,
273
- `${JSON.stringify(sanitized, null, 2)}\n`,
278
+ `${JSON.stringify(await claudeAuthMetadata(), null, 2)}\n`,
274
279
  { encoding: 'utf8', mode: 0o600 },
275
280
  );
276
281
  await chmod(configPath, 0o600);
277
282
  return { statePath, tmpPath, configPath };
278
283
  };
279
284
 
285
+ // Writes a 0600 file into a directory the confined child can also write. The
286
+ // directory must still be a real directory, and the file is written beside the
287
+ // target and renamed over it, so a symlink the child left at either name is
288
+ // replaced, never followed into another of the person's files.
289
+ const replacePrivateFile = async (dir, name, content) => {
290
+ const dirStat = await statOrNull(dir);
291
+ if (!dirStat || dirStat.isSymbolicLink() || !dirStat.isDirectory()) {
292
+ throw new Error(`refusing to write Claude state into a non-directory: ${dir}`);
293
+ }
294
+ const target = join(dir, name);
295
+ const staged = join(dir, `.${name}.${randomUUID()}.tmp`);
296
+ await writeFile(staged, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
297
+ try {
298
+ await chmod(staged, 0o600);
299
+ await rename(staged, target);
300
+ } catch (err) {
301
+ await rm(staged, { force: true });
302
+ throw err;
303
+ }
304
+ return target;
305
+ };
306
+
307
+ // A workspace seat on Linux runs under bwrap, which has no keychain. Claude
308
+ // Code on Linux keeps its login in <config dir>/.credentials.json, so the
309
+ // seat's own state dir gets a copy of the person's, taken fresh each spawn:
310
+ // their own credential on their own machine, the exposure the macOS keychain
311
+ // arm already has. Only the copy is inside the jail. Nothing is written back:
312
+ // the jail can write its copy, and a write-back would let it replace the
313
+ // person's login with one of its choosing. A missing login removes the copy,
314
+ // so a person who logged out is not still signed in here.
315
+ const claudeLoginSource = (env) => join(
316
+ env?.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'),
317
+ '.credentials.json',
318
+ );
319
+
320
+ // Opened once, without following a symlink, and checked on the open handle,
321
+ // so the file read is the file checked (a FIFO cannot hold the open either).
322
+ const carryClaudeLogin = async (env, configDir) => {
323
+ const source = claudeLoginSource(env);
324
+ const refuse = () => new Error(`refusing a Claude login that is not a regular file: ${source}`);
325
+ let handle;
326
+ try {
327
+ handle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
328
+ } catch (err) {
329
+ if (err?.code === 'ENOENT') {
330
+ await rm(join(configDir, '.credentials.json'), { force: true });
331
+ return null;
332
+ }
333
+ if (err?.code === 'ELOOP') throw refuse();
334
+ throw err;
335
+ }
336
+ try {
337
+ if (!(await handle.stat()).isFile()) throw refuse();
338
+ return replacePrivateFile(configDir, '.credentials.json', await handle.readFile('utf8'));
339
+ } finally {
340
+ await handle.close();
341
+ }
342
+ };
343
+
344
+ // State for a workspace seat under bwrap: the same per-seat folder the macOS
345
+ // path uses, outside the person's project, with the auth metadata whitelist
346
+ // and the login copy. The jail binds this folder writable and makes it HOME,
347
+ // so transcripts land here rather than in the project.
348
+ const prepareWorkspaceBwrapClaudeState = async (ctx) => {
349
+ const identity = ctx.agentName || ctx.cwd || 'anonymous';
350
+ const identityHash = createHash('sha256').update(identity).digest('hex').slice(0, 20);
351
+ const statePath = ctx._publicClaudeState || publicClaudeStateRoot(identityHash);
352
+ const tmpPath = join(statePath, 'tmp');
353
+ const configDir = join(statePath, '.claude');
354
+ await mkdir(statePath, { recursive: true, mode: 0o700 });
355
+ await chmod(statePath, 0o700);
356
+ for (const dir of [tmpPath, configDir]) {
357
+ const existing = await statOrNull(dir);
358
+ if (existing && (existing.isSymbolicLink() || !existing.isDirectory())) {
359
+ throw new Error(`refusing Claude state path that is not a directory: ${dir}`);
360
+ }
361
+ if (!existing) await mkdir(dir, { mode: 0o700 });
362
+ await chmod(dir, 0o700);
363
+ }
364
+
365
+ const configPath = await replacePrivateFile(
366
+ statePath, '.claude.json', `${JSON.stringify(await claudeAuthMetadata(), null, 2)}\n`,
367
+ );
368
+ const loginPath = await carryClaudeLogin(ctx.env, configDir);
369
+ return { statePath, tmpPath, configPath, loginPath };
370
+ };
371
+
280
372
  const buildClaudeEnv = (input, state, expansionEnv = {}, credentialFile = null, publicSeat = false) => {
281
373
  const source = input || process.env;
282
374
  const output = state || publicSeat ? {} : { ...source };
@@ -696,6 +788,25 @@ const resolveClaudePath = (env = process.env) => {
696
788
  return 'claude';
697
789
  };
698
790
 
791
+ // Where the claude this host would run is installed: the folder the Linux
792
+ // workspace jail has to bind, since it binds the system binary folders only.
793
+ // Null when the binary cannot be located (a bare name, a dangling link).
794
+ const resolveClaudeInstall = (env) => {
795
+ const claudeBin = resolveClaudePath(env);
796
+ if (!isAbsolute(claudeBin)) return { claudeBin, claudeInstall: null };
797
+ try {
798
+ return { claudeBin, claudeInstall: dirname(realpathSync(claudeBin)) };
799
+ } catch {
800
+ return { claudeBin, claudeInstall: null };
801
+ }
802
+ };
803
+
804
+ // A jail that cannot carry claude would fail inside at every spawn.
805
+ const claudeInstallRefusal = (claudeBin) => new Error(
806
+ 'workspace Claude agents on Linux need Claude Code on PATH, so the sandbox can carry its install; '
807
+ + `\`${claudeBin}\` could not be resolved`,
808
+ );
809
+
699
810
  const prepareArgv = async (innerArgv, ctx) => {
700
811
  const env = ctx.environment;
701
812
  const claudeEnv = withClaudePath(ctx.claudeEnv);
@@ -732,6 +843,9 @@ const prepareArgv = async (innerArgv, ctx) => {
732
843
  // get the jail only — a derived Linux seat kept Bash/Write/WebFetch and ran on
733
844
  // the operator's own Claude settings inside the namespace (Vera 69578).
734
845
  const publicBwrapSandbox = sandboxTrust === 'public' && sandboxMode === 'bwrap';
846
+ // A workspace seat on Linux: the same jail, with the workspace tier's policy
847
+ // and a HOME of its own outside the project.
848
+ const workspaceBwrapSandbox = sandboxTrust === 'workspace' && sandboxMode === 'bwrap';
735
849
  const commitHooksPath = ctx.commitAttributionEnv?.COMMONLY_AGENT_HOOKS_PATH || null;
736
850
  const originalHooksPath = ctx.commitAttributionEnv?.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH || null;
737
851
  const commitHookExecutables = commitHooksPath
@@ -754,12 +868,6 @@ const prepareArgv = async (innerArgv, ctx) => {
754
868
  // non-string) used to fall through to the bare `claude` spawn below — an
755
869
  // unconfined seat with a public record, which is how a record can claim
756
870
  // confinement it never applies (Vera 69548).
757
- // bwrap points the child's HOME at its folder (childHome below), so a
758
- // workspace seat's transcripts would be written into the person's project.
759
- if (sandboxTrust === 'workspace' && sandboxMode === 'bwrap') {
760
- throw new Error('workspace Claude agents run on macOS only for now: under bwrap '
761
- + 'their transcripts would be written into the project folder');
762
- }
763
871
  if (isConfinedTrust(sandboxTrust) && !publicNativeSandbox && sandboxMode !== 'bwrap') {
764
872
  throw new Error(
765
873
  `${sandboxTrust} Claude agents require an enforced sandbox mode `
@@ -818,23 +926,68 @@ const prepareArgv = async (innerArgv, ctx) => {
818
926
  };
819
927
  }
820
928
 
821
- if (publicBwrapSandbox) {
929
+ if (publicBwrapSandbox || workspaceBwrapSandbox) {
822
930
  // The mode is chosen here, so the mechanism is verified here: a missing
823
931
  // bwrap must fail the seat's derivation with an actionable message, not
824
932
  // every one of its spawns with a bare ENOENT (Wren 69586). On a non-Linux
825
933
  // host this reports the macOS-only message, same as the wrapper would.
826
934
  const bwrap = (ctx._detectBwrap || detectBwrap)();
827
935
  if (!bwrap.available) {
828
- throw new Error(`public Claude agents require bwrap: ${bwrap.error}`);
936
+ throw new Error(`${sandboxTrust} Claude agents require bwrap: ${bwrap.error}`);
937
+ }
938
+ if (workspaceBwrapSandbox && !ctx.publicClaudeState) {
939
+ throw new Error('workspace Claude state was not prepared before argv construction');
829
940
  }
830
941
  innerArgv = [
831
942
  ...innerArgv,
832
- ...buildPublicClaudePolicyArgs(allowedPatterns, { memoryWithheld: ctx.memoryWithheld === true }),
943
+ ...buildPublicClaudePolicyArgs(allowedPatterns, workspaceBwrapSandbox
944
+ ? { trust: sandboxTrust, allow: grants, memoryWithheld: ctx.memoryWithheld === true }
945
+ : { memoryWithheld: ctx.memoryWithheld === true }),
833
946
  ...(ctx.publicSkillArgs || []),
834
947
  ];
835
948
  } else if (allowedPatterns.length > 0) {
836
949
  innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns];
837
950
  }
951
+ if (workspaceBwrapSandbox) {
952
+ // Seatbelt denies writes under .git by path, present or not. A bwrap mount
953
+ // needs the path to exist, so a writable project without a .git could get
954
+ // one from the child, hooks included, that the person's next git command
955
+ // runs. Refuse that rather than create anything in their project.
956
+ if (grants.includes('write')) {
957
+ const gitEntry = await statOrNull(join(ctx.cwd, '.git'));
958
+ if (!gitEntry || gitEntry.isSymbolicLink()) {
959
+ throw new Error('workspace Claude agents with write on Linux need the project to be a git '
960
+ + `repository (run \`git init\` in ${ctx.cwd}), so its .git can be held read-only`);
961
+ }
962
+ }
963
+ const { claudeBin, claudeInstall } = resolveClaudeInstall(claudeEnv);
964
+ if (!claudeInstall) throw claudeInstallRefusal(claudeBin);
965
+ const { statePath } = ctx.publicClaudeState;
966
+ const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, {
967
+ workspacePath: ctx.cwd,
968
+ workspaceReadOnly: !grants.includes('write'),
969
+ readOnlyWorkspaceEntries: ['.git'],
970
+ homePath: statePath,
971
+ writablePaths: [statePath],
972
+ readOnlyPaths: [
973
+ ctx.mcpConfigDir, commitHooksPath, originalHooksPath,
974
+ ...(ctx.mcpReadDirectories || []),
975
+ ...(ctx.workspaceGit?.readDirectories || []),
976
+ ...(ctx.workspaceGit?.readPaths || []),
977
+ ].filter(Boolean),
978
+ // The binaries the jail runs: claude (a user-local install is outside
979
+ // the default binds, and its link points into a versions folder), the
980
+ // node that runs the stdio MCP servers, and the git the history tool runs.
981
+ optionalReadOnlyPaths: [...new Set([
982
+ dirname(claudeBin), claudeInstall, dirname(process.execPath),
983
+ ctx.workspaceGit ? dirname(ctx.workspaceGit.executable) : null,
984
+ // A claude not found on PATH is left bare and fails at spawn; its
985
+ // dirname is '.', which is not a path to bind.
986
+ ].filter((p) => p && isAbsolute(p)))],
987
+ });
988
+ // TASK-440 reads the transcript from the HOME the child wrote under.
989
+ return { cmd: wrapped[0], args: wrapped.slice(1), env: claudeEnv, childHome: statePath };
990
+ }
838
991
  if (sandboxMode === 'bwrap') {
839
992
  const claudeBin = resolveClaudePath(claudeEnv);
840
993
  const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, {
@@ -974,6 +1127,8 @@ export default {
974
1127
  && PUBLIC_SANDBOX_MODES.has(resolvePublicSandboxMode(ctx.environment?.sandbox));
975
1128
  const publicBwrapSandbox = publicTrust
976
1129
  && resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
1130
+ const workspaceBwrapSandbox = ctx.environment?.sandbox?.trust === 'workspace'
1131
+ && resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
977
1132
  try {
978
1133
  // Trust is normalized at entry (legacy `internal` reads as public). The
979
1134
  // MCP policy follows trust alone, whatever confines the seat: a public
@@ -981,6 +1136,14 @@ export default {
981
1136
  // (Rhea on #2247).
982
1137
  if (publicNativeSandbox) {
983
1138
  publicClaudeState = await preparePublicClaudeState(ctx);
1139
+ } else if (workspaceBwrapSandbox) {
1140
+ // Checked before the state is prepared, so a host that cannot run the
1141
+ // jail never gets a copy of the login.
1142
+ const bwrap = (ctx._detectBwrap || detectBwrap)();
1143
+ if (!bwrap.available) throw new Error(`workspace Claude agents require bwrap: ${bwrap.error}`);
1144
+ const { claudeBin, claudeInstall } = resolveClaudeInstall(withClaudePath(ctx.env));
1145
+ if (!claudeInstall) throw claudeInstallRefusal(claudeBin);
1146
+ publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx);
984
1147
  }
985
1148
  if (publicTrust) {
986
1149
  const prompt = await publicSkillPromptArgs(mountedSkills);
@@ -161,7 +161,29 @@ export const wrapArgvWithBwrap = (innerArgv, env, opts = {}) => {
161
161
  flags.push('--ro-bind-try', p, p);
162
162
  }
163
163
 
164
- flags.push('--bind', opts.workspacePath, opts.workspacePath);
164
+ // A HOME the adapter keeps outside the workspace (a workspace seat's state)
165
+ // is bound writable on its own, so the child's transcripts and login stay
166
+ // out of the person's project.
167
+ for (const p of opts.writablePaths || []) {
168
+ if (!p || !isAbsolute(p)) {
169
+ throw new Error('wrapArgvWithBwrap opts.writablePaths entries must be absolute paths');
170
+ }
171
+ flags.push('--bind', p, p);
172
+ }
173
+
174
+ flags.push(opts.workspaceReadOnly ? '--ro-bind' : '--bind', opts.workspacePath, opts.workspacePath);
175
+ // Entries held read-only inside a writable workspace (its .git). Bound after
176
+ // the workspace so they land on top of it. In a writable workspace a missing
177
+ // entry cannot be protected — the child could create it, and a planted
178
+ // .git/hooks runs on the person's next git command — so the bind is strict
179
+ // there and bwrap fails rather than starts.
180
+ for (const entry of opts.readOnlyWorkspaceEntries || []) {
181
+ if (!entry || isAbsolute(entry) || entry.split(/[\\/]/).includes('..')) {
182
+ throw new Error('wrapArgvWithBwrap opts.readOnlyWorkspaceEntries entries must be relative names inside the workspace');
183
+ }
184
+ const p = join(opts.workspacePath, entry);
185
+ flags.push(opts.workspaceReadOnly ? '--ro-bind-try' : '--ro-bind', p, p);
186
+ }
165
187
  flags.push('--chdir', opts.workspacePath);
166
188
  flags.push('--setenv', 'HOME', opts.homePath || opts.workspacePath);
167
189
 
@@ -46,15 +46,17 @@ export const windowsSandboxMessage = (adapterName) => {
46
46
  };
47
47
 
48
48
  /**
49
- * Claude Code on Linux (WSL included). The bubblewrap namespace binds the
50
- * system binary folders only, and Claude Code installs under the person's
51
- * home or /usr/local, so a confined spawn could not even start it; its login
52
- * and settings live under that home too (Ubuntu run, 2026-10-16). Until the
53
- * namespace carries what Claude needs, the seat stops here with its options.
49
+ * Claude Code on Linux (WSL included) for any seat but a workspace one. Only
50
+ * the workspace jail (#2784) carries what Claude needs: its install, a HOME
51
+ * of its own and a copy of the login. The public jail binds the system binary
52
+ * folders only, so a confined spawn could not even start Claude (Ubuntu run,
53
+ * 2026-10-16), and giving it the login would hand strangers a jail that holds
54
+ * it. A workspace seat needs every pod it answers in to be invite-only.
54
55
  */
55
56
  export const linuxClaudeMessage = () => [
56
- 'Commonly runs a connected Claude Code inside a sandbox, and on Linux (WSL included) that sandbox',
57
- 'cannot run Claude Code yet, so this agent cannot listen for @mentions here. Instead:',
57
+ 'Commonly runs a connected Claude Code inside a sandbox. On Linux (WSL included) it does that only',
58
+ 'for an agent whose pods are all invite-only, and this one is not, so it cannot listen for @mentions here. Instead:',
59
+ ' - From an invite-only pod: run the commands its Connect page shows, from your project\'s folder.',
58
60
  ' - Use Commonly from your own Claude Code sessions: the Connect page shows a `claude mcp add` command.',
59
61
  HOSTED_LINE,
60
62
  ].join('\n');
@@ -76,7 +78,11 @@ export const hostSandboxRefusal = ({
76
78
  if (mode !== 'bwrap' && !SEATBELT_MODES.has(mode)) return null;
77
79
 
78
80
  if (platform === 'win32') return windowsSandboxMessage(adapterName);
79
- if (mode === 'bwrap' && adapterName === 'claude') return linuxClaudeMessage();
81
+ // A workspace claude seat falls through to the bubblewrap probe below; the
82
+ // probe failing, for any reason, is a refusal like any other adapter's.
83
+ if (mode === 'bwrap' && adapterName === 'claude' && sandbox.trust !== 'workspace') {
84
+ return linuxClaudeMessage();
85
+ }
80
86
  if (mode === 'bwrap') {
81
87
  const bwrap = detectBwrap();
82
88
  return bwrap.available ? null : `This seat runs in a bubblewrap sandbox, which is not available here. ${bwrap.error}`;
@@ -73,9 +73,10 @@ export const workspaceStartDirRefusal = (dir, { home = homedir() } = {}) => {
73
73
  };
74
74
 
75
75
  const PUBLIC_POD_REASON = 'a pod it answers in is publicly readable or open to join, or did not report whether it is';
76
- // claude.js refuses a workspace seat under bwrap, so off macOS the default
76
+ // claude.js runs a workspace seat under Seatbelt on macOS and under bwrap on
77
+ // Linux (#2784). Native Windows has no sandbox for it, so there the default
77
78
  // would be a seat that fails every turn rather than one that reads a project.
78
- const NOT_MACOS_REASON = 'an agent works in its folder only on macOS for now';
79
+ const UNSUPPORTED_OS_REASON = 'an agent works in its folder only on macOS and Linux for now';
79
80
 
80
81
  /**
81
82
  * Where a first-run claude seat may read (TASK-294), from three facts the
@@ -90,7 +91,9 @@ export const firstRunWorkspace = ({
90
91
  adapterName, installations, startDir, home, platform = process.platform,
91
92
  } = {}) => {
92
93
  if (adapterName !== 'claude') return null;
93
- if (platform !== 'darwin') return { workspacePath: null, reason: NOT_MACOS_REASON };
94
+ if (platform !== 'darwin' && platform !== 'linux') {
95
+ return { workspacePath: null, reason: UNSUPPORTED_OS_REASON };
96
+ }
94
97
  if (!allPodsConfirmedPrivate(installations)) {
95
98
  return { workspacePath: null, reason: PUBLIC_POD_REASON };
96
99
  }