@commonlyai/cli 0.1.235 → 0.1.236

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.236",
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,39 @@ 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 on this machine (\`claude\`, then /login)${apiKeyHint ? ', or set ANTHROPIC_API_KEY for the agent' : ''}.`);
426
+ }
427
+ // An allowlist, not "everything but refreshToken": a field the host record
428
+ // gains later, secret or not, never reaches the seat's writable HOME.
429
+ const access = Object.fromEntries(ACCESS_ONLY_LOGIN_FIELDS
430
+ .filter((field) => oauth[field] !== undefined)
431
+ .map((field) => [field, oauth[field]]));
432
+ return `${JSON.stringify({ claudeAiOauth: access })}\n`;
433
+ };
434
+
435
+ const carryClaudeLogin = async (env, configDir, { accessOnly = false, mustOutlastMs = 0, apiKeyHint = true } = {}) => {
344
436
  const source = claudeLoginSource(env);
345
437
  const refuse = () => new Error(`refusing a Claude login that is not a regular file: ${source}`);
346
438
  let handle;
@@ -356,20 +448,33 @@ const carryClaudeLogin = async (env, configDir) => {
356
448
  }
357
449
  try {
358
450
  if (!(await handle.stat()).isFile()) throw refuse();
359
- return replacePrivateFile(configDir, '.credentials.json', await handle.readFile('utf8'));
451
+ const text = await handle.readFile('utf8');
452
+ return replacePrivateFile(configDir, '.credentials.json', accessOnly
453
+ ? accessOnlyLogin(text, { source, mustOutlastMs, apiKeyHint })
454
+ : text);
360
455
  } finally {
361
456
  await handle.close();
362
457
  }
363
458
  };
364
459
 
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) => {
460
+ // The per-seat folder a bwrap seat runs in. A public seat (F96) gets its own,
461
+ // never the workspace seat's: that one holds the transcripts of private-pod
462
+ // turns, and the same agent can run both kinds of
463
+ // turn, even at once (a daemon and a separate `agent run`).
464
+ export const bwrapClaudeStatePath = (ctx, seat = 'workspace') => {
465
+ if (ctx._publicClaudeState) return ctx._publicClaudeState;
370
466
  const identity = ctx.agentName || ctx.cwd || 'anonymous';
371
467
  const identityHash = createHash('sha256').update(identity).digest('hex').slice(0, 20);
372
- const statePath = ctx._publicClaudeState || publicClaudeStateRoot(identityHash);
468
+ return publicClaudeStateRoot(seat === 'public' ? `${identityHash}-public` : identityHash);
469
+ };
470
+
471
+ // State for a seat under bwrap, outside the person's project, with the auth
472
+ // metadata whitelist and the login copy. The jail binds this folder writable
473
+ // and makes it HOME, so transcripts land here rather than in the project.
474
+ // Every seat carries an access-only copy of the login, or a public seat none
475
+ // when an API key reaches the model through the provider proxy instead.
476
+ const prepareWorkspaceBwrapClaudeState = async (ctx, { login = 'carry', seat = 'workspace' } = {}) => {
477
+ const statePath = bwrapClaudeStatePath(ctx, seat);
373
478
  const tmpPath = join(statePath, 'tmp');
374
479
  const configDir = join(statePath, '.claude');
375
480
  await mkdir(statePath, { recursive: true, mode: 0o700 });
@@ -386,7 +491,15 @@ const prepareWorkspaceBwrapClaudeState = async (ctx) => {
386
491
  const configPath = await replacePrivateFile(
387
492
  statePath, '.claude.json', `${JSON.stringify(await claudeAuthMetadata(), null, 2)}\n`,
388
493
  );
389
- const loginPath = await carryClaudeLogin(ctx.env, configDir);
494
+ if (login === 'none') {
495
+ await rm(join(configDir, '.credentials.json'), { force: true });
496
+ return { statePath, tmpPath, configPath, loginPath: null };
497
+ }
498
+ const loginPath = await carryClaudeLogin(ctx.env, configDir, login === 'access-only'
499
+ // A workspace seat's jail never sees the host's API key, so only a public
500
+ // seat is told to set one.
501
+ ? { accessOnly: true, mustOutlastMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS, apiKeyHint: seat === 'public' }
502
+ : {});
390
503
  return { statePath, tmpPath, configPath, loginPath };
391
504
  };
392
505
 
@@ -640,12 +753,14 @@ const publicWitnessError = ({ code, tool, error }) => {
640
753
  };
641
754
 
642
755
  // The reply a turn posts. A public seat's turn is witnessed first (TASK-256).
756
+ // Both ways out of a turn are redacted: a reply that echoes a credential the
757
+ // child was handed (the per-turn proxy token included), and the witness error.
643
758
  const turnReply = (stdout, { credentials, publicMcp }) => {
644
759
  if (publicMcp) {
645
760
  const failure = claudePublicMcpWitness(stdout, publicMcp);
646
- if (failure) throw publicWitnessError(failure);
761
+ if (failure) throw publicWitnessError({ ...failure, error: redactKnownCredentials(String(failure.error), credentials) });
647
762
  }
648
- return claudeResultText(stdout, { credentials });
763
+ return redactKnownCredentials(claudeResultText(stdout, { credentials }), credentials);
649
764
  };
650
765
 
651
766
  // The failure tail for a non-zero exit. Stream events are mostly system init
@@ -1063,7 +1178,7 @@ const resolveClaudeInstall = (env) => {
1063
1178
 
1064
1179
  // A jail that cannot carry claude would fail inside at every spawn.
1065
1180
  const claudeInstallRefusal = (claudeBin) => new Error(
1066
- 'workspace Claude agents on Linux need Claude Code on PATH, so the sandbox can carry its install; '
1181
+ 'Claude agents on Linux need Claude Code on PATH, so the sandbox can carry its install; '
1067
1182
  + `\`${claudeBin}\` could not be resolved`,
1068
1183
  );
1069
1184
 
@@ -1231,8 +1346,8 @@ const prepareArgv = async (innerArgv, ctx) => {
1231
1346
  if (!bwrap.available) {
1232
1347
  throw new Error(`${sandboxTrust} Claude agents require bwrap: ${bwrap.error}`);
1233
1348
  }
1234
- if (workspaceBwrapSandbox && !ctx.publicClaudeState) {
1235
- throw new Error('workspace Claude state was not prepared before argv construction');
1349
+ if (!ctx.publicClaudeState) {
1350
+ throw new Error(`${sandboxTrust} Claude state was not prepared before argv construction`);
1236
1351
  }
1237
1352
  innerArgv = [
1238
1353
  ...innerArgv,
@@ -1244,7 +1359,7 @@ const prepareArgv = async (innerArgv, ctx) => {
1244
1359
  } else if (allowedPatterns.length > 0) {
1245
1360
  innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns];
1246
1361
  }
1247
- if (workspaceBwrapSandbox) {
1362
+ if (workspaceBwrapSandbox || publicBwrapSandbox) {
1248
1363
  // Seatbelt denies writes under .git by path, present or not. A bwrap mount
1249
1364
  // needs the path to exist, so a writable project without a .git could get
1250
1365
  // one from the child, hooks included, that the person's next git command
@@ -1432,6 +1547,10 @@ export default {
1432
1547
 
1433
1548
  let mcpConfig = null;
1434
1549
  let publicClaudeState = null;
1550
+ let providerProxy = null;
1551
+ // A login seat's gateway, when the host names one: config, not a secret.
1552
+ let publicLoginBaseUrl = null;
1553
+ let loginTurn = null;
1435
1554
  let publicSkillArgs = [];
1436
1555
  // D245: no jail to prepare for on a policy-only Windows host.
1437
1556
  const jailed = !policyOnlyHost(ctx);
@@ -1441,6 +1560,18 @@ export default {
1441
1560
  && resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
1442
1561
  const workspaceBwrapSandbox = jailed && ctx.environment?.sandbox?.trust === 'workspace'
1443
1562
  && resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
1563
+ // The proxy's address and per-turn token, never the host's key.
1564
+ const withProvider = (env) => {
1565
+ if (providerProxy) {
1566
+ return Object.assign(env, { ANTHROPIC_BASE_URL: providerProxy.baseURL, ANTHROPIC_API_KEY: providerProxy.token });
1567
+ }
1568
+ return publicLoginBaseUrl ? Object.assign(env, { ANTHROPIC_BASE_URL: publicLoginBaseUrl }) : env;
1569
+ };
1570
+ // The values a failure tail or reply must never echo: the seat's own, and
1571
+ // the per-turn proxy token the child was handed in place of the host key.
1572
+ const turnCredentials = (spawnContext) => [
1573
+ ...spawnCredentials(spawnContext), providerProxy?.token,
1574
+ ].filter(Boolean);
1444
1575
  try {
1445
1576
  // Trust is normalized at entry (legacy `internal` reads as public). The
1446
1577
  // MCP policy follows trust alone, whatever confines the seat: a public
@@ -1455,7 +1586,50 @@ export default {
1455
1586
  if (!bwrap.available) throw new Error(`workspace Claude agents require bwrap: ${bwrap.error}`);
1456
1587
  const { claudeBin, claudeInstall } = resolveClaudeInstall(withClaudePath(ctx.env));
1457
1588
  if (!claudeInstall) throw claudeInstallRefusal(claudeBin);
1458
- publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx);
1589
+ publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx, { login: 'access-only' });
1590
+ // The host's model route, when it is the one the login uses.
1591
+ publicLoginBaseUrl = loginRouteBaseUrl(ctx.env || process.env);
1592
+ } else if (publicBwrapSandbox) {
1593
+ // F96: the workspace tier's jail, reaching the model one of two ways.
1594
+ // An API key stays on the host behind a per-turn proxy; without one,
1595
+ // the seat gets an access-only copy of the login, deleted after the turn.
1596
+ // Either way the model is across the network: restricted networking
1597
+ // (--unshare-net) cuts off the host proxy and the vendor alike, so the
1598
+ // seat is refused before a proxy starts or a login is copied.
1599
+ if (ctx.environment?.sandbox?.network?.policy === 'restricted') {
1600
+ throw new Error('public Claude agents on Linux need shared bwrap networking to reach the model; '
1601
+ + 'sandbox.network.policy "restricted" cuts it off.');
1602
+ }
1603
+ // A public seat sees its project and its own folder, nothing more: the
1604
+ // server's environment cannot bind more of the host into a jail that
1605
+ // strangers drive (goose refuses the same).
1606
+ for (const key of ['read-outside', 'write-outside']) {
1607
+ if (ctx.environment?.sandbox?.filesystem?.[key]?.length) {
1608
+ throw new Error(`public Claude agents on Linux do not allow sandbox.filesystem.${key}`);
1609
+ }
1610
+ }
1611
+ const bwrap = (ctx._detectBwrap || detectBwrap)();
1612
+ if (!bwrap.available) throw new Error(`public Claude agents require bwrap: ${bwrap.error}`);
1613
+ const { claudeBin, claudeInstall } = resolveClaudeInstall(withClaudePath(ctx.env));
1614
+ if (!claudeInstall) throw claudeInstallRefusal(claudeBin);
1615
+ const source = ctx.env || process.env;
1616
+ if (source.ANTHROPIC_API_KEY) {
1617
+ publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx, { login: 'none', seat: 'public' });
1618
+ providerProxy = await (ctx._startProviderProxy || startProviderProxy)({
1619
+ provider: { baseURL: source.ANTHROPIC_BASE_URL || 'https://api.anthropic.com' },
1620
+ keyMaterial: source.ANTHROPIC_API_KEY,
1621
+ wire: 'anthropic-messages',
1622
+ adapterLabel: 'Claude',
1623
+ });
1624
+ } else {
1625
+ publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx, { login: 'access-only', seat: 'public' });
1626
+ publicLoginBaseUrl = loginRouteBaseUrl(source);
1627
+ if (!publicClaudeState.loginPath) {
1628
+ throw new Error('public Claude agents on Linux need ANTHROPIC_API_KEY in the agent\'s environment, '
1629
+ + 'or sign in with `claude` on this machine first.');
1630
+ }
1631
+ loginTurn = await registerLoginTurn(publicClaudeState.statePath);
1632
+ }
1459
1633
  }
1460
1634
  if (publicTrust) {
1461
1635
  const prompt = await publicSkillPromptArgs(mountedSkills);
@@ -1480,14 +1654,14 @@ export default {
1480
1654
  });
1481
1655
  }
1482
1656
  if (mcpConfig?.unavailableMcp.length) throw publicMcpUnavailableError(mcpConfig.unavailableMcp);
1483
- const claudeEnv = buildClaudeEnv(
1657
+ const claudeEnv = withProvider(buildClaudeEnv(
1484
1658
  ctx.env,
1485
1659
  publicClaudeState,
1486
1660
  mcpConfig?.expansionEnv,
1487
1661
  mcpConfig?.credential?.path || null,
1488
1662
  publicTrust,
1489
1663
  ctx._platform || process.platform,
1490
- );
1664
+ ));
1491
1665
  if ((publicNativeSandbox || publicBwrapSandbox) && ctx.commitAttributionEnv) {
1492
1666
  Object.assign(claudeEnv, ctx.commitAttributionEnv);
1493
1667
  }
@@ -1511,7 +1685,7 @@ export default {
1511
1685
  // check. Snapshot before the spawn: claude is subscription-authenticated
1512
1686
  // and its provider key is not an env var here, so the seat credential is
1513
1687
  // the value a tail can echo back.
1514
- credentials: spawnCredentials(spawnCtx),
1688
+ credentials: turnCredentials(spawnCtx),
1515
1689
  spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }), // test seam only — do not use in production
1516
1690
  ownGroup: wantsOwnProcessGroup(ctx),
1517
1691
  transcriptPath: claudeTranscriptPath({
@@ -1530,7 +1704,7 @@ export default {
1530
1704
  cwd: physicalPath(ctx.cwd), sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }),
1531
1705
  });
1532
1706
  return {
1533
- text: turnReply(stdout, { credentials: spawnCredentials(spawnCtx), publicMcp: witnessedMcp }),
1707
+ text: turnReply(stdout, { credentials: turnCredentials(spawnCtx), publicMcp: witnessedMcp }),
1534
1708
  newSessionId: sessionId,
1535
1709
  contextTokens,
1536
1710
  // D245 item 4: what the run said about itself, for the reply receipt.
@@ -1564,14 +1738,14 @@ export default {
1564
1738
  mcpReadDirectories: mcpConfig?.mcpReadDirectories || [],
1565
1739
  publicClaudeState,
1566
1740
  publicSkillArgs,
1567
- claudeEnv: buildClaudeEnv(
1741
+ claudeEnv: withProvider(buildClaudeEnv(
1568
1742
  ctx.env,
1569
1743
  publicClaudeState,
1570
1744
  mcpConfig?.expansionEnv,
1571
1745
  mcpConfig?.credential?.path || null,
1572
1746
  publicTrust,
1573
1747
  ctx._platform || process.platform,
1574
- ),
1748
+ )),
1575
1749
  };
1576
1750
  if ((publicNativeSandbox || publicBwrapSandbox) && ctx.commitAttributionEnv) {
1577
1751
  Object.assign(retryCtx.claudeEnv, ctx.commitAttributionEnv);
@@ -1583,7 +1757,7 @@ export default {
1583
1757
  cwd: ctx.cwd,
1584
1758
  env: retry.env,
1585
1759
  timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
1586
- credentials: spawnCredentials(retryCtx),
1760
+ credentials: turnCredentials(retryCtx),
1587
1761
  spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }),
1588
1762
  ownGroup: wantsOwnProcessGroup(ctx),
1589
1763
  transcriptPath: claudeTranscriptPath({
@@ -1602,7 +1776,7 @@ export default {
1602
1776
  configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }),
1603
1777
  });
1604
1778
  return {
1605
- text: turnReply(stdout, { credentials: spawnCredentials(retryCtx), publicMcp: witnessedMcp }),
1779
+ text: turnReply(stdout, { credentials: turnCredentials(retryCtx), publicMcp: witnessedMcp }),
1606
1780
  newSessionId: freshId,
1607
1781
  contextTokens,
1608
1782
  stats: claudeRunStats(stdout),
@@ -1613,6 +1787,13 @@ export default {
1613
1787
  if (mcpConfig?.dir) {
1614
1788
  try { await rm(mcpConfig.dir, { recursive: true, force: true }); } catch { /* ignore */ }
1615
1789
  }
1790
+ try { await providerProxy?.close(); } catch { /* ignore */ }
1791
+ // A public seat's login copy lives for its turns (F96): the last live
1792
+ // turn of the agent to finish removes it.
1793
+ if (publicBwrapSandbox && !workspaceBwrapSandbox && publicClaudeState?.loginPath) {
1794
+ if (loginTurn) await releaseLoginTurn(loginTurn, publicClaudeState.loginPath);
1795
+ else await rm(publicClaudeState.loginPath, { force: true });
1796
+ }
1616
1797
  }
1617
1798
  },
1618
1799
  };
@@ -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 };