@commonlyai/cli 0.1.234 → 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.234",
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",
@@ -89,7 +89,7 @@ import { createClaimedWorkGate } from '../lib/claimed-work-gate.js';
89
89
  import { prepareCommitAttribution } from '../lib/commit-attribution.js';
90
90
  import { detectBwrap } from '../lib/sandbox/bwrap.js';
91
91
  import { resolvePublicSandboxMode } from '../lib/sandbox/mode.js';
92
- import { hostSandboxRefusal, windowsSandboxMessage } from '../lib/sandbox/host-check.js';
92
+ import { UNSANDBOXED_FLAG, hostSandboxRefusal, windowsSandboxMessage } from '../lib/sandbox/host-check.js';
93
93
  import {
94
94
  UNSANDBOXED_NOTICE, hostUnconfinedForRun, runOptedInUnsandboxed, unsandboxedFlagRefusal,
95
95
  } from '../lib/sandbox/windows-unsandboxed.js';
@@ -1176,6 +1176,8 @@ export const performAttach = async ({
1176
1176
  log = () => {},
1177
1177
  adapterRegistry = { getAdapter, listAdapterNames },
1178
1178
  seatbelt: probeSeatbelt = detectSeatbelt,
1179
+ bwrap: probeBwrap = detectBwrap,
1180
+ platform = process.platform,
1179
1181
  }) => {
1180
1182
  const adapter = adapterRegistry.getAdapter(adapterName);
1181
1183
  if (!adapter) {
@@ -1210,10 +1212,37 @@ export const performAttach = async ({
1210
1212
  }
1211
1213
  log(`workspace: ${workspace.path}${workspace.created ? ' (created)' : ''}`);
1212
1214
 
1213
- const { mode: sandboxMode } = resolveAttachSandbox({ environment, adapterName, adapter });
1215
+ const { mode: sandboxMode } = resolveAttachSandbox({ environment, adapterName, adapter, platform });
1216
+ // D245: native Windows has neither sandbox, so its refusal names Windows
1217
+ // and the user's own opt-in, never Seatbelt or bwrap advice. Attach still
1218
+ // refuses there, as before; only the words differ.
1219
+ const windowsRefusal = () => new Error(adapterName === 'dsh'
1220
+ ? `confined dsh sandbox.mode=${sandboxMode} needs a sandbox, and Commonly has no sandbox for Windows yet, `
1221
+ + 'so dsh cannot run confined on native Windows. Choose "Run it here" on the Connect page for a hosted agent.'
1222
+ : `This environment file asks for sandbox.mode=${sandboxMode}, which Commonly cannot enforce on Windows, `
1223
+ + 'so attach stops here.\n'
1224
+ // This attach made no agent, so Claude's options name the path that works on
1225
+ // Windows: the Connect page's commands, which set the two env vars and run with
1226
+ // the flag, keeping the public trust and policy (#3407 review B1). Other
1227
+ // adapters keep the shared Windows text.
1228
+ + (adapterName === 'claude' ? [
1229
+ 'Instead:',
1230
+ ' - Run it without an OS sandbox, which is your choice: use the commands on the Connect page.'
1231
+ + ' They set COMMONLY_API_URL and COMMONLY_AGENT_TOKEN, then run'
1232
+ + ` commonly agent run <name> --adapter claude ${UNSANDBOXED_FLAG}.`
1233
+ + " It keeps Commonly's restricted tool settings and gets no shell, but nothing at the OS level"
1234
+ + ' confines it, and it gets no connector grants.'
1235
+ // agent run prefers a saved record over the page's env vars, so an
1236
+ // older record of that name would run with its own settings (#3407).
1237
+ + ' If this computer already has an agent saved under that name, the run uses that one and its'
1238
+ + ' settings instead: remove it first with commonly agent detach <name> --force.',
1239
+ ' - Use Commonly from your own Claude Code sessions: the Connect page shows a PowerShell `claude mcp add` command.',
1240
+ ' - A hosted agent: choose "Run it here" on the Connect page. Nothing runs on this machine.',
1241
+ ].join('\n') : windowsSandboxMessage(adapterName)));
1214
1242
  if (sandboxMode === 'bwrap') {
1215
- const bwrap = detectBwrap();
1243
+ const bwrap = probeBwrap();
1216
1244
  if (!bwrap.available) {
1245
+ if (platform === 'win32') throw windowsRefusal();
1217
1246
  throw new Error(`sandbox.mode=bwrap requested but unavailable: ${bwrap.error}`);
1218
1247
  }
1219
1248
  if (environment.sandbox?.network?.policy === 'restricted') {
@@ -1239,6 +1268,7 @@ export const performAttach = async ({
1239
1268
  || adapterName;
1240
1269
  const seatbelt = probeSeatbelt();
1241
1270
  if (!seatbelt.available) {
1271
+ if (platform === 'win32') throw windowsRefusal();
1242
1272
  // dsh refuses bwrap, so it gets no Linux hint that leads to a second refusal.
1243
1273
  throw new Error(adapterName === 'dsh'
1244
1274
  ? `confined dsh sandbox.mode=${sandboxMode} requires macOS Seatbelt: ${seatbelt.error}.`
@@ -1595,6 +1625,8 @@ export const performRun = ({
1595
1625
  setTimeoutImpl = setTimeout,
1596
1626
  setIntervalImpl = setInterval,
1597
1627
  clearIntervalImpl = clearInterval,
1628
+ // The host's bubblewrap probe, for a turn placed public; tests pin it.
1629
+ detectBwrapImpl,
1598
1630
  setDeadlineTimeoutImpl = setTimeout,
1599
1631
  clearDeadlineTimeoutImpl = clearTimeout,
1600
1632
  turnTimeoutMs,
@@ -2109,14 +2141,19 @@ export const performRun = ({
2109
2141
  const placement = await placeTurn();
2110
2142
  // A workspace turn placed public (its pod turned public, or its pods could
2111
2143
  // not be read) is confined as a public seat, which this host may not be
2112
- // able to do: Claude Code on Linux has only the workspace jail. Refuse it
2113
- // 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
2114
2146
  // never runs and nothing is posted. A public pod is a settled answer, so a
2115
2147
  // human's message is handed to another listener (claim-outcome.js). A
2116
2148
  // failed read is not: it throws, so the event stays unacked and comes back
2117
2149
  // through the spawn-retry backoff instead of being refused for good.
2118
2150
  if (placement.reason) {
2119
- 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
+ });
2120
2157
  if (turnRefusal && placement.readError) {
2121
2158
  throw Object.assign(
2122
2159
  new Error(`turn deferred, because its pods could not be read: ${placement.readError.message}`, {
@@ -300,6 +300,34 @@ export const resolveAdapterForRuntime = async (runtime, registry = { getAdapter
300
300
  return null;
301
301
  };
302
302
 
303
+ /**
304
+ * The daemon's first poll and heartbeat, with the Windows launcher watch armed
305
+ * BEFORE them (D245). Arming first puts the EPIPE guard on stdout and stderr
306
+ * before any supervision log line, and a launcher that is already gone stops
307
+ * the daemon here instead of after a tick logged into a dead pipe. Returns
308
+ * false when the daemon should not go on to its timers.
309
+ */
310
+ export const firstSupervisionPass = async ({
311
+ supervisor,
312
+ onLauncherGone,
313
+ isEnded = () => false,
314
+ armLauncherWatch = watchWindowsLauncher,
315
+ }) => {
316
+ let launcherGone = false;
317
+ armLauncherWatch({
318
+ onGone: (pid) => {
319
+ launcherGone = true;
320
+ onLauncherGone(pid);
321
+ },
322
+ });
323
+ const stopped = () => launcherGone || isEnded();
324
+ if (stopped()) return false;
325
+ await supervisor.tick();
326
+ if (stopped()) return false;
327
+ await supervisor.heartbeat();
328
+ return !stopped();
329
+ };
330
+
303
331
  export const registerDaemon = (program) => {
304
332
  const daemon = program.command('daemon').description('Manage the local Commonly daemon');
305
333
 
@@ -654,10 +682,26 @@ Examples:
654
682
  });
655
683
 
656
684
  stampLog(`daemon supervising for ${record.machineName} — poll ${opts.poll}ms, heartbeat ${opts.heartbeat}ms (ctrl+c to stop)`);
657
- await supervisor.tick();
658
- if (terminalEnded) return;
659
- await supervisor.heartbeat();
660
- if (terminalEnded) return;
685
+ const shutdown = () => {
686
+ stampLog('daemon stopping — terminating supervised agents');
687
+ clearInterval(pollTimer);
688
+ clearInterval(heartbeatTimer);
689
+ supervisor.stop();
690
+ process.exit(0);
691
+ };
692
+ // D245: under the Windows launcher, the launcher's exit is the stop
693
+ // signal. Armed before the first tick, so the EPIPE guard covers its
694
+ // log lines and a launcher already gone stops the daemon at once.
695
+ const started = await firstSupervisionPass({
696
+ supervisor,
697
+ onLauncherGone: (pid) => {
698
+ // stdout and stderr already swallow EPIPE (armed with the watch).
699
+ try { stampLog(`launcher ${pid} has exited`); } catch { /* stdout is gone */ }
700
+ shutdown();
701
+ },
702
+ isEnded: () => terminalEnded,
703
+ });
704
+ if (!started) return;
661
705
  const runtimeFailure = (error) => {
662
706
  if (terminalEnded) return;
663
707
  terminalEnded = true;
@@ -669,23 +713,8 @@ Examples:
669
713
  };
670
714
  pollTimer = setInterval(() => { void supervisor.tick().catch(runtimeFailure); }, Number(opts.poll) || DEFAULT_POLL_MS);
671
715
  heartbeatTimer = setInterval(() => { void supervisor.heartbeat().catch(runtimeFailure); }, Number(opts.heartbeat) || DEFAULT_HEARTBEAT_MS);
672
- const shutdown = () => {
673
- stampLog('daemon stopping — terminating supervised agents');
674
- clearInterval(pollTimer);
675
- clearInterval(heartbeatTimer);
676
- supervisor.stop();
677
- process.exit(0);
678
- };
679
716
  process.on('SIGINT', shutdown);
680
717
  process.on('SIGTERM', shutdown);
681
- // D245: under the Windows launcher, the launcher's exit is the stop signal.
682
- watchWindowsLauncher({
683
- onGone: (pid) => {
684
- // stdout and stderr already swallow EPIPE (armed with the watch).
685
- try { stampLog(`launcher ${pid} has exited`); } catch { /* stdout is gone */ }
686
- shutdown();
687
- },
688
- });
689
718
  } catch (error) {
690
719
  console.error(`Daemon run failed: ${error.message}`);
691
720
  const badConfig = !record || error.message.startsWith('Daemon credential file');
@@ -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,
@@ -84,6 +86,7 @@ import {
84
86
  } from '../environment.js';
85
87
  import { detectBwrap, wrapArgvWithBwrap } from '../sandbox/bwrap.js';
86
88
  import { PUBLIC_SANDBOX_MODES, resolvePublicSandboxMode } from '../sandbox/mode.js';
89
+ import { UNSANDBOXED_FLAG } from '../sandbox/host-check.js';
87
90
  import {
88
91
  publicClaudeStateRoot,
89
92
  wrapArgvWithSeatbelt,
@@ -97,11 +100,12 @@ import {
97
100
  import { BACKGROUND_PROCESS_CUE, buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
98
101
  import { claudeConfigDirFor, claudeContextTokens, claudeTranscriptPath } from '../session-recycle.js';
99
102
  import { skillsAsText } from '../skill-prompt.js';
100
- import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
103
+ import { adapterFailure, redactKnownCredentials, spawnCredentials } from '../upstream-refusal.js';
101
104
  import { PUBLIC_WITHHELD_TOOLS } from '../public-withheld-tools.js';
102
105
  import { isShippedCommonlyMcpCommand } from '../declared-mcp-guard.js';
103
106
  import { isAnswerableToolError, PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE } from '../public-tool-error.js';
104
107
  import { resolveLaunch, resolveMcpCommand, resolveWindowsCommandWithReason } from '../windows-command.js';
108
+ import { startProviderProxy } from '../provider-proxy.js';
105
109
 
106
110
  // See codex.js for the rationale on bumping the default + env override.
107
111
  // Keeping both adapters in lockstep so any wrapper agent runtime has the
@@ -324,14 +328,71 @@ const replacePrivateFile = async (dir, name, content) => {
324
328
  return target;
325
329
  };
326
330
 
327
- // A workspace seat on Linux runs under bwrap, which has no keychain. Claude
328
- // Code on Linux keeps its login in <config dir>/.credentials.json, so the
329
- // seat's own state dir gets a copy of the person's, taken fresh each spawn:
330
- // their own credential on their own machine, the exposure the macOS keychain
331
- // arm already has. Only the copy is inside the jail. Nothing is written back:
332
- // the jail can write its copy, and a write-back would let it replace the
333
- // person's login with one of its choosing. A missing login removes the copy,
334
- // 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.
335
396
  const claudeLoginSource = (env) => join(
336
397
  env?.CLAUDE_CONFIG_DIR || process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'),
337
398
  '.credentials.json',
@@ -339,7 +400,39 @@ const claudeLoginSource = (env) => join(
339
400
 
340
401
  // Opened once, without following a symlink, and checked on the open handle,
341
402
  // so the file read is the file checked (a FIFO cannot hold the open either).
342
- 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 } = {}) => {
343
436
  const source = claudeLoginSource(env);
344
437
  const refuse = () => new Error(`refusing a Claude login that is not a regular file: ${source}`);
345
438
  let handle;
@@ -355,20 +448,33 @@ const carryClaudeLogin = async (env, configDir) => {
355
448
  }
356
449
  try {
357
450
  if (!(await handle.stat()).isFile()) throw refuse();
358
- 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);
359
455
  } finally {
360
456
  await handle.close();
361
457
  }
362
458
  };
363
459
 
364
- // State for a workspace seat under bwrap: the same per-seat folder the macOS
365
- // path uses, outside the person's project, with the auth metadata whitelist
366
- // and the login copy. The jail binds this folder writable and makes it HOME,
367
- // so transcripts land here rather than in the project.
368
- 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;
369
466
  const identity = ctx.agentName || ctx.cwd || 'anonymous';
370
467
  const identityHash = createHash('sha256').update(identity).digest('hex').slice(0, 20);
371
- 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);
372
478
  const tmpPath = join(statePath, 'tmp');
373
479
  const configDir = join(statePath, '.claude');
374
480
  await mkdir(statePath, { recursive: true, mode: 0o700 });
@@ -385,7 +491,15 @@ const prepareWorkspaceBwrapClaudeState = async (ctx) => {
385
491
  const configPath = await replacePrivateFile(
386
492
  statePath, '.claude.json', `${JSON.stringify(await claudeAuthMetadata(), null, 2)}\n`,
387
493
  );
388
- 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
+ : {});
389
503
  return { statePath, tmpPath, configPath, loginPath };
390
504
  };
391
505
 
@@ -639,12 +753,14 @@ const publicWitnessError = ({ code, tool, error }) => {
639
753
  };
640
754
 
641
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.
642
758
  const turnReply = (stdout, { credentials, publicMcp }) => {
643
759
  if (publicMcp) {
644
760
  const failure = claudePublicMcpWitness(stdout, publicMcp);
645
- if (failure) throw publicWitnessError(failure);
761
+ if (failure) throw publicWitnessError({ ...failure, error: redactKnownCredentials(String(failure.error), credentials) });
646
762
  }
647
- return claudeResultText(stdout, { credentials });
763
+ return redactKnownCredentials(claudeResultText(stdout, { credentials }), credentials);
648
764
  };
649
765
 
650
766
  // The failure tail for a non-zero exit. Stream events are mostly system init
@@ -1062,7 +1178,7 @@ const resolveClaudeInstall = (env) => {
1062
1178
 
1063
1179
  // A jail that cannot carry claude would fail inside at every spawn.
1064
1180
  const claudeInstallRefusal = (claudeBin) => new Error(
1065
- '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; '
1066
1182
  + `\`${claudeBin}\` could not be resolved`,
1067
1183
  );
1068
1184
 
@@ -1154,6 +1270,16 @@ const prepareArgv = async (innerArgv, ctx) => {
1154
1270
  // unconfined seat with a public record, which is how a record can claim
1155
1271
  // confinement it never applies.
1156
1272
  if (isConfinedTrust(sandboxTrust) && !publicNativeSandbox && sandboxMode !== 'bwrap') {
1273
+ // D245: on native Windows, name Windows and the user's own opt-in, never
1274
+ // macOS or bwrap advice (host-check passes an unrecognised mode through).
1275
+ if ((ctx._platform || process.platform) === 'win32') {
1276
+ throw new Error(
1277
+ `${sandboxTrust} Claude agents run inside a sandbox, and Commonly has no sandbox it can enforce on Windows `
1278
+ + `(got sandbox.mode=${JSON.stringify(sandboxMode)}). To run this agent without one, which is your choice, `
1279
+ + `run commonly agent run <name> ${UNSANDBOXED_FLAG} (or commonly daemon install ${UNSANDBOXED_FLAG} for the agents `
1280
+ + 'this computer\'s daemon runs), or choose "Run it here" on the Connect page for a hosted agent.',
1281
+ );
1282
+ }
1157
1283
  throw new Error(
1158
1284
  `${sandboxTrust} Claude agents require an enforced sandbox mode `
1159
1285
  + `(workspace or read-only on macOS, bwrap elsewhere), got ${JSON.stringify(sandboxMode)}`,
@@ -1220,8 +1346,8 @@ const prepareArgv = async (innerArgv, ctx) => {
1220
1346
  if (!bwrap.available) {
1221
1347
  throw new Error(`${sandboxTrust} Claude agents require bwrap: ${bwrap.error}`);
1222
1348
  }
1223
- if (workspaceBwrapSandbox && !ctx.publicClaudeState) {
1224
- 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`);
1225
1351
  }
1226
1352
  innerArgv = [
1227
1353
  ...innerArgv,
@@ -1233,7 +1359,7 @@ const prepareArgv = async (innerArgv, ctx) => {
1233
1359
  } else if (allowedPatterns.length > 0) {
1234
1360
  innerArgv = [...innerArgv, '--allowedTools', ...allowedPatterns];
1235
1361
  }
1236
- if (workspaceBwrapSandbox) {
1362
+ if (workspaceBwrapSandbox || publicBwrapSandbox) {
1237
1363
  // Seatbelt denies writes under .git by path, present or not. A bwrap mount
1238
1364
  // needs the path to exist, so a writable project without a .git could get
1239
1365
  // one from the child, hooks included, that the person's next git command
@@ -1421,6 +1547,10 @@ export default {
1421
1547
 
1422
1548
  let mcpConfig = null;
1423
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;
1424
1554
  let publicSkillArgs = [];
1425
1555
  // D245: no jail to prepare for on a policy-only Windows host.
1426
1556
  const jailed = !policyOnlyHost(ctx);
@@ -1430,6 +1560,18 @@ export default {
1430
1560
  && resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
1431
1561
  const workspaceBwrapSandbox = jailed && ctx.environment?.sandbox?.trust === 'workspace'
1432
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);
1433
1575
  try {
1434
1576
  // Trust is normalized at entry (legacy `internal` reads as public). The
1435
1577
  // MCP policy follows trust alone, whatever confines the seat: a public
@@ -1444,7 +1586,50 @@ export default {
1444
1586
  if (!bwrap.available) throw new Error(`workspace Claude agents require bwrap: ${bwrap.error}`);
1445
1587
  const { claudeBin, claudeInstall } = resolveClaudeInstall(withClaudePath(ctx.env));
1446
1588
  if (!claudeInstall) throw claudeInstallRefusal(claudeBin);
1447
- 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
+ }
1448
1633
  }
1449
1634
  if (publicTrust) {
1450
1635
  const prompt = await publicSkillPromptArgs(mountedSkills);
@@ -1469,14 +1654,14 @@ export default {
1469
1654
  });
1470
1655
  }
1471
1656
  if (mcpConfig?.unavailableMcp.length) throw publicMcpUnavailableError(mcpConfig.unavailableMcp);
1472
- const claudeEnv = buildClaudeEnv(
1657
+ const claudeEnv = withProvider(buildClaudeEnv(
1473
1658
  ctx.env,
1474
1659
  publicClaudeState,
1475
1660
  mcpConfig?.expansionEnv,
1476
1661
  mcpConfig?.credential?.path || null,
1477
1662
  publicTrust,
1478
1663
  ctx._platform || process.platform,
1479
- );
1664
+ ));
1480
1665
  if ((publicNativeSandbox || publicBwrapSandbox) && ctx.commitAttributionEnv) {
1481
1666
  Object.assign(claudeEnv, ctx.commitAttributionEnv);
1482
1667
  }
@@ -1500,7 +1685,7 @@ export default {
1500
1685
  // check. Snapshot before the spawn: claude is subscription-authenticated
1501
1686
  // and its provider key is not an env var here, so the seat credential is
1502
1687
  // the value a tail can echo back.
1503
- credentials: spawnCredentials(spawnCtx),
1688
+ credentials: turnCredentials(spawnCtx),
1504
1689
  spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }), // test seam only — do not use in production
1505
1690
  ownGroup: wantsOwnProcessGroup(ctx),
1506
1691
  transcriptPath: claudeTranscriptPath({
@@ -1519,7 +1704,7 @@ export default {
1519
1704
  cwd: physicalPath(ctx.cwd), sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }),
1520
1705
  });
1521
1706
  return {
1522
- text: turnReply(stdout, { credentials: spawnCredentials(spawnCtx), publicMcp: witnessedMcp }),
1707
+ text: turnReply(stdout, { credentials: turnCredentials(spawnCtx), publicMcp: witnessedMcp }),
1523
1708
  newSessionId: sessionId,
1524
1709
  contextTokens,
1525
1710
  // D245 item 4: what the run said about itself, for the reply receipt.
@@ -1553,14 +1738,14 @@ export default {
1553
1738
  mcpReadDirectories: mcpConfig?.mcpReadDirectories || [],
1554
1739
  publicClaudeState,
1555
1740
  publicSkillArgs,
1556
- claudeEnv: buildClaudeEnv(
1741
+ claudeEnv: withProvider(buildClaudeEnv(
1557
1742
  ctx.env,
1558
1743
  publicClaudeState,
1559
1744
  mcpConfig?.expansionEnv,
1560
1745
  mcpConfig?.credential?.path || null,
1561
1746
  publicTrust,
1562
1747
  ctx._platform || process.platform,
1563
- ),
1748
+ )),
1564
1749
  };
1565
1750
  if ((publicNativeSandbox || publicBwrapSandbox) && ctx.commitAttributionEnv) {
1566
1751
  Object.assign(retryCtx.claudeEnv, ctx.commitAttributionEnv);
@@ -1572,7 +1757,7 @@ export default {
1572
1757
  cwd: ctx.cwd,
1573
1758
  env: retry.env,
1574
1759
  timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
1575
- credentials: spawnCredentials(retryCtx),
1760
+ credentials: turnCredentials(retryCtx),
1576
1761
  spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }),
1577
1762
  ownGroup: wantsOwnProcessGroup(ctx),
1578
1763
  transcriptPath: claudeTranscriptPath({
@@ -1591,7 +1776,7 @@ export default {
1591
1776
  configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }),
1592
1777
  });
1593
1778
  return {
1594
- text: turnReply(stdout, { credentials: spawnCredentials(retryCtx), publicMcp: witnessedMcp }),
1779
+ text: turnReply(stdout, { credentials: turnCredentials(retryCtx), publicMcp: witnessedMcp }),
1595
1780
  newSessionId: freshId,
1596
1781
  contextTokens,
1597
1782
  stats: claudeRunStats(stdout),
@@ -1602,6 +1787,13 @@ export default {
1602
1787
  if (mcpConfig?.dir) {
1603
1788
  try { await rm(mcpConfig.dir, { recursive: true, force: true }); } catch { /* ignore */ }
1604
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
+ }
1605
1797
  }
1606
1798
  },
1607
1799
  };
@@ -201,7 +201,8 @@ const pidAlive = (pid) => {
201
201
  * keep running, unsupervised and invisible to the task (seen on windows-latest,
202
202
  * 11-04: a stopped task left its daemon checking in). A daemon started by the
203
203
  * launcher therefore polls the launcher's pid and calls `onGone` once it has
204
- * exited. Returns a stop function; does nothing outside the launcher.
204
+ * exited, at once if it is already gone when the watch is armed. Returns a
205
+ * stop function; does nothing outside the launcher.
205
206
  */
206
207
  export const watchWindowsLauncher = ({
207
208
  env = process.env,
@@ -221,6 +222,12 @@ export const watchWindowsLauncher = ({
221
222
  // swallow errors from the moment the watch is armed, not only after the
222
223
  // launcher's exit is noticed up to two seconds later.
223
224
  for (const stream of streams) stream?.on?.('error', () => {});
225
+ // Already gone when armed (the daemon is armed before its first tick): say
226
+ // so now, rather than after a poll and a heartbeat logged into a dead pipe.
227
+ if (!isAlive(pid)) {
228
+ onGone(pid);
229
+ return () => {};
230
+ }
224
231
  const timer = setIntervalFn(() => {
225
232
  if (isAlive(pid)) return;
226
233
  clearIntervalFn(timer);
@@ -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 };
@@ -41,6 +41,7 @@ import { homedir } from 'os';
41
41
  import { join } from 'path';
42
42
 
43
43
  import { writeServiceFile } from '../daemon-service.js';
44
+ import { effectiveSandboxTrust } from '../environment.js';
44
45
  import { UNSANDBOXED_FLAG } from './host-check.js';
45
46
 
46
47
  export { UNSANDBOXED_FLAG };
@@ -100,7 +101,9 @@ export const POLICY_ONLY_ADAPTERS = new Set(['claude']);
100
101
  export const hostUnconfinedForRun = ({
101
102
  optedIn, adapterName, environment, platform = process.platform,
102
103
  }) => {
103
- const trust = environment?.sandbox?.trust;
104
+ // The effective trust, as host-check.js and the claude adapter read it: a
105
+ // legacy `internal` record means `public`, so it gets the same opt-in.
106
+ const trust = effectiveSandboxTrust(environment?.sandbox?.trust);
104
107
  return platform === 'win32' && optedIn === true && POLICY_ONLY_ADAPTERS.has(adapterName)
105
108
  && (trust === 'public' || trust === 'workspace');
106
109
  };