@commonlyai/cli 0.1.235 → 0.1.237

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.
@@ -169,10 +169,14 @@ const readProviderKey = async (keyFile, adapterLabel) => {
169
169
 
170
170
  // The routes a CLI on each wire may call, relative to the configured base
171
171
  // path, and the header the real key goes out in. Anything else is refused.
172
+ // `search` lists the exact query strings a wire's client sends, forwarded as
173
+ // sent: Claude Code calls `/v1/messages?beta=true` (F96). Any other query is
174
+ // refused.
172
175
  const WIRES = Object.freeze({
173
176
  'openai-chat': { routes: [['POST', 'chat/completions'], ['GET', 'models']], auth: 'bearer' },
174
177
  'anthropic-messages': {
175
178
  routes: [['POST', 'v1/messages'], ['POST', 'v1/messages/count_tokens'], ['GET', 'v1/models']],
179
+ search: ['?beta=true'],
176
180
  auth: 'x-api-key',
177
181
  },
178
182
  'openai-responses': { routes: [['POST', 'responses'], ['GET', 'models']], auth: 'bearer' },
@@ -276,7 +280,8 @@ export const startProviderProxy = async ({
276
280
  // This keeps a CLI request from turning the proxy into an arbitrary
277
281
  // path/URL fetcher (including paths on a local provider host).
278
282
  const requested = new URL(requestPath, 'http://127.0.0.1');
279
- if (requested.origin !== 'http://127.0.0.1' || requested.search || requested.hash) {
283
+ if (requested.origin !== 'http://127.0.0.1' || requested.hash
284
+ || (requested.search && !(wireSpec.search || []).includes(requested.search))) {
280
285
  rejectRoute(requested.pathname);
281
286
  return;
282
287
  }
@@ -289,7 +294,7 @@ export const startProviderProxy = async ({
289
294
  }
290
295
  const endpoint = route[1];
291
296
  target = {
292
- path: `${configuredPath}/${endpoint}`,
297
+ path: `${configuredPath}/${endpoint}${requested.search}`,
293
298
  protocol: upstream.protocol,
294
299
  hostname: upstream.hostname,
295
300
  port: upstream.port || undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.235",
3
+ "version": "0.1.237",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -1625,6 +1625,8 @@ export const performRun = ({
1625
1625
  setTimeoutImpl = setTimeout,
1626
1626
  setIntervalImpl = setInterval,
1627
1627
  clearIntervalImpl = clearInterval,
1628
+ // The host's bubblewrap probe, for a turn placed public; tests pin it.
1629
+ detectBwrapImpl,
1628
1630
  setDeadlineTimeoutImpl = setTimeout,
1629
1631
  clearDeadlineTimeoutImpl = clearTimeout,
1630
1632
  turnTimeoutMs,
@@ -2139,14 +2141,19 @@ export const performRun = ({
2139
2141
  const placement = await placeTurn();
2140
2142
  // A workspace turn placed public (its pod turned public, or its pods could
2141
2143
  // not be read) is confined as a public seat, which this host may not be
2142
- // able to do: Claude Code on Linux has only the workspace jail. Refuse it
2143
- // as the run start would, before anything is read or spawned. The model
2144
+ // able to do (a Linux host without bubblewrap). Refuse it as the run start
2145
+ // would, before anything is read or spawned. The model
2144
2146
  // never runs and nothing is posted. A public pod is a settled answer, so a
2145
2147
  // human's message is handed to another listener (claim-outcome.js). A
2146
2148
  // failed read is not: it throws, so the event stays unacked and comes back
2147
2149
  // through the spawn-retry backoff instead of being refused for good.
2148
2150
  if (placement.reason) {
2149
- const turnRefusal = hostSandboxRefusal({ environment: placement.environment, adapterName: adapter.name, hostUnconfined });
2151
+ const turnRefusal = hostSandboxRefusal({
2152
+ environment: placement.environment,
2153
+ adapterName: adapter.name,
2154
+ hostUnconfined,
2155
+ ...(detectBwrapImpl ? { detectBwrap: detectBwrapImpl } : {}),
2156
+ });
2150
2157
  if (turnRefusal && placement.readError) {
2151
2158
  throw Object.assign(
2152
2159
  new Error(`turn deferred, because its pods could not be read: ${placement.readError.message}`, {
@@ -64,9 +64,11 @@ import {
64
64
  mkdtemp,
65
65
  open,
66
66
  readFile,
67
+ readdir,
67
68
  readlink,
68
69
  rename,
69
70
  rm,
71
+ rmdir,
70
72
  symlink,
71
73
  unlink,
72
74
  writeFile,
@@ -98,11 +100,12 @@ import {
98
100
  import { BACKGROUND_PROCESS_CUE, buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
99
101
  import { claudeConfigDirFor, claudeContextTokens, claudeTranscriptPath } from '../session-recycle.js';
100
102
  import { skillsAsText } from '../skill-prompt.js';
101
- import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
103
+ import { adapterFailure, redactKnownCredentials, spawnCredentials } from '../upstream-refusal.js';
102
104
  import { PUBLIC_WITHHELD_TOOLS } from '../public-withheld-tools.js';
103
105
  import { isShippedCommonlyMcpCommand } from '../declared-mcp-guard.js';
104
106
  import { isAnswerableToolError, PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE } from '../public-tool-error.js';
105
107
  import { resolveLaunch, resolveMcpCommand, resolveWindowsCommandWithReason } from '../windows-command.js';
108
+ import { startProviderProxy } from '../provider-proxy.js';
106
109
 
107
110
  // See codex.js for the rationale on bumping the default + env override.
108
111
  // Keeping both adapters in lockstep so any wrapper agent runtime has the
@@ -325,14 +328,71 @@ const replacePrivateFile = async (dir, name, content) => {
325
328
  return target;
326
329
  };
327
330
 
328
- // A workspace seat on Linux runs under bwrap, which has no keychain. Claude
329
- // Code on Linux keeps its login in <config dir>/.credentials.json, so the
330
- // seat's own state dir gets a copy of the person's, taken fresh each spawn:
331
- // their own credential on their own machine, the exposure the macOS keychain
332
- // arm already has. Only the copy is inside the jail. Nothing is written back:
333
- // the jail can write its copy, and a write-back would let it replace the
334
- // person's login with one of its choosing. A missing login removes the copy,
335
- // so a person who logged out is not still signed in here.
331
+ // The host's ANTHROPIC_BASE_URL, for a seat that reaches the model on the
332
+ // person's login. Only when it is the route the login uses: with an
333
+ // ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN beside it, it is that key's
334
+ // gateway, and the jail (which never gets the key) would send the person's
335
+ // subscription token there instead. A URL carrying a user or password is not
336
+ // forwarded either.
337
+ export const loginRouteBaseUrl = (source = {}) => {
338
+ if (!source.ANTHROPIC_BASE_URL || source.ANTHROPIC_API_KEY || source.ANTHROPIC_AUTH_TOKEN) return null;
339
+ try {
340
+ const url = new URL(source.ANTHROPIC_BASE_URL);
341
+ return url.username || url.password ? null : source.ANTHROPIC_BASE_URL;
342
+ } catch {
343
+ return null;
344
+ }
345
+ };
346
+
347
+ // Two turns of one public agent share its folder (a daemon and a separate
348
+ // `agent run`, or two events at once). Each registers itself in a sibling of
349
+ // that folder, outside the jail's writable bind so a turn cannot forge one,
350
+ // and the login copy is removed only by the last live turn to finish.
351
+ const registerLoginTurn = async (statePath) => {
352
+ const dir = `${statePath}.login-turns`;
353
+ await mkdir(dir, { recursive: true, mode: 0o700 });
354
+ const marker = join(dir, `${process.pid}-${randomUUID()}`);
355
+ await writeFile(marker, '', { mode: 0o600, flag: 'wx' });
356
+ return marker;
357
+ };
358
+ const pidAlive = (pid) => {
359
+ try {
360
+ process.kill(pid, 0);
361
+ return true;
362
+ } catch (err) {
363
+ return err?.code === 'EPERM';
364
+ }
365
+ };
366
+ const releaseLoginTurn = async (marker, loginPath) => {
367
+ await rm(marker, { force: true });
368
+ const dir = dirname(marker);
369
+ let others = [];
370
+ try {
371
+ others = await readdir(dir);
372
+ } catch { /* gone: nobody else is registered */ }
373
+ let live = 0;
374
+ for (const name of others) {
375
+ const pid = Number(name.split('-')[0]);
376
+ if (Number.isInteger(pid) && pid > 0 && pidAlive(pid)) live += 1;
377
+ else await rm(join(dir, name), { force: true });
378
+ }
379
+ if (!live) {
380
+ await rm(loginPath, { force: true });
381
+ // Only an empty folder goes: a turn registering at this moment keeps it.
382
+ try { await rmdir(dir); } catch { /* not empty, or already gone */ }
383
+ }
384
+ };
385
+
386
+ // A seat on Linux runs under bwrap, which has no keychain. Claude Code on
387
+ // Linux keeps its login in <config dir>/.credentials.json, so the seat's own
388
+ // state dir gets a copy of the person's, taken fresh each spawn. Only the copy
389
+ // is inside the jail. Nothing is written back: the jail can write its copy, and
390
+ // a write-back would let it replace the person's login with one of its
391
+ // choosing. Since F96 the copy is access-only for every seat, workspace ones
392
+ // included: a jail that refreshed would spend the host's refresh token and
393
+ // leave the person signed out (CI, 11-04: Claude Code rewrites the jail's copy
394
+ // when its session check fails). A missing login removes the copy, so a person
395
+ // who logged out is not still signed in here.
336
396
  const claudeLoginSource = (env) => join(
337
397
  env?.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'),
338
398
  '.credentials.json',
@@ -340,7 +400,40 @@ const claudeLoginSource = (env) => join(
340
400
 
341
401
  // Opened once, without following a symlink, and checked on the open handle,
342
402
  // so the file read is the file checked (a FIFO cannot hold the open either).
343
- const carryClaudeLogin = async (env, configDir) => {
403
+ // A public seat's copy (F96) holds the access token alone. Claude Code
404
+ // rotates the refresh token when it refreshes, so a jail holding one could
405
+ // spend it and leave the host signed out; without one it cannot refresh at
406
+ // all, so a login that would expire mid-turn is refused here instead.
407
+ // What a public seat's access-only login keeps: the token and the account
408
+ // metadata Claude Code reads beside it.
409
+ const ACCESS_ONLY_LOGIN_FIELDS = ['accessToken', 'expiresAt', 'scopes', 'subscriptionType', 'rateLimitTier'];
410
+
411
+ const accessOnlyLogin = (text, { source, mustOutlastMs, apiKeyHint = true }) => {
412
+ let login;
413
+ try {
414
+ login = JSON.parse(text);
415
+ } catch {
416
+ throw new Error(`refusing a Claude login that is not JSON: ${source}`);
417
+ }
418
+ const oauth = login?.claudeAiOauth;
419
+ if (!oauth || typeof oauth.accessToken !== 'string' || !oauth.accessToken) {
420
+ throw new Error(`refusing a Claude login with no access token: ${source}`);
421
+ }
422
+ if (typeof oauth.expiresAt === 'number' && oauth.expiresAt - Date.now() < mustOutlastMs) {
423
+ const minutes = Math.max(0, Math.floor((oauth.expiresAt - Date.now()) / 60000));
424
+ throw new Error(`the Claude login on this machine expires in ${minutes} min, before this turn could end. `
425
+ + 'Sign in again in your own terminal on this machine, outside the agent: run `claude`, then /login'
426
+ + `${apiKeyHint ? '. Or set ANTHROPIC_API_KEY for the agent' : ''}.`);
427
+ }
428
+ // An allowlist, not "everything but refreshToken": a field the host record
429
+ // gains later, secret or not, never reaches the seat's writable HOME.
430
+ const access = Object.fromEntries(ACCESS_ONLY_LOGIN_FIELDS
431
+ .filter((field) => oauth[field] !== undefined)
432
+ .map((field) => [field, oauth[field]]));
433
+ return `${JSON.stringify({ claudeAiOauth: access })}\n`;
434
+ };
435
+
436
+ const carryClaudeLogin = async (env, configDir, { accessOnly = false, mustOutlastMs = 0, apiKeyHint = true } = {}) => {
344
437
  const source = claudeLoginSource(env);
345
438
  const refuse = () => new Error(`refusing a Claude login that is not a regular file: ${source}`);
346
439
  let handle;
@@ -356,20 +449,33 @@ const carryClaudeLogin = async (env, configDir) => {
356
449
  }
357
450
  try {
358
451
  if (!(await handle.stat()).isFile()) throw refuse();
359
- return replacePrivateFile(configDir, '.credentials.json', await handle.readFile('utf8'));
452
+ const text = await handle.readFile('utf8');
453
+ return replacePrivateFile(configDir, '.credentials.json', accessOnly
454
+ ? accessOnlyLogin(text, { source, mustOutlastMs, apiKeyHint })
455
+ : text);
360
456
  } finally {
361
457
  await handle.close();
362
458
  }
363
459
  };
364
460
 
365
- // State for a workspace seat under bwrap: the same per-seat folder the macOS
366
- // path uses, outside the person's project, with the auth metadata whitelist
367
- // and the login copy. The jail binds this folder writable and makes it HOME,
368
- // so transcripts land here rather than in the project.
369
- const prepareWorkspaceBwrapClaudeState = async (ctx) => {
461
+ // The per-seat folder a bwrap seat runs in. A public seat (F96) gets its own,
462
+ // never the workspace seat's: that one holds the transcripts of private-pod
463
+ // turns, and the same agent can run both kinds of
464
+ // turn, even at once (a daemon and a separate `agent run`).
465
+ export const bwrapClaudeStatePath = (ctx, seat = 'workspace') => {
466
+ if (ctx._publicClaudeState) return ctx._publicClaudeState;
370
467
  const identity = ctx.agentName || ctx.cwd || 'anonymous';
371
468
  const identityHash = createHash('sha256').update(identity).digest('hex').slice(0, 20);
372
- const statePath = ctx._publicClaudeState || publicClaudeStateRoot(identityHash);
469
+ return publicClaudeStateRoot(seat === 'public' ? `${identityHash}-public` : identityHash);
470
+ };
471
+
472
+ // State for a seat under bwrap, outside the person's project, with the auth
473
+ // metadata whitelist and the login copy. The jail binds this folder writable
474
+ // and makes it HOME, so transcripts land here rather than in the project.
475
+ // Every seat carries an access-only copy of the login, or a public seat none
476
+ // when an API key reaches the model through the provider proxy instead.
477
+ const prepareWorkspaceBwrapClaudeState = async (ctx, { login = 'carry', seat = 'workspace' } = {}) => {
478
+ const statePath = bwrapClaudeStatePath(ctx, seat);
373
479
  const tmpPath = join(statePath, 'tmp');
374
480
  const configDir = join(statePath, '.claude');
375
481
  await mkdir(statePath, { recursive: true, mode: 0o700 });
@@ -386,7 +492,15 @@ const prepareWorkspaceBwrapClaudeState = async (ctx) => {
386
492
  const configPath = await replacePrivateFile(
387
493
  statePath, '.claude.json', `${JSON.stringify(await claudeAuthMetadata(), null, 2)}\n`,
388
494
  );
389
- const loginPath = await carryClaudeLogin(ctx.env, configDir);
495
+ if (login === 'none') {
496
+ await rm(join(configDir, '.credentials.json'), { force: true });
497
+ return { statePath, tmpPath, configPath, loginPath: null };
498
+ }
499
+ const loginPath = await carryClaudeLogin(ctx.env, configDir, login === 'access-only'
500
+ // A workspace seat's jail never sees the host's API key, so only a public
501
+ // seat is told to set one.
502
+ ? { accessOnly: true, mustOutlastMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS, apiKeyHint: seat === 'public' }
503
+ : {});
390
504
  return { statePath, tmpPath, configPath, loginPath };
391
505
  };
392
506
 
@@ -640,12 +754,14 @@ const publicWitnessError = ({ code, tool, error }) => {
640
754
  };
641
755
 
642
756
  // The reply a turn posts. A public seat's turn is witnessed first (TASK-256).
757
+ // Both ways out of a turn are redacted: a reply that echoes a credential the
758
+ // child was handed (the per-turn proxy token included), and the witness error.
643
759
  const turnReply = (stdout, { credentials, publicMcp }) => {
644
760
  if (publicMcp) {
645
761
  const failure = claudePublicMcpWitness(stdout, publicMcp);
646
- if (failure) throw publicWitnessError(failure);
762
+ if (failure) throw publicWitnessError({ ...failure, error: redactKnownCredentials(String(failure.error), credentials) });
647
763
  }
648
- return claudeResultText(stdout, { credentials });
764
+ return redactKnownCredentials(claudeResultText(stdout, { credentials }), credentials);
649
765
  };
650
766
 
651
767
  // The failure tail for a non-zero exit. Stream events are mostly system init
@@ -1063,7 +1179,7 @@ const resolveClaudeInstall = (env) => {
1063
1179
 
1064
1180
  // A jail that cannot carry claude would fail inside at every spawn.
1065
1181
  const claudeInstallRefusal = (claudeBin) => new Error(
1066
- 'workspace Claude agents on Linux need Claude Code on PATH, so the sandbox can carry its install; '
1182
+ 'Claude agents on Linux need Claude Code on PATH, so the sandbox can carry its install; '
1067
1183
  + `\`${claudeBin}\` could not be resolved`,
1068
1184
  );
1069
1185
 
@@ -1231,8 +1347,8 @@ const prepareArgv = async (innerArgv, ctx) => {
1231
1347
  if (!bwrap.available) {
1232
1348
  throw new Error(`${sandboxTrust} Claude agents require bwrap: ${bwrap.error}`);
1233
1349
  }
1234
- if (workspaceBwrapSandbox && !ctx.publicClaudeState) {
1235
- throw new Error('workspace Claude state was not prepared before argv construction');
1350
+ if (!ctx.publicClaudeState) {
1351
+ throw new Error(`${sandboxTrust} Claude state was not prepared before argv construction`);
1236
1352
  }
1237
1353
  innerArgv = [
1238
1354
  ...innerArgv,
@@ -1244,7 +1360,7 @@ const prepareArgv = async (innerArgv, ctx) => {
1244
1360
  } else if (allowedPatterns.length > 0) {
1245
1361
  innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns];
1246
1362
  }
1247
- if (workspaceBwrapSandbox) {
1363
+ if (workspaceBwrapSandbox || publicBwrapSandbox) {
1248
1364
  // Seatbelt denies writes under .git by path, present or not. A bwrap mount
1249
1365
  // needs the path to exist, so a writable project without a .git could get
1250
1366
  // one from the child, hooks included, that the person's next git command
@@ -1432,6 +1548,10 @@ export default {
1432
1548
 
1433
1549
  let mcpConfig = null;
1434
1550
  let publicClaudeState = null;
1551
+ let providerProxy = null;
1552
+ // A login seat's gateway, when the host names one: config, not a secret.
1553
+ let publicLoginBaseUrl = null;
1554
+ let loginTurn = null;
1435
1555
  let publicSkillArgs = [];
1436
1556
  // D245: no jail to prepare for on a policy-only Windows host.
1437
1557
  const jailed = !policyOnlyHost(ctx);
@@ -1441,6 +1561,18 @@ export default {
1441
1561
  && resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
1442
1562
  const workspaceBwrapSandbox = jailed && ctx.environment?.sandbox?.trust === 'workspace'
1443
1563
  && resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
1564
+ // The proxy's address and per-turn token, never the host's key.
1565
+ const withProvider = (env) => {
1566
+ if (providerProxy) {
1567
+ return Object.assign(env, { ANTHROPIC_BASE_URL: providerProxy.baseURL, ANTHROPIC_API_KEY: providerProxy.token });
1568
+ }
1569
+ return publicLoginBaseUrl ? Object.assign(env, { ANTHROPIC_BASE_URL: publicLoginBaseUrl }) : env;
1570
+ };
1571
+ // The values a failure tail or reply must never echo: the seat's own, and
1572
+ // the per-turn proxy token the child was handed in place of the host key.
1573
+ const turnCredentials = (spawnContext) => [
1574
+ ...spawnCredentials(spawnContext), providerProxy?.token,
1575
+ ].filter(Boolean);
1444
1576
  try {
1445
1577
  // Trust is normalized at entry (legacy `internal` reads as public). The
1446
1578
  // MCP policy follows trust alone, whatever confines the seat: a public
@@ -1455,7 +1587,50 @@ export default {
1455
1587
  if (!bwrap.available) throw new Error(`workspace Claude agents require bwrap: ${bwrap.error}`);
1456
1588
  const { claudeBin, claudeInstall } = resolveClaudeInstall(withClaudePath(ctx.env));
1457
1589
  if (!claudeInstall) throw claudeInstallRefusal(claudeBin);
1458
- publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx);
1590
+ publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx, { login: 'access-only' });
1591
+ // The host's model route, when it is the one the login uses.
1592
+ publicLoginBaseUrl = loginRouteBaseUrl(ctx.env || process.env);
1593
+ } else if (publicBwrapSandbox) {
1594
+ // F96: the workspace tier's jail, reaching the model one of two ways.
1595
+ // An API key stays on the host behind a per-turn proxy; without one,
1596
+ // the seat gets an access-only copy of the login, deleted after the turn.
1597
+ // Either way the model is across the network: restricted networking
1598
+ // (--unshare-net) cuts off the host proxy and the vendor alike, so the
1599
+ // seat is refused before a proxy starts or a login is copied.
1600
+ if (ctx.environment?.sandbox?.network?.policy === 'restricted') {
1601
+ throw new Error('public Claude agents on Linux need shared bwrap networking to reach the model; '
1602
+ + 'sandbox.network.policy "restricted" cuts it off.');
1603
+ }
1604
+ // A public seat sees its project and its own folder, nothing more: the
1605
+ // server's environment cannot bind more of the host into a jail that
1606
+ // strangers drive (goose refuses the same).
1607
+ for (const key of ['read-outside', 'write-outside']) {
1608
+ if (ctx.environment?.sandbox?.filesystem?.[key]?.length) {
1609
+ throw new Error(`public Claude agents on Linux do not allow sandbox.filesystem.${key}`);
1610
+ }
1611
+ }
1612
+ const bwrap = (ctx._detectBwrap || detectBwrap)();
1613
+ if (!bwrap.available) throw new Error(`public Claude agents require bwrap: ${bwrap.error}`);
1614
+ const { claudeBin, claudeInstall } = resolveClaudeInstall(withClaudePath(ctx.env));
1615
+ if (!claudeInstall) throw claudeInstallRefusal(claudeBin);
1616
+ const source = ctx.env || process.env;
1617
+ if (source.ANTHROPIC_API_KEY) {
1618
+ publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx, { login: 'none', seat: 'public' });
1619
+ providerProxy = await (ctx._startProviderProxy || startProviderProxy)({
1620
+ provider: { baseURL: source.ANTHROPIC_BASE_URL || 'https://api.anthropic.com' },
1621
+ keyMaterial: source.ANTHROPIC_API_KEY,
1622
+ wire: 'anthropic-messages',
1623
+ adapterLabel: 'Claude',
1624
+ });
1625
+ } else {
1626
+ publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx, { login: 'access-only', seat: 'public' });
1627
+ publicLoginBaseUrl = loginRouteBaseUrl(source);
1628
+ if (!publicClaudeState.loginPath) {
1629
+ throw new Error('public Claude agents on Linux need ANTHROPIC_API_KEY in the agent\'s environment, '
1630
+ + 'or sign in with `claude` on this machine first.');
1631
+ }
1632
+ loginTurn = await registerLoginTurn(publicClaudeState.statePath);
1633
+ }
1459
1634
  }
1460
1635
  if (publicTrust) {
1461
1636
  const prompt = await publicSkillPromptArgs(mountedSkills);
@@ -1480,14 +1655,14 @@ export default {
1480
1655
  });
1481
1656
  }
1482
1657
  if (mcpConfig?.unavailableMcp.length) throw publicMcpUnavailableError(mcpConfig.unavailableMcp);
1483
- const claudeEnv = buildClaudeEnv(
1658
+ const claudeEnv = withProvider(buildClaudeEnv(
1484
1659
  ctx.env,
1485
1660
  publicClaudeState,
1486
1661
  mcpConfig?.expansionEnv,
1487
1662
  mcpConfig?.credential?.path || null,
1488
1663
  publicTrust,
1489
1664
  ctx._platform || process.platform,
1490
- );
1665
+ ));
1491
1666
  if ((publicNativeSandbox || publicBwrapSandbox) && ctx.commitAttributionEnv) {
1492
1667
  Object.assign(claudeEnv, ctx.commitAttributionEnv);
1493
1668
  }
@@ -1511,7 +1686,7 @@ export default {
1511
1686
  // check. Snapshot before the spawn: claude is subscription-authenticated
1512
1687
  // and its provider key is not an env var here, so the seat credential is
1513
1688
  // the value a tail can echo back.
1514
- credentials: spawnCredentials(spawnCtx),
1689
+ credentials: turnCredentials(spawnCtx),
1515
1690
  spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }), // test seam only — do not use in production
1516
1691
  ownGroup: wantsOwnProcessGroup(ctx),
1517
1692
  transcriptPath: claudeTranscriptPath({
@@ -1530,7 +1705,7 @@ export default {
1530
1705
  cwd: physicalPath(ctx.cwd), sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }),
1531
1706
  });
1532
1707
  return {
1533
- text: turnReply(stdout, { credentials: spawnCredentials(spawnCtx), publicMcp: witnessedMcp }),
1708
+ text: turnReply(stdout, { credentials: turnCredentials(spawnCtx), publicMcp: witnessedMcp }),
1534
1709
  newSessionId: sessionId,
1535
1710
  contextTokens,
1536
1711
  // D245 item 4: what the run said about itself, for the reply receipt.
@@ -1564,14 +1739,14 @@ export default {
1564
1739
  mcpReadDirectories: mcpConfig?.mcpReadDirectories || [],
1565
1740
  publicClaudeState,
1566
1741
  publicSkillArgs,
1567
- claudeEnv: buildClaudeEnv(
1742
+ claudeEnv: withProvider(buildClaudeEnv(
1568
1743
  ctx.env,
1569
1744
  publicClaudeState,
1570
1745
  mcpConfig?.expansionEnv,
1571
1746
  mcpConfig?.credential?.path || null,
1572
1747
  publicTrust,
1573
1748
  ctx._platform || process.platform,
1574
- ),
1749
+ )),
1575
1750
  };
1576
1751
  if ((publicNativeSandbox || publicBwrapSandbox) && ctx.commitAttributionEnv) {
1577
1752
  Object.assign(retryCtx.claudeEnv, ctx.commitAttributionEnv);
@@ -1583,7 +1758,7 @@ export default {
1583
1758
  cwd: ctx.cwd,
1584
1759
  env: retry.env,
1585
1760
  timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
1586
- credentials: spawnCredentials(retryCtx),
1761
+ credentials: turnCredentials(retryCtx),
1587
1762
  spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }),
1588
1763
  ownGroup: wantsOwnProcessGroup(ctx),
1589
1764
  transcriptPath: claudeTranscriptPath({
@@ -1602,7 +1777,7 @@ export default {
1602
1777
  configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }),
1603
1778
  });
1604
1779
  return {
1605
- text: turnReply(stdout, { credentials: spawnCredentials(retryCtx), publicMcp: witnessedMcp }),
1780
+ text: turnReply(stdout, { credentials: turnCredentials(retryCtx), publicMcp: witnessedMcp }),
1606
1781
  newSessionId: freshId,
1607
1782
  contextTokens,
1608
1783
  stats: claudeRunStats(stdout),
@@ -1613,6 +1788,13 @@ export default {
1613
1788
  if (mcpConfig?.dir) {
1614
1789
  try { await rm(mcpConfig.dir, { recursive: true, force: true }); } catch { /* ignore */ }
1615
1790
  }
1791
+ try { await providerProxy?.close(); } catch { /* ignore */ }
1792
+ // A public seat's login copy lives for its turns (F96): the last live
1793
+ // turn of the agent to finish removes it.
1794
+ if (publicBwrapSandbox && !workspaceBwrapSandbox && publicClaudeState?.loginPath) {
1795
+ if (loginTurn) await releaseLoginTurn(loginTurn, publicClaudeState.loginPath);
1796
+ else await rm(publicClaudeState.loginPath, { force: true });
1797
+ }
1616
1798
  }
1617
1799
  },
1618
1800
  };
@@ -35,9 +35,10 @@ const expandHome = (p) => {
35
35
  // mechanisms that do exist are named instead.
36
36
  const MACOS_MESSAGE = 'bwrap is Linux-only. On macOS a public seat runs under Seatbelt '
37
37
  + '(sandbox.mode workspace or read-only).';
38
- // D245: Windows has no sandbox for agents, and WSL does not give Claude Code
39
- // one either (host-check.js linuxClaudeMessage), so the only honest way
40
- // forward named here is the user's own opt-in (sandbox/windows-unsandboxed.js).
38
+ // D245: native Windows has no sandbox for agents. WSL runs the Linux one,
39
+ // Claude Code included since F96, and host-check.js offers it; this message
40
+ // names the native path, which is the user's own opt-in
41
+ // (sandbox/windows-unsandboxed.js).
41
42
  const WINDOWS_MESSAGE = 'bwrap is Linux-only, and native Windows has no sandbox for agents yet. '
42
43
  + 'To run this agent without one, with your own access to files and commands, add --unsandboxed.';
43
44
  const nonLinuxMessage = (platform) => (platform === 'win32' ? WINDOWS_MESSAGE : MACOS_MESSAGE);
@@ -35,11 +35,13 @@ const HOSTED_LINE = ' - A hosted agent: choose "Run it here" on the Connect pag
35
35
  /** What a person on native Windows can do instead. */
36
36
  export const windowsSandboxMessage = (adapterName) => {
37
37
  const label = ADAPTER_LABELS[adapterName] || adapterName;
38
- // WSL is offered only where the Linux sandbox can run the CLI: not Claude
39
- // Code (see linuxClaudeMessage).
38
+ // WSL runs the Linux sandbox, which carries every adapter, Claude Code
39
+ // included (F96). Claude Code also has the MCP command, which needs none.
40
40
  return [
41
41
  `Commonly runs a connected ${label} inside a sandbox, and has no sandbox for Windows yet,`,
42
42
  'so by default this agent cannot listen for @mentions from native Windows. You can:',
43
+ ` - WSL 2 (Ubuntu): install Node and ${label} inside WSL, run \`sudo apt install bubblewrap\`,`
44
+ + ' then run these same commands there.',
43
45
  // D245: the user's own choice, never a default (sandbox/windows-unsandboxed.js).
44
46
  ...(adapterName === 'claude'
45
47
  ? [` - Run it without an OS sandbox: add ${UNSANDBOXED_FLAG}`
@@ -48,28 +50,11 @@ export const windowsSandboxMessage = (adapterName) => {
48
50
  : []),
49
51
  ...(adapterName === 'claude'
50
52
  ? [' - Use Commonly from your own Claude Code sessions: the Connect page shows a PowerShell `claude mcp add` command.']
51
- : [` - WSL 2 (Ubuntu): install Node and ${label} inside WSL, run \`sudo apt install bubblewrap\`,`
52
- + ' then run these same commands there.']),
53
+ : []),
53
54
  HOSTED_LINE,
54
55
  ].join('\n');
55
56
  };
56
57
 
57
- /**
58
- * Claude Code on Linux (WSL included) for any seat but a workspace one. Only
59
- * the workspace jail (#2784) carries what Claude needs: its install, a HOME
60
- * of its own and a copy of the login. The public jail binds the system binary
61
- * folders only, so a confined spawn could not even start Claude (Ubuntu run,
62
- * 2026-10-16), and giving it the login would hand strangers a jail that holds
63
- * it. A workspace seat needs every pod it answers in to be invite-only.
64
- */
65
- export const linuxClaudeMessage = () => [
66
- 'Commonly runs a connected Claude Code inside a sandbox. On Linux (WSL included) it does that only',
67
- 'for an agent whose pods are all invite-only, and this one is not, so it cannot listen for @mentions here. Instead:',
68
- ' - From an invite-only pod: run the commands its Connect page shows, from your project\'s folder.',
69
- ' - Use Commonly from your own Claude Code sessions: the Connect page shows a `claude mcp add` command.',
70
- HOSTED_LINE,
71
- ].join('\n');
72
-
73
58
  /** Why this host cannot confine the seat, or null when it can (or the seat declares no sandbox). */
74
59
  export const hostSandboxRefusal = ({
75
60
  environment,
@@ -92,11 +77,6 @@ export const hostSandboxRefusal = ({
92
77
  if (platform === 'win32') {
93
78
  return hostUnconfined === true && adapterName === 'claude' ? null : windowsSandboxMessage(adapterName);
94
79
  }
95
- // A workspace claude seat falls through to the bubblewrap probe below; the
96
- // probe failing, for any reason, is a refusal like any other adapter's.
97
- if (mode === 'bwrap' && adapterName === 'claude' && sandbox.trust !== 'workspace') {
98
- return linuxClaudeMessage();
99
- }
100
80
  if (mode === 'bwrap') {
101
81
  const bwrap = detectBwrap();
102
82
  return bwrap.available ? null : `This seat runs in a bubblewrap sandbox, which is not available here. ${bwrap.error}`;
@@ -107,4 +87,4 @@ export const hostSandboxRefusal = ({
107
87
  : `This seat runs in a macOS Seatbelt sandbox (sandbox.mode=${mode}), which is not available here. ${seatbelt.error}`;
108
88
  };
109
89
 
110
- export default { hostSandboxRefusal, linuxClaudeMessage, windowsSandboxMessage };
90
+ export default { hostSandboxRefusal, windowsSandboxMessage };