@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 +1 -1
- package/src/commands/agent.js +31 -2
- package/src/commands/daemon.js +54 -7
- package/src/lib/adapters/claude.js +108 -22
- package/src/lib/adapters/codex.js +27 -5
- package/src/lib/daemon-service-windows.js +256 -0
- package/src/lib/daemon-service.js +32 -7
- package/src/lib/daemon-store.js +8 -3
- package/src/lib/daemon-supervisor.js +8 -1
- package/src/lib/grant-broker-guard.js +10 -2
- package/src/lib/native-runtime-inventory.js +9 -2
- package/src/lib/sandbox/bwrap.js +9 -3
- package/src/lib/sandbox/host-check.js +16 -2
- package/src/lib/sandbox/windows-unsandboxed.js +131 -0
- package/src/lib/windows-command.js +348 -0
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -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),
|
package/src/commands/daemon.js
CHANGED
|
@@ -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
|
|
437
|
-
.
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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, {
|
|
419
|
-
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 = '';
|