@commonlyai/cli 0.1.232 → 0.1.234

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.232",
3
+ "version": "0.1.234",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -90,6 +90,9 @@ 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
92
  import { hostSandboxRefusal, windowsSandboxMessage } from '../lib/sandbox/host-check.js';
93
+ import {
94
+ UNSANDBOXED_NOTICE, hostUnconfinedForRun, runOptedInUnsandboxed, unsandboxedFlagRefusal,
95
+ } from '../lib/sandbox/windows-unsandboxed.js';
93
96
  import { prepareMcpBeforeFirstPoll } from '../lib/mcp-first-run.js';
94
97
  import { installedCliVersion, startUpdateNotice } from '../lib/update-check.js';
95
98
  import { createSeatStop } from '../lib/seat-stop.js';
@@ -131,6 +134,7 @@ import {
131
134
  peerHoldsFrame,
132
135
  resolveCascadeSettings,
133
136
  } from '../lib/enforcement.js';
137
+ import { buildReplyReceipt } from '../lib/reply-receipt.js';
134
138
 
135
139
  const isPodFocusDiagnostic = (error) => (
136
140
  typeof error?.code === 'string'
@@ -1581,6 +1585,9 @@ export const performRun = ({
1581
1585
  instanceId = 'default',
1582
1586
  podId = null,
1583
1587
  environment = null,
1588
+ // D245: the user's own Windows opt-in for this run; reaches the adapter's
1589
+ // spawn context only (never the environment).
1590
+ hostUnconfined = false,
1584
1591
  workspacePath = null,
1585
1592
  intervalMs = 5000,
1586
1593
  log = () => {},
@@ -2109,7 +2116,7 @@ export const performRun = ({
2109
2116
  // failed read is not: it throws, so the event stays unacked and comes back
2110
2117
  // through the spawn-retry backoff instead of being refused for good.
2111
2118
  if (placement.reason) {
2112
- const turnRefusal = hostSandboxRefusal({ environment: placement.environment, adapterName: adapter.name });
2119
+ const turnRefusal = hostSandboxRefusal({ environment: placement.environment, adapterName: adapter.name, hostUnconfined });
2113
2120
  if (turnRefusal && placement.readError) {
2114
2121
  throw Object.assign(
2115
2122
  new Error(`turn deferred, because its pods could not be read: ${placement.readError.message}`, {
@@ -2290,6 +2297,8 @@ export const performRun = ({
2290
2297
  ? { mounted: serverSkills.mounted, conflicted: serverSkills.conflicted, ...(skillReadUnavailable ? { status: 'unavailable' } : {}) }
2291
2298
  : null;
2292
2299
  let spawnCompleted = false;
2300
+ // D245 item 4: how long the runtime ran, for the reply receipt.
2301
+ let spawnDurationMs;
2293
2302
  let observedSpawn = false;
2294
2303
  let commitAttribution = null;
2295
2304
  try {
@@ -2336,6 +2345,8 @@ export const performRun = ({
2336
2345
  memoryLongTerm,
2337
2346
  memoryWithheld,
2338
2347
  environment: placement.environment,
2348
+ // Only when set, so every other spawn context stays as it was.
2349
+ ...(hostUnconfined === true ? { hostUnconfined: true } : {}),
2339
2350
  onSkillsLinked: ({ mounted, conflicted }) => {
2340
2351
  skillMount = {
2341
2352
  mounted: [...new Set([...serverSkills.mounted, ...mounted.map(reportedSkillName)])],
@@ -2356,7 +2367,9 @@ export const performRun = ({
2356
2367
  };
2357
2368
  // Supervised or plain, a failure is thrown exactly as the adapter's own
2358
2369
  // would be, so the run loop's classification and retry apply unchanged.
2370
+ const spawnStartedAt = Date.now();
2359
2371
  result = await turn.run(adapter, spawnPrompt, spawnCtx);
2372
+ spawnDurationMs = Date.now() - spawnStartedAt;
2360
2373
  spawnCompleted = true;
2361
2374
  } finally {
2362
2375
  try {
@@ -2543,6 +2556,12 @@ export const performRun = ({
2543
2556
  maxChunks: maxChatChunks,
2544
2557
  uploadName: `${agentName}-reply-${event._id}.md`,
2545
2558
  log: (line) => log(`[${event.type}] ${line}`),
2559
+ // D245 item 4: the wrapper's own account of this turn.
2560
+ receipt: buildReplyReceipt({
2561
+ adapterName: adapter.name,
2562
+ stats: result.stats,
2563
+ durationMs: spawnDurationMs,
2564
+ }),
2546
2565
  });
2547
2566
  } catch (deliveryError) {
2548
2567
  // TASK-535: every rung threw, so the reply reached nobody. The throw
@@ -4218,7 +4237,15 @@ Docs:
4218
4237
  .option('--cascade-reset <ms>', `Silence window that clears the streak (env ${CASCADE_ENV_VARS.resetMs}, default ${CASCADE_DEFAULTS.resetMs})`)
4219
4238
  .option('--refusal-handoff <agent>', 'Agent to @mention when the model provider refuses a request\'s content; the request is acked as refused, never retried')
4220
4239
  .option('--allow <grants>', 'On first run of an agent that works in its folder, also let it edit files and run commands (write) and/or fetch web pages (web), comma-separated. Commands reach the network.')
4240
+ .option('--unsandboxed', 'Native Windows only: run this agent without a sandbox, with your own access to files and commands. It gets no connector grants.')
4221
4241
  .action(async (name, opts) => {
4242
+ if (opts.unsandboxed) {
4243
+ const flagRefusal = unsandboxedFlagRefusal();
4244
+ if (flagRefusal) {
4245
+ console.error(`${stamp()} [${name}] ${flagRefusal}`);
4246
+ process.exit(1);
4247
+ }
4248
+ }
4222
4249
  let allow;
4223
4250
  try {
4224
4251
  allow = parseAllowGrants(opts.allow);
@@ -4258,9 +4285,21 @@ Docs:
4258
4285
  bootstrapped = true;
4259
4286
  }
4260
4287
 
4288
+ // D245: on native Windows the user may choose to run without an OS
4289
+ // sandbox, by typing the flag or by installing the daemon with it (the
4290
+ // marker is read only by the seats that daemon spawns). Nothing from the
4291
+ // server decides this, and the environment never carries it: the seat
4292
+ // keeps its trust and tool policy, and only the OS jail is skipped.
4293
+ const hostUnconfined = hostUnconfinedForRun({
4294
+ optedIn: runOptedInUnsandboxed({ flag: opts.unsandboxed === true }),
4295
+ adapterName: record.adapter,
4296
+ environment: record.environment,
4297
+ });
4298
+ if (hostUnconfined) console.warn(`${stamp()} [${name}] ${UNSANDBOXED_NOTICE}`);
4299
+
4261
4300
  // Every turn of a seat this host cannot confine would fail, so say so
4262
4301
  // once, before a token file is written or a poll is made.
4263
- const hostRefusal = hostSandboxRefusal({ environment: record.environment, adapterName: record.adapter });
4302
+ const hostRefusal = hostSandboxRefusal({ environment: record.environment, adapterName: record.adapter, hostUnconfined });
4264
4303
  if (hostRefusal) {
4265
4304
  console.error(`${stamp()} [${name}] ${hostRefusal}`);
4266
4305
  process.exit(1);
@@ -4349,6 +4388,7 @@ Docs:
4349
4388
  instanceId: record.instanceId,
4350
4389
  podId: record.podId,
4351
4390
  environment: record.environment || null,
4391
+ hostUnconfined,
4352
4392
  workspacePath: record.workspacePath || null,
4353
4393
  intervalMs: parseInt(opts.interval, 10),
4354
4394
  ...cascadeOverridesFromOpts(opts),
@@ -38,6 +38,10 @@ import {
38
38
  servicePaths,
39
39
  } from '../lib/daemon-service.js';
40
40
  import { daemonLogsDir, daemonSeatLogPath, readLogTail } from '../lib/daemon-logs.js';
41
+ import { watchWindowsLauncher } from '../lib/daemon-service-windows.js';
42
+ import {
43
+ UNSANDBOXED_FLAG, clearUnsandboxedOptIn, hostSandboxReport, loadUnsandboxedOptIn, saveUnsandboxedOptIn, unsandboxedFlagRefusal,
44
+ } from '../lib/sandbox/windows-unsandboxed.js';
41
45
  import { loadDaemonState, saveDaemonState } from '../lib/daemon-state.js';
42
46
  import {
43
47
  CLEAR_SCREEN, renderDaemonWatch, shouldRedraw, WATCH_INTERVAL_MS,
@@ -433,21 +437,44 @@ Examples:
433
437
 
434
438
  daemon
435
439
  .command('install')
436
- .description('Register the daemon as a login service (launchd/systemd); on Linux, also turn on lingering so it starts at boot')
437
- .action(async () => {
440
+ .description('Register the daemon as a login service (launchd, systemd, or a Windows scheduled task); on Linux, also turn on lingering so it starts at boot')
441
+ .option(UNSANDBOXED_FLAG, 'Native Windows only: the agents this daemon runs have no sandbox, and run with your own access to files and commands. They get no connector grants. Install again without it to turn it off.')
442
+ .action(async (opts) => {
438
443
  try {
444
+ if (opts.unsandboxed) {
445
+ const flagRefusal = unsandboxedFlagRefusal();
446
+ if (flagRefusal) throw new Error(flagRefusal);
447
+ }
439
448
  // A service without a credential just crash-loops at boot.
440
449
  requireDaemonRecord();
450
+ // D245: this command is the only writer of the opt-in, and installing
451
+ // without the flag is how it is turned off. Written before the task
452
+ // starts, so the first seats it spawns already read the answer.
453
+ // N3: the marker's state before this install, so a failed install can
454
+ // put it back rather than leave an opt-in with no daemon behind it.
455
+ const optedInBefore = loadUnsandboxedOptIn();
456
+ if (opts.unsandboxed) {
457
+ saveUnsandboxedOptIn();
458
+ console.log('Agents this daemon runs will have no sandbox: they act with your own access to files and commands, and get no connector grants. To turn this off, run: commonly daemon install');
459
+ } else if (clearUnsandboxedOptIn()) {
460
+ console.log('Turned off running without a sandbox: on Windows, agents this daemon runs are refused again until you choose otherwise.');
461
+ }
441
462
  // Same class one layer over (TASK-049): a service without the SEATS'
442
463
  // provider key comes up clean, and every pi seat then dies inside its
443
464
  // adapter. The keys are captured here because this is the only moment
444
465
  // the operator's shell is in reach; the ones that are missing are named
445
466
  // by installDaemonService rather than left to be discovered per seat.
446
- const installed = await installDaemonService({
447
- ...serviceDeps(),
448
- providerKeyEnvNames: providerKeyEnvNames(),
449
- optionalEnvNames: [UPDATE_CHECK_OPT_OUT_ENV],
450
- });
467
+ let installed;
468
+ try {
469
+ installed = await installDaemonService({
470
+ ...serviceDeps(),
471
+ providerKeyEnvNames: providerKeyEnvNames(),
472
+ optionalEnvNames: [UPDATE_CHECK_OPT_OUT_ENV],
473
+ });
474
+ } catch (error) {
475
+ if (optedInBefore) saveUnsandboxedOptIn(); else clearUnsandboxedOptIn();
476
+ throw error;
477
+ }
451
478
  console.log(installSummary(installed));
452
479
  } catch (error) {
453
480
  console.error(`Daemon install failed: ${error.message}`);
@@ -464,6 +491,10 @@ Examples:
464
491
  } catch (error) {
465
492
  console.error(`Daemon uninstall failed: ${error.message}`);
466
493
  process.exitCode = 1;
494
+ } finally {
495
+ // N4: an uninstall that half-failed still turns the opt-in off; a
496
+ // later install has to ask for it again.
497
+ clearUnsandboxedOptIn();
467
498
  }
468
499
  });
469
500
 
@@ -590,11 +621,18 @@ Examples:
590
621
  chmodSync(seatLog, 0o600);
591
622
  return spawn(process.execPath, [process.argv[1], 'agent', 'run', agentName], {
592
623
  stdio: ['ignore', out, out],
624
+ // Windows: never flash a console window per seat (no-op elsewhere).
625
+ windowsHide: true,
593
626
  env: { ...process.env, COMMONLY_MANAGED_RUNNER: '1' },
594
627
  });
595
628
  },
596
629
  loadToken: loadAgentToken,
597
630
  saveToken: saveAgentToken,
631
+ // Native Windows has no OS sandbox for any adapter, so it never
632
+ // reports 'default': Codex there runs on its own profiles only, and a
633
+ // removed marker must not read as "confined" while seats may still run.
634
+ // An older daemon sends nothing, which leaves the server's record.
635
+ machineReport: () => ({ sandbox: hostSandboxReport() }),
598
636
  runtimeInventory: async () => {
599
637
  if (!cachedInventory || Date.now() - inventoryCheckedAt > 300_000) {
600
638
  cachedInventory = await collectRuntimeInventory(getAdapter);
@@ -640,6 +678,14 @@ Examples:
640
678
  };
641
679
  process.on('SIGINT', shutdown);
642
680
  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
+ });
643
689
  } catch (error) {
644
690
  console.error(`Daemon run failed: ${error.message}`);
645
691
  const badConfig = !record || error.message.startsWith('Daemon credential file');
@@ -676,6 +722,7 @@ Examples:
676
722
  });
677
723
  const lastSeen = machine.lastSeenAt ? new Date(machine.lastSeenAt).toLocaleString() : 'never';
678
724
  console.log(`${machine.name}: ${machine.status} (last heartbeat: ${lastSeen})`);
725
+ if (loadUnsandboxedOptIn()) console.log('Sandbox: unsandboxed (opted in). Agents run with your own access and get no connector grants. Turn off: commonly daemon install');
679
726
  if (opts.verbose) {
680
727
  const local = loadDaemonState();
681
728
  if (!local) {
@@ -53,6 +53,7 @@ import { describeTurnReap, markTurn, turnReapEnabled } from '@commonlyai/seats/t
53
53
  */
54
54
 
55
55
  import { requireSeatModel, seatModelRequired } from './seat-model.js';
56
+ import { claudeRunStats } from '../reply-receipt.js';
56
57
  import { spawn as childSpawn, spawnSync } from 'child_process';
57
58
  import { createHash, randomUUID } from 'crypto';
58
59
  import { accessSync, constants, realpathSync } from 'fs';
@@ -100,6 +101,7 @@ import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
100
101
  import { PUBLIC_WITHHELD_TOOLS } from '../public-withheld-tools.js';
101
102
  import { isShippedCommonlyMcpCommand } from '../declared-mcp-guard.js';
102
103
  import { isAnswerableToolError, PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE } from '../public-tool-error.js';
104
+ import { resolveLaunch, resolveMcpCommand, resolveWindowsCommandWithReason } from '../windows-command.js';
103
105
 
104
106
  // See codex.js for the rationale on bumping the default + env override.
105
107
  // Keeping both adapters in lockstep so any wrapper agent runtime has the
@@ -145,6 +147,9 @@ const PUBLIC_DENIED_TOOLS = [
145
147
  'WebSearch',
146
148
  'WebFetch',
147
149
  'Bash',
150
+ // Claude Code's shell tool on Windows. dontAsk and the Read(./**) allowlist
151
+ // already refuse it; named here so the deny list holds on its own (D245).
152
+ 'PowerShell',
148
153
  'Write',
149
154
  'Edit',
150
155
  'NotebookEdit',
@@ -158,6 +163,7 @@ const isConfinedTrust = (trust) => trust === 'public' || trust === 'workspace';
158
163
  // WebFetch alone: WebSearch, NotebookEdit and Task stay denied at every tier.
159
164
  // Bash and Write land wherever the jail lets them, so the jail, not this list,
160
165
  // bounds a write.
166
+ const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
161
167
  const WORKSPACE_ALLOW_TOOLS = {
162
168
  write: ['Edit', 'Write', 'Bash'],
163
169
  web: ['WebFetch'],
@@ -210,6 +216,10 @@ const withWorkspaceGit = (ctx) => {
210
216
  };
211
217
  };
212
218
  const PUBLIC_SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR)$/;
219
+ // D245: what a Windows process needs to start at all, find its own login and
220
+ // open a socket. Windows keys are case-insensitive (Path, SystemRoot), so this
221
+ // one is matched without case. None of these carries a secret.
222
+ const PUBLIC_SAFE_ENV_WIN32 = /^(?:PATH|PATHEXT|SYSTEMROOT|SYSTEMDRIVE|WINDIR|COMSPEC|TEMP|TMP|USERPROFILE|USERNAME|USERDOMAIN|HOMEDRIVE|HOMEPATH|APPDATA|LOCALAPPDATA|PROGRAMDATA|PROGRAMFILES|PROGRAMFILES\(X86\)|COMMONPROGRAMFILES|NUMBER_OF_PROCESSORS|PROCESSOR_ARCHITECTURE|OS)$/i;
213
223
 
214
224
  const statOrNull = async (path) => {
215
225
  try {
@@ -379,12 +389,13 @@ const prepareWorkspaceBwrapClaudeState = async (ctx) => {
379
389
  return { statePath, tmpPath, configPath, loginPath };
380
390
  };
381
391
 
382
- const buildClaudeEnv = (input, state, expansionEnv = {}, credentialFile = null, publicSeat = false) => {
392
+ const buildClaudeEnv = (input, state, expansionEnv = {}, credentialFile = null, publicSeat = false, platform = process.platform) => {
383
393
  const source = input || process.env;
384
394
  const output = state || publicSeat ? {} : { ...source };
385
395
  if (state || publicSeat) {
396
+ const safe = platform === 'win32' ? PUBLIC_SAFE_ENV_WIN32 : PUBLIC_SAFE_ENV;
386
397
  for (const [key, value] of Object.entries(source)) {
387
- if (PUBLIC_SAFE_ENV.test(key)) output[key] = value;
398
+ if (safe.test(key) || PUBLIC_SAFE_ENV.test(key)) output[key] = value;
388
399
  }
389
400
  }
390
401
  if (state) {
@@ -412,20 +423,38 @@ const buildClaudeEnv = (input, state, expansionEnv = {}, credentialFile = null,
412
423
 
413
424
  const absoluteToolDeny = (path) => `Read(/${path}/**)`;
414
425
 
426
+ // D245: Claude Code matches permission paths in POSIX form on Windows
427
+ // (C:\Users\ada becomes /c/Users/ada), so an absolute deny written from a
428
+ // Windows path would match nothing.
429
+ const permissionPath = (path, platform) => (platform === 'win32'
430
+ ? path.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`)
431
+ : path);
432
+
415
433
  // The tools that read or write the seat's agent-wide memory.
416
434
  const MEMORY_TOOLS = Object.freeze(['commonly_read_agent_memory', 'commonly_write_agent_memory', 'commonly_save_my_memory']);
417
435
 
418
- const buildPublicClaudePolicyArgs = (mcpToolPatterns, { trust, allow = [], memoryWithheld = false } = {}) => {
419
- const home = homedir();
436
+ const buildPublicClaudePolicyArgs = (mcpToolPatterns, {
437
+ trust, allow = [], memoryWithheld = false, platform = process.platform, home: homeOverride = null,
438
+ noShell = false,
439
+ } = {}) => {
440
+ const home = homeOverride || homedir();
441
+ const deny = (sub) => absoluteToolDeny(permissionPath(join(home, sub), platform));
420
442
  // `allow` is already narrowed to a workspace seat's grants (workspaceGrants).
421
- const granted = allow.flatMap((grant) => WORKSPACE_ALLOW_TOOLS[grant] || []);
443
+ // D245: with no OS jail (native Windows, --unsandboxed) a shell would run
444
+ // with the user's whole access and could read the seat's own token from its
445
+ // environment, so `write` grants edits only there.
446
+ // With no jail the edit tools are also scoped to the working folder: a bare
447
+ // Edit/Write could reach the user's profile (a Startup script, ~/.claude).
448
+ const granted = allow.flatMap((grant) => WORKSPACE_ALLOW_TOOLS[grant] || [])
449
+ .filter((tool) => !(noShell && SHELL_TOOLS.has(tool)))
450
+ .map((tool) => (noShell && (tool === 'Edit' || tool === 'Write') ? `${tool}(./**)` : tool));
422
451
  const deniedReads = [
423
- absoluteToolDeny(join(home, '.commonly')),
424
- absoluteToolDeny(join(home, '.claude')),
425
- absoluteToolDeny(join(home, '.codex')),
426
- absoluteToolDeny(join(home, '.ssh')),
427
- absoluteToolDeny(join(home, '.aws')),
428
- absoluteToolDeny(join(home, '.config')),
452
+ deny('.commonly'),
453
+ deny('.claude'),
454
+ deny('.codex'),
455
+ deny('.ssh'),
456
+ deny('.aws'),
457
+ deny('.config'),
429
458
  'Read(//private/tmp/**)',
430
459
  'Read(./.commonly/**)',
431
460
  'Read(./.codex/**)',
@@ -465,7 +494,7 @@ const buildPublicClaudePolicyArgs = (mcpToolPatterns, { trust, allow = [], memor
465
494
  '--no-chrome',
466
495
  '--permission-mode', 'dontAsk',
467
496
  '--allowedTools', 'Read(./**)', ...granted, ...mcpToolPatterns,
468
- '--disallowedTools', ...PUBLIC_DENIED_TOOLS.filter((tool) => !granted.includes(tool)),
497
+ '--disallowedTools', ...PUBLIC_DENIED_TOOLS.filter((tool) => !granted.some((g) => g === tool || g.startsWith(`${tool}(`))),
469
498
  ...deniedReads, ...workspaceDenies, ...memoryDenies, ...withheldDenies,
470
499
  ];
471
500
  };
@@ -638,11 +667,15 @@ const runClaude = ({
638
667
  cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false,
639
668
  transcriptPath = null, firstOutputMs = firstOutputWindowMs(), lockProbe, stallKillGraceMs = STALL_KILL_GRACE_MS,
640
669
  processTable = readProcessTable, survivorIo, modelStream = true, turnReap = turnReapEnabled(),
670
+ commandResolution = {},
641
671
  }) => new Promise((resolve, reject) => {
642
672
  // Every process the turn starts inherits this marker however it detaches,
643
673
  // so what it leaves running is stopped once the turn has closed.
644
674
  const turn = markTurn(env ?? process.env, { enabled: turnReap });
645
- const proc = spawnInOwnGroup(spawnImpl, cmd, args, {
675
+ // On Windows an npm `claude.cmd` is read, not run through cmd.exe: the
676
+ // prompt in `args` is pod content (#2141). Off Windows this is identity.
677
+ const launch = resolveLaunch(cmd, args, { env: turn.env, ...commandResolution });
678
+ const proc = spawnInOwnGroup(spawnImpl, launch.command, launch.args, {
646
679
  cwd,
647
680
  env: turn.env,
648
681
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -871,7 +904,11 @@ const buildMcpConfig = (mcpServers, ctx = {}) => {
871
904
  const entry = { type: server.transport || 'stdio' };
872
905
  if (server.url) entry.url = server.url;
873
906
  if (spawnCommand) {
874
- const [command, ...args] = spawnCommand;
907
+ // Win32 only: claude spawns this without a shell, so an `npx.cmd` is
908
+ // resolved to its target here (#2141). Identity on every other platform.
909
+ const [command, ...args] = resolveMcpCommand(spawnCommand, ctx.commandResolution, {
910
+ label: 'claude', name: server.name,
911
+ });
875
912
  entry.command = command;
876
913
  if (args.length) entry.args = args;
877
914
  }
@@ -966,11 +1003,14 @@ const createMcpConfig = async (mcpServers, ctx = {}) => {
966
1003
  const withClaudePath = (input) => {
967
1004
  const output = { ...(input || process.env) };
968
1005
  const localBin = join(homedir(), '.local', 'bin');
969
- const entries = String(output.PATH || '')
1006
+ // Windows keys are case-insensitive, and a child given both `Path` and a
1007
+ // new `PATH` keeps only one of them, so update the key that is already there.
1008
+ const key = Object.keys(output).find((k) => k.toUpperCase() === 'PATH') || 'PATH';
1009
+ const entries = String(output[key] || '')
970
1010
  .split(delimiter)
971
1011
  .filter(Boolean);
972
1012
  if (!entries.includes(localBin)) entries.push(localBin);
973
- output.PATH = entries.join(delimiter);
1013
+ output[key] = entries.join(delimiter);
974
1014
  return output;
975
1015
  };
976
1016
 
@@ -980,6 +1020,10 @@ const withClaudePath = (input) => {
980
1020
  // PATH but cannot reach the user's `~/.local/bin` without an absolute path
981
1021
  // argv[0], even when that directory is bound read-only into the sandbox.
982
1022
  const resolveClaudePath = (env = process.env) => {
1023
+ // Windows has no `which`; the shim-aware resolver finds the same file.
1024
+ if (process.platform === 'win32') {
1025
+ return resolveWindowsCommandWithReason('claude', { env: withClaudePath(env) }).source || 'claude';
1026
+ }
983
1027
  // Defensive: spawnSync can return undefined under aggressive mocks (the
984
1028
  // adapters.claude.environment.test.mjs suite stubs child_process so no real
985
1029
  // process runs). Treat any failure mode as "use the bare command name."
@@ -1022,6 +1066,16 @@ const claudeInstallRefusal = (claudeBin) => new Error(
1022
1066
  + `\`${claudeBin}\` could not be resolved`,
1023
1067
  );
1024
1068
 
1069
+ /**
1070
+ * D245: native Windows has no OS jail, and the user chose to run without one
1071
+ * (`--unsandboxed`). Read from the spawn context only, set by `agent run`, never
1072
+ * from the environment. The seat keeps its trust, so everything the trust
1073
+ * brings except the jail still applies.
1074
+ */
1075
+ const policyOnlyHost = (ctx) => ctx.hostUnconfined === true
1076
+ && (ctx._platform || process.platform) === 'win32'
1077
+ && isConfinedTrust(ctx.environment?.sandbox?.trust);
1078
+
1025
1079
  const prepareArgv = async (innerArgv, ctx) => {
1026
1080
  const env = ctx.environment;
1027
1081
  const claudeEnv = withClaudePath(ctx.claudeEnv);
@@ -1078,6 +1132,22 @@ const prepareArgv = async (innerArgv, ctx) => {
1078
1132
  }
1079
1133
  })
1080
1134
  : [];
1135
+ if (policyOnlyHost(ctx)) {
1136
+ // The bwrap branch's policy, without the jail: on a host with no OS
1137
+ // isolation the tool policy is the whole boundary, so it is never dropped.
1138
+ innerArgv = [
1139
+ ...innerArgv,
1140
+ ...buildPublicClaudePolicyArgs(allowedPatterns, {
1141
+ ...(sandboxTrust === 'workspace' ? { trust: sandboxTrust, allow: grants } : {}),
1142
+ memoryWithheld: ctx.memoryWithheld === true,
1143
+ platform: ctx._platform || process.platform,
1144
+ home: ctx._home || null,
1145
+ noShell: true,
1146
+ }),
1147
+ ...(ctx.publicSkillArgs || []),
1148
+ ];
1149
+ return { cmd: 'claude', args: innerArgv, env: claudeEnv };
1150
+ }
1081
1151
  // A public trust MUST resolve to an enforced mode. Absent mode is resolved
1082
1152
  // above; anything else that lands here (the literal 'none', a typo, a
1083
1153
  // non-string) used to fall through to the bare `claude` spawn below — an
@@ -1230,10 +1300,19 @@ export default {
1230
1300
 
1231
1301
  inventory: () => claudeRuntimeInventory({ env: withClaudePath(process.env) }),
1232
1302
 
1233
- async detect() {
1303
+ // `opts` is a test seam only: `_commandResolution` overrides the Windows
1304
+ // resolver's platform/env/fs. Production calls detect() with no argument.
1305
+ async detect(opts = {}) {
1234
1306
  try {
1235
1307
  const claudeEnv = withClaudePath(process.env);
1236
- const res = spawnSync('claude', ['--version'], {
1308
+ const resolution = { env: claudeEnv, ...opts._commandResolution };
1309
+ const located = resolveWindowsCommandWithReason('claude', resolution);
1310
+ if (!located.command) {
1311
+ // Windows only: a `claude.cmd` this CLI cannot read safely, or none.
1312
+ process.stderr.write(`claude adapter: ${located.reason}\n`);
1313
+ return null;
1314
+ }
1315
+ const res = spawnSync(located.command, [...located.args, '--version'], {
1237
1316
  encoding: 'utf8', timeout: 5000,
1238
1317
  env: claudeEnv,
1239
1318
  });
@@ -1242,7 +1321,8 @@ export default {
1242
1321
  const version = (res.stdout || '').trim().split(/\s+/)[0] || 'unknown';
1243
1322
  // Best-effort resolve of the binary path for clearer UX ("claude detected
1244
1323
  // at /usr/local/bin/claude"). Falls back to the bare command name on
1245
- // platforms without `which` (e.g. Windows).
1324
+ // platforms without `which`. On Windows the resolver already found it.
1325
+ if (located.source) return { path: located.source, version };
1246
1326
  const where = spawnSync('which', ['claude'], {
1247
1327
  encoding: 'utf8',
1248
1328
  env: claudeEnv,
@@ -1342,11 +1422,13 @@ export default {
1342
1422
  let mcpConfig = null;
1343
1423
  let publicClaudeState = null;
1344
1424
  let publicSkillArgs = [];
1345
- const publicNativeSandbox = publicTrust
1425
+ // D245: no jail to prepare for on a policy-only Windows host.
1426
+ const jailed = !policyOnlyHost(ctx);
1427
+ const publicNativeSandbox = jailed && publicTrust
1346
1428
  && PUBLIC_SANDBOX_MODES.has(resolvePublicSandboxMode(ctx.environment?.sandbox));
1347
- const publicBwrapSandbox = publicTrust
1429
+ const publicBwrapSandbox = jailed && publicTrust
1348
1430
  && resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
1349
- const workspaceBwrapSandbox = ctx.environment?.sandbox?.trust === 'workspace'
1431
+ const workspaceBwrapSandbox = jailed && ctx.environment?.sandbox?.trust === 'workspace'
1350
1432
  && resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
1351
1433
  try {
1352
1434
  // Trust is normalized at entry (legacy `internal` reads as public). The
@@ -1383,6 +1465,7 @@ export default {
1383
1465
  instanceUrl: ctx.instanceUrl,
1384
1466
  agentName: ctx.agentName,
1385
1467
  publicSeat: publicTrust,
1468
+ commandResolution: { env: withClaudePath(ctx.env), ...ctx._commandResolution },
1386
1469
  });
1387
1470
  }
1388
1471
  if (mcpConfig?.unavailableMcp.length) throw publicMcpUnavailableError(mcpConfig.unavailableMcp);
@@ -1392,6 +1475,7 @@ export default {
1392
1475
  mcpConfig?.expansionEnv,
1393
1476
  mcpConfig?.credential?.path || null,
1394
1477
  publicTrust,
1478
+ ctx._platform || process.platform,
1395
1479
  );
1396
1480
  if ((publicNativeSandbox || publicBwrapSandbox) && ctx.commitAttributionEnv) {
1397
1481
  Object.assign(claudeEnv, ctx.commitAttributionEnv);
@@ -1427,6 +1511,7 @@ export default {
1427
1511
  processTable: ctx._processTable,
1428
1512
  survivorIo: ctx._survivorIo,
1429
1513
  turnReap: ctx._turnReap,
1514
+ commandResolution: ctx._commandResolution, // test seam only
1430
1515
  });
1431
1516
  // TASK-440: the context this turn left, read from the transcript the
1432
1517
  // child wrote under its own env (an isolated HOME for public seats).
@@ -1437,6 +1522,8 @@ export default {
1437
1522
  text: turnReply(stdout, { credentials: spawnCredentials(spawnCtx), publicMcp: witnessedMcp }),
1438
1523
  newSessionId: sessionId,
1439
1524
  contextTokens,
1525
+ // D245 item 4: what the run said about itself, for the reply receipt.
1526
+ stats: claudeRunStats(stdout),
1440
1527
  };
1441
1528
  } catch (err) {
1442
1529
  // A public seat's refusal is not a session-store failure, whatever its
@@ -1472,6 +1559,7 @@ export default {
1472
1559
  mcpConfig?.expansionEnv,
1473
1560
  mcpConfig?.credential?.path || null,
1474
1561
  publicTrust,
1562
+ ctx._platform || process.platform,
1475
1563
  ),
1476
1564
  };
1477
1565
  if ((publicNativeSandbox || publicBwrapSandbox) && ctx.commitAttributionEnv) {
@@ -1496,6 +1584,7 @@ export default {
1496
1584
  processTable: ctx._processTable,
1497
1585
  survivorIo: ctx._survivorIo,
1498
1586
  turnReap: ctx._turnReap,
1587
+ commandResolution: ctx._commandResolution, // test seam only
1499
1588
  });
1500
1589
  const contextTokens = claudeContextTokens({
1501
1590
  cwd: physicalPath(ctx.cwd), sessionId: freshId,
@@ -1505,6 +1594,7 @@ export default {
1505
1594
  text: turnReply(stdout, { credentials: spawnCredentials(retryCtx), publicMcp: witnessedMcp }),
1506
1595
  newSessionId: freshId,
1507
1596
  contextTokens,
1597
+ stats: claudeRunStats(stdout),
1508
1598
  };
1509
1599
  }
1510
1600
  throw err;
@@ -79,6 +79,7 @@ import { resolveSkillSource, skillsAsText } from '../skill-prompt.js';
79
79
  import { isLegacySandboxTrust, normalizeSandboxTrust, refuseWorkspaceTrust } from '../environment.js';
80
80
  import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
81
81
  import { PUBLIC_WITHHELD_TOOLS } from '../public-withheld-tools.js';
82
+ import { resolveLaunch, resolveMcpCommand, resolveWindowsCommandWithReason } from '../windows-command.js';
82
83
 
83
84
  // Default timeout for a single codex spawn (exec mode).
84
85
  //
@@ -183,7 +184,13 @@ const buildMcpOverrideArgs = (mcpServers, ctx = {}) => {
183
184
  credentialFile: ctx.credentialFile,
184
185
  label: 'codex',
185
186
  }).env;
186
- const [command, ...rest] = spawnCommand.map((a) => substitutePlaceholders(a, ctx));
187
+ // Win32 only: codex spawns the server without a shell, so an `npx.cmd` is
188
+ // resolved to its target here (#2141). Identity on every other platform.
189
+ const [command, ...rest] = resolveMcpCommand(
190
+ spawnCommand.map((a) => substitutePlaceholders(a, ctx)),
191
+ ctx.commandResolution,
192
+ { label: 'codex', name: server.name },
193
+ );
187
194
  // The doc block above promises bearer tokens never ride in argv, and this is
188
195
  // the place that had to hold it: an env value that carries the token is
189
196
  // diverted to env_vars, but a COMMAND ARGUMENT has no such route — codex
@@ -734,7 +741,7 @@ const CODEX_PROVIDER_KEY_ENVS = [
734
741
  // `ownGroup`: see runClaude in claude.js.
735
742
  const runCodex = ({
736
743
  args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false,
737
- turnReap = turnReapEnabled(),
744
+ turnReap = turnReapEnabled(), commandResolution = {},
738
745
  }) => new Promise((resolve, reject) => { // stdio: ['ignore', 'pipe', 'pipe'] — without this, child_process.spawn
739
746
  // defaults stdin to a fresh pipe. Codex 0.125.0's `exec` then blocks on
740
747
  // `Reading additional input from stdin...` because it sees an open pipe
@@ -744,7 +751,11 @@ const runCodex = ({
744
751
  // argv prompt as intended. Surfaced live during ADR-005 Phase 2 smoke.
745
752
  // What the turn leaves running is stopped once it has closed (see claude.js).
746
753
  const turn = markTurn(env ?? process.env, { enabled: turnReap });
747
- const proc = spawnInOwnGroup(spawnImpl, 'codex', args, { cwd, env: turn.env, stdio: ['ignore', 'pipe', 'pipe'] }, { ownGroup });
754
+ // On Windows `npm i -g @openai/codex` leaves a `codex.cmd`; it is read and
755
+ // its target spawned directly, never through cmd.exe, because the prompt is
756
+ // the last argv element and is pod content (#2141). Identity elsewhere.
757
+ const launch = resolveLaunch('codex', args, { env: turn.env, ...commandResolution });
758
+ const proc = spawnInOwnGroup(spawnImpl, launch.command, launch.args, { cwd, env: turn.env, stdio: ['ignore', 'pipe', 'pipe'] }, { ownGroup });
748
759
  observeSpawn(proc, onSpawn, controls);
749
760
  let stderr = '';
750
761
  let timedOut = false;
@@ -798,9 +809,17 @@ export default {
798
809
 
799
810
  inventory: codexRuntimeInventory,
800
811
 
801
- async detect() {
812
+ // `opts` is a test seam only: `_commandResolution` overrides the Windows
813
+ // resolver's platform/env/fs. Production calls detect() with no argument.
814
+ async detect(opts = {}) {
802
815
  try {
803
- const res = spawnSync('codex', ['--version'], { encoding: 'utf8', timeout: 5000 });
816
+ const located = resolveWindowsCommandWithReason('codex', opts._commandResolution);
817
+ if (!located.command) {
818
+ // Windows only: a `codex.cmd` this CLI cannot read safely, or none.
819
+ process.stderr.write(`codex adapter: ${located.reason}\n`);
820
+ return null;
821
+ }
822
+ const res = spawnSync(located.command, [...located.args, '--version'], { encoding: 'utf8', timeout: 5000 });
804
823
  if (res.error || res.status !== 0) return null;
805
824
  // `codex --version` prints e.g. "codex-cli 0.125.0" — last token is
806
825
  // the version. Defensive against future format tweaks: if no
@@ -808,6 +827,7 @@ export default {
808
827
  const stdout = (res.stdout || '').trim();
809
828
  const versionMatch = stdout.match(/(\d+\.\d+(?:\.\d+)?)/);
810
829
  const version = versionMatch ? versionMatch[1] : (stdout || 'unknown');
830
+ if (located.source) return { path: located.source, version };
811
831
  const where = spawnSync('which', ['codex'], { encoding: 'utf8' });
812
832
  const path = where.status === 0 ? (where.stdout || '').trim() || 'codex' : 'codex';
813
833
  return { path, version };
@@ -854,6 +874,7 @@ export default {
854
874
  instanceUrl: ctx.instanceUrl,
855
875
  credentialFile: credential?.path || null,
856
876
  publicSeat: sandbox?.trust === 'public',
877
+ commandResolution: { env: ctx.env || process.env, ...ctx._commandResolution },
857
878
  });
858
879
  if (mcp.unavailableMcp.length) throw publicMcpUnavailableError(mcp.unavailableMcp);
859
880
  // A derived record stores `trust: 'public'` and no mode (the block is
@@ -927,6 +948,7 @@ export default {
927
948
  spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args.slice(args.indexOf('exec') + (args[args.indexOf('exec') + 1] === 'resume' ? 3 : 1), -1), { modelFlag: '--model', valueFlags: ['-c', '-o', '--permission-profile'], codex: true }), // test seam only — do not use in production
928
949
  ownGroup: wantsOwnProcessGroup(ctx),
929
950
  turnReap: ctx._turnReap,
951
+ commandResolution: ctx._commandResolution, // test seam only
930
952
  });
931
953
 
932
954
  let text = '';