@commonlyai/cli 0.1.232 → 0.1.233

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.233",
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';
@@ -1581,6 +1584,9 @@ export const performRun = ({
1581
1584
  instanceId = 'default',
1582
1585
  podId = null,
1583
1586
  environment = null,
1587
+ // D245: the user's own Windows opt-in for this run; reaches the adapter's
1588
+ // spawn context only (never the environment).
1589
+ hostUnconfined = false,
1584
1590
  workspacePath = null,
1585
1591
  intervalMs = 5000,
1586
1592
  log = () => {},
@@ -2109,7 +2115,7 @@ export const performRun = ({
2109
2115
  // failed read is not: it throws, so the event stays unacked and comes back
2110
2116
  // through the spawn-retry backoff instead of being refused for good.
2111
2117
  if (placement.reason) {
2112
- const turnRefusal = hostSandboxRefusal({ environment: placement.environment, adapterName: adapter.name });
2118
+ const turnRefusal = hostSandboxRefusal({ environment: placement.environment, adapterName: adapter.name, hostUnconfined });
2113
2119
  if (turnRefusal && placement.readError) {
2114
2120
  throw Object.assign(
2115
2121
  new Error(`turn deferred, because its pods could not be read: ${placement.readError.message}`, {
@@ -2336,6 +2342,8 @@ export const performRun = ({
2336
2342
  memoryLongTerm,
2337
2343
  memoryWithheld,
2338
2344
  environment: placement.environment,
2345
+ // Only when set, so every other spawn context stays as it was.
2346
+ ...(hostUnconfined === true ? { hostUnconfined: true } : {}),
2339
2347
  onSkillsLinked: ({ mounted, conflicted }) => {
2340
2348
  skillMount = {
2341
2349
  mounted: [...new Set([...serverSkills.mounted, ...mounted.map(reportedSkillName)])],
@@ -4218,7 +4226,15 @@ Docs:
4218
4226
  .option('--cascade-reset <ms>', `Silence window that clears the streak (env ${CASCADE_ENV_VARS.resetMs}, default ${CASCADE_DEFAULTS.resetMs})`)
4219
4227
  .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
4228
  .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.')
4229
+ .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
4230
  .action(async (name, opts) => {
4231
+ if (opts.unsandboxed) {
4232
+ const flagRefusal = unsandboxedFlagRefusal();
4233
+ if (flagRefusal) {
4234
+ console.error(`${stamp()} [${name}] ${flagRefusal}`);
4235
+ process.exit(1);
4236
+ }
4237
+ }
4222
4238
  let allow;
4223
4239
  try {
4224
4240
  allow = parseAllowGrants(opts.allow);
@@ -4258,9 +4274,21 @@ Docs:
4258
4274
  bootstrapped = true;
4259
4275
  }
4260
4276
 
4277
+ // D245: on native Windows the user may choose to run without an OS
4278
+ // sandbox, by typing the flag or by installing the daemon with it (the
4279
+ // marker is read only by the seats that daemon spawns). Nothing from the
4280
+ // server decides this, and the environment never carries it: the seat
4281
+ // keeps its trust and tool policy, and only the OS jail is skipped.
4282
+ const hostUnconfined = hostUnconfinedForRun({
4283
+ optedIn: runOptedInUnsandboxed({ flag: opts.unsandboxed === true }),
4284
+ adapterName: record.adapter,
4285
+ environment: record.environment,
4286
+ });
4287
+ if (hostUnconfined) console.warn(`${stamp()} [${name}] ${UNSANDBOXED_NOTICE}`);
4288
+
4261
4289
  // Every turn of a seat this host cannot confine would fail, so say so
4262
4290
  // once, before a token file is written or a poll is made.
4263
- const hostRefusal = hostSandboxRefusal({ environment: record.environment, adapterName: record.adapter });
4291
+ const hostRefusal = hostSandboxRefusal({ environment: record.environment, adapterName: record.adapter, hostUnconfined });
4264
4292
  if (hostRefusal) {
4265
4293
  console.error(`${stamp()} [${name}] ${hostRefusal}`);
4266
4294
  process.exit(1);
@@ -4349,6 +4377,7 @@ Docs:
4349
4377
  instanceId: record.instanceId,
4350
4378
  podId: record.podId,
4351
4379
  environment: record.environment || null,
4380
+ hostUnconfined,
4352
4381
  workspacePath: record.workspacePath || null,
4353
4382
  intervalMs: parseInt(opts.interval, 10),
4354
4383
  ...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) {
@@ -100,6 +100,7 @@ import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
100
100
  import { PUBLIC_WITHHELD_TOOLS } from '../public-withheld-tools.js';
101
101
  import { isShippedCommonlyMcpCommand } from '../declared-mcp-guard.js';
102
102
  import { isAnswerableToolError, PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE } from '../public-tool-error.js';
103
+ import { resolveLaunch, resolveMcpCommand, resolveWindowsCommandWithReason } from '../windows-command.js';
103
104
 
104
105
  // See codex.js for the rationale on bumping the default + env override.
105
106
  // Keeping both adapters in lockstep so any wrapper agent runtime has the
@@ -145,6 +146,9 @@ const PUBLIC_DENIED_TOOLS = [
145
146
  'WebSearch',
146
147
  'WebFetch',
147
148
  'Bash',
149
+ // Claude Code's shell tool on Windows. dontAsk and the Read(./**) allowlist
150
+ // already refuse it; named here so the deny list holds on its own (D245).
151
+ 'PowerShell',
148
152
  'Write',
149
153
  'Edit',
150
154
  'NotebookEdit',
@@ -158,6 +162,7 @@ const isConfinedTrust = (trust) => trust === 'public' || trust === 'workspace';
158
162
  // WebFetch alone: WebSearch, NotebookEdit and Task stay denied at every tier.
159
163
  // Bash and Write land wherever the jail lets them, so the jail, not this list,
160
164
  // bounds a write.
165
+ const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
161
166
  const WORKSPACE_ALLOW_TOOLS = {
162
167
  write: ['Edit', 'Write', 'Bash'],
163
168
  web: ['WebFetch'],
@@ -210,6 +215,10 @@ const withWorkspaceGit = (ctx) => {
210
215
  };
211
216
  };
212
217
  const PUBLIC_SAFE_ENV = /^(?:PATH|LANG|LC_[A-Z_]+|TERM|USER|LOGNAME|NO_COLOR|CI|SSL_CERT_FILE|SSL_CERT_DIR)$/;
218
+ // D245: what a Windows process needs to start at all, find its own login and
219
+ // open a socket. Windows keys are case-insensitive (Path, SystemRoot), so this
220
+ // one is matched without case. None of these carries a secret.
221
+ 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
222
 
214
223
  const statOrNull = async (path) => {
215
224
  try {
@@ -379,12 +388,13 @@ const prepareWorkspaceBwrapClaudeState = async (ctx) => {
379
388
  return { statePath, tmpPath, configPath, loginPath };
380
389
  };
381
390
 
382
- const buildClaudeEnv = (input, state, expansionEnv = {}, credentialFile = null, publicSeat = false) => {
391
+ const buildClaudeEnv = (input, state, expansionEnv = {}, credentialFile = null, publicSeat = false, platform = process.platform) => {
383
392
  const source = input || process.env;
384
393
  const output = state || publicSeat ? {} : { ...source };
385
394
  if (state || publicSeat) {
395
+ const safe = platform === 'win32' ? PUBLIC_SAFE_ENV_WIN32 : PUBLIC_SAFE_ENV;
386
396
  for (const [key, value] of Object.entries(source)) {
387
- if (PUBLIC_SAFE_ENV.test(key)) output[key] = value;
397
+ if (safe.test(key) || PUBLIC_SAFE_ENV.test(key)) output[key] = value;
388
398
  }
389
399
  }
390
400
  if (state) {
@@ -412,20 +422,38 @@ const buildClaudeEnv = (input, state, expansionEnv = {}, credentialFile = null,
412
422
 
413
423
  const absoluteToolDeny = (path) => `Read(/${path}/**)`;
414
424
 
425
+ // D245: Claude Code matches permission paths in POSIX form on Windows
426
+ // (C:\Users\ada becomes /c/Users/ada), so an absolute deny written from a
427
+ // Windows path would match nothing.
428
+ const permissionPath = (path, platform) => (platform === 'win32'
429
+ ? path.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`)
430
+ : path);
431
+
415
432
  // The tools that read or write the seat's agent-wide memory.
416
433
  const MEMORY_TOOLS = Object.freeze(['commonly_read_agent_memory', 'commonly_write_agent_memory', 'commonly_save_my_memory']);
417
434
 
418
- const buildPublicClaudePolicyArgs = (mcpToolPatterns, { trust, allow = [], memoryWithheld = false } = {}) => {
419
- const home = homedir();
435
+ const buildPublicClaudePolicyArgs = (mcpToolPatterns, {
436
+ trust, allow = [], memoryWithheld = false, platform = process.platform, home: homeOverride = null,
437
+ noShell = false,
438
+ } = {}) => {
439
+ const home = homeOverride || homedir();
440
+ const deny = (sub) => absoluteToolDeny(permissionPath(join(home, sub), platform));
420
441
  // `allow` is already narrowed to a workspace seat's grants (workspaceGrants).
421
- const granted = allow.flatMap((grant) => WORKSPACE_ALLOW_TOOLS[grant] || []);
442
+ // D245: with no OS jail (native Windows, --unsandboxed) a shell would run
443
+ // with the user's whole access and could read the seat's own token from its
444
+ // environment, so `write` grants edits only there.
445
+ // With no jail the edit tools are also scoped to the working folder: a bare
446
+ // Edit/Write could reach the user's profile (a Startup script, ~/.claude).
447
+ const granted = allow.flatMap((grant) => WORKSPACE_ALLOW_TOOLS[grant] || [])
448
+ .filter((tool) => !(noShell && SHELL_TOOLS.has(tool)))
449
+ .map((tool) => (noShell && (tool === 'Edit' || tool === 'Write') ? `${tool}(./**)` : tool));
422
450
  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')),
451
+ deny('.commonly'),
452
+ deny('.claude'),
453
+ deny('.codex'),
454
+ deny('.ssh'),
455
+ deny('.aws'),
456
+ deny('.config'),
429
457
  'Read(//private/tmp/**)',
430
458
  'Read(./.commonly/**)',
431
459
  'Read(./.codex/**)',
@@ -465,7 +493,7 @@ const buildPublicClaudePolicyArgs = (mcpToolPatterns, { trust, allow = [], memor
465
493
  '--no-chrome',
466
494
  '--permission-mode', 'dontAsk',
467
495
  '--allowedTools', 'Read(./**)', ...granted, ...mcpToolPatterns,
468
- '--disallowedTools', ...PUBLIC_DENIED_TOOLS.filter((tool) => !granted.includes(tool)),
496
+ '--disallowedTools', ...PUBLIC_DENIED_TOOLS.filter((tool) => !granted.some((g) => g === tool || g.startsWith(`${tool}(`))),
469
497
  ...deniedReads, ...workspaceDenies, ...memoryDenies, ...withheldDenies,
470
498
  ];
471
499
  };
@@ -638,11 +666,15 @@ const runClaude = ({
638
666
  cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false,
639
667
  transcriptPath = null, firstOutputMs = firstOutputWindowMs(), lockProbe, stallKillGraceMs = STALL_KILL_GRACE_MS,
640
668
  processTable = readProcessTable, survivorIo, modelStream = true, turnReap = turnReapEnabled(),
669
+ commandResolution = {},
641
670
  }) => new Promise((resolve, reject) => {
642
671
  // Every process the turn starts inherits this marker however it detaches,
643
672
  // so what it leaves running is stopped once the turn has closed.
644
673
  const turn = markTurn(env ?? process.env, { enabled: turnReap });
645
- const proc = spawnInOwnGroup(spawnImpl, cmd, args, {
674
+ // On Windows an npm `claude.cmd` is read, not run through cmd.exe: the
675
+ // prompt in `args` is pod content (#2141). Off Windows this is identity.
676
+ const launch = resolveLaunch(cmd, args, { env: turn.env, ...commandResolution });
677
+ const proc = spawnInOwnGroup(spawnImpl, launch.command, launch.args, {
646
678
  cwd,
647
679
  env: turn.env,
648
680
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -871,7 +903,11 @@ const buildMcpConfig = (mcpServers, ctx = {}) => {
871
903
  const entry = { type: server.transport || 'stdio' };
872
904
  if (server.url) entry.url = server.url;
873
905
  if (spawnCommand) {
874
- const [command, ...args] = spawnCommand;
906
+ // Win32 only: claude spawns this without a shell, so an `npx.cmd` is
907
+ // resolved to its target here (#2141). Identity on every other platform.
908
+ const [command, ...args] = resolveMcpCommand(spawnCommand, ctx.commandResolution, {
909
+ label: 'claude', name: server.name,
910
+ });
875
911
  entry.command = command;
876
912
  if (args.length) entry.args = args;
877
913
  }
@@ -966,11 +1002,14 @@ const createMcpConfig = async (mcpServers, ctx = {}) => {
966
1002
  const withClaudePath = (input) => {
967
1003
  const output = { ...(input || process.env) };
968
1004
  const localBin = join(homedir(), '.local', 'bin');
969
- const entries = String(output.PATH || '')
1005
+ // Windows keys are case-insensitive, and a child given both `Path` and a
1006
+ // new `PATH` keeps only one of them, so update the key that is already there.
1007
+ const key = Object.keys(output).find((k) => k.toUpperCase() === 'PATH') || 'PATH';
1008
+ const entries = String(output[key] || '')
970
1009
  .split(delimiter)
971
1010
  .filter(Boolean);
972
1011
  if (!entries.includes(localBin)) entries.push(localBin);
973
- output.PATH = entries.join(delimiter);
1012
+ output[key] = entries.join(delimiter);
974
1013
  return output;
975
1014
  };
976
1015
 
@@ -980,6 +1019,10 @@ const withClaudePath = (input) => {
980
1019
  // PATH but cannot reach the user's `~/.local/bin` without an absolute path
981
1020
  // argv[0], even when that directory is bound read-only into the sandbox.
982
1021
  const resolveClaudePath = (env = process.env) => {
1022
+ // Windows has no `which`; the shim-aware resolver finds the same file.
1023
+ if (process.platform === 'win32') {
1024
+ return resolveWindowsCommandWithReason('claude', { env: withClaudePath(env) }).source || 'claude';
1025
+ }
983
1026
  // Defensive: spawnSync can return undefined under aggressive mocks (the
984
1027
  // adapters.claude.environment.test.mjs suite stubs child_process so no real
985
1028
  // process runs). Treat any failure mode as "use the bare command name."
@@ -1022,6 +1065,16 @@ const claudeInstallRefusal = (claudeBin) => new Error(
1022
1065
  + `\`${claudeBin}\` could not be resolved`,
1023
1066
  );
1024
1067
 
1068
+ /**
1069
+ * D245: native Windows has no OS jail, and the user chose to run without one
1070
+ * (`--unsandboxed`). Read from the spawn context only, set by `agent run`, never
1071
+ * from the environment. The seat keeps its trust, so everything the trust
1072
+ * brings except the jail still applies.
1073
+ */
1074
+ const policyOnlyHost = (ctx) => ctx.hostUnconfined === true
1075
+ && (ctx._platform || process.platform) === 'win32'
1076
+ && isConfinedTrust(ctx.environment?.sandbox?.trust);
1077
+
1025
1078
  const prepareArgv = async (innerArgv, ctx) => {
1026
1079
  const env = ctx.environment;
1027
1080
  const claudeEnv = withClaudePath(ctx.claudeEnv);
@@ -1078,6 +1131,22 @@ const prepareArgv = async (innerArgv, ctx) => {
1078
1131
  }
1079
1132
  })
1080
1133
  : [];
1134
+ if (policyOnlyHost(ctx)) {
1135
+ // The bwrap branch's policy, without the jail: on a host with no OS
1136
+ // isolation the tool policy is the whole boundary, so it is never dropped.
1137
+ innerArgv = [
1138
+ ...innerArgv,
1139
+ ...buildPublicClaudePolicyArgs(allowedPatterns, {
1140
+ ...(sandboxTrust === 'workspace' ? { trust: sandboxTrust, allow: grants } : {}),
1141
+ memoryWithheld: ctx.memoryWithheld === true,
1142
+ platform: ctx._platform || process.platform,
1143
+ home: ctx._home || null,
1144
+ noShell: true,
1145
+ }),
1146
+ ...(ctx.publicSkillArgs || []),
1147
+ ];
1148
+ return { cmd: 'claude', args: innerArgv, env: claudeEnv };
1149
+ }
1081
1150
  // A public trust MUST resolve to an enforced mode. Absent mode is resolved
1082
1151
  // above; anything else that lands here (the literal 'none', a typo, a
1083
1152
  // non-string) used to fall through to the bare `claude` spawn below — an
@@ -1230,10 +1299,19 @@ export default {
1230
1299
 
1231
1300
  inventory: () => claudeRuntimeInventory({ env: withClaudePath(process.env) }),
1232
1301
 
1233
- async detect() {
1302
+ // `opts` is a test seam only: `_commandResolution` overrides the Windows
1303
+ // resolver's platform/env/fs. Production calls detect() with no argument.
1304
+ async detect(opts = {}) {
1234
1305
  try {
1235
1306
  const claudeEnv = withClaudePath(process.env);
1236
- const res = spawnSync('claude', ['--version'], {
1307
+ const resolution = { env: claudeEnv, ...opts._commandResolution };
1308
+ const located = resolveWindowsCommandWithReason('claude', resolution);
1309
+ if (!located.command) {
1310
+ // Windows only: a `claude.cmd` this CLI cannot read safely, or none.
1311
+ process.stderr.write(`claude adapter: ${located.reason}\n`);
1312
+ return null;
1313
+ }
1314
+ const res = spawnSync(located.command, [...located.args, '--version'], {
1237
1315
  encoding: 'utf8', timeout: 5000,
1238
1316
  env: claudeEnv,
1239
1317
  });
@@ -1242,7 +1320,8 @@ export default {
1242
1320
  const version = (res.stdout || '').trim().split(/\s+/)[0] || 'unknown';
1243
1321
  // Best-effort resolve of the binary path for clearer UX ("claude detected
1244
1322
  // at /usr/local/bin/claude"). Falls back to the bare command name on
1245
- // platforms without `which` (e.g. Windows).
1323
+ // platforms without `which`. On Windows the resolver already found it.
1324
+ if (located.source) return { path: located.source, version };
1246
1325
  const where = spawnSync('which', ['claude'], {
1247
1326
  encoding: 'utf8',
1248
1327
  env: claudeEnv,
@@ -1342,11 +1421,13 @@ export default {
1342
1421
  let mcpConfig = null;
1343
1422
  let publicClaudeState = null;
1344
1423
  let publicSkillArgs = [];
1345
- const publicNativeSandbox = publicTrust
1424
+ // D245: no jail to prepare for on a policy-only Windows host.
1425
+ const jailed = !policyOnlyHost(ctx);
1426
+ const publicNativeSandbox = jailed && publicTrust
1346
1427
  && PUBLIC_SANDBOX_MODES.has(resolvePublicSandboxMode(ctx.environment?.sandbox));
1347
- const publicBwrapSandbox = publicTrust
1428
+ const publicBwrapSandbox = jailed && publicTrust
1348
1429
  && resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
1349
- const workspaceBwrapSandbox = ctx.environment?.sandbox?.trust === 'workspace'
1430
+ const workspaceBwrapSandbox = jailed && ctx.environment?.sandbox?.trust === 'workspace'
1350
1431
  && resolvePublicSandboxMode(ctx.environment?.sandbox) === 'bwrap';
1351
1432
  try {
1352
1433
  // Trust is normalized at entry (legacy `internal` reads as public). The
@@ -1383,6 +1464,7 @@ export default {
1383
1464
  instanceUrl: ctx.instanceUrl,
1384
1465
  agentName: ctx.agentName,
1385
1466
  publicSeat: publicTrust,
1467
+ commandResolution: { env: withClaudePath(ctx.env), ...ctx._commandResolution },
1386
1468
  });
1387
1469
  }
1388
1470
  if (mcpConfig?.unavailableMcp.length) throw publicMcpUnavailableError(mcpConfig.unavailableMcp);
@@ -1392,6 +1474,7 @@ export default {
1392
1474
  mcpConfig?.expansionEnv,
1393
1475
  mcpConfig?.credential?.path || null,
1394
1476
  publicTrust,
1477
+ ctx._platform || process.platform,
1395
1478
  );
1396
1479
  if ((publicNativeSandbox || publicBwrapSandbox) && ctx.commitAttributionEnv) {
1397
1480
  Object.assign(claudeEnv, ctx.commitAttributionEnv);
@@ -1427,6 +1510,7 @@ export default {
1427
1510
  processTable: ctx._processTable,
1428
1511
  survivorIo: ctx._survivorIo,
1429
1512
  turnReap: ctx._turnReap,
1513
+ commandResolution: ctx._commandResolution, // test seam only
1430
1514
  });
1431
1515
  // TASK-440: the context this turn left, read from the transcript the
1432
1516
  // child wrote under its own env (an isolated HOME for public seats).
@@ -1472,6 +1556,7 @@ export default {
1472
1556
  mcpConfig?.expansionEnv,
1473
1557
  mcpConfig?.credential?.path || null,
1474
1558
  publicTrust,
1559
+ ctx._platform || process.platform,
1475
1560
  ),
1476
1561
  };
1477
1562
  if ((publicNativeSandbox || publicBwrapSandbox) && ctx.commitAttributionEnv) {
@@ -1496,6 +1581,7 @@ export default {
1496
1581
  processTable: ctx._processTable,
1497
1582
  survivorIo: ctx._survivorIo,
1498
1583
  turnReap: ctx._turnReap,
1584
+ commandResolution: ctx._commandResolution, // test seam only
1499
1585
  });
1500
1586
  const contextTokens = claudeContextTokens({
1501
1587
  cwd: physicalPath(ctx.cwd), sessionId: freshId,
@@ -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 = '';