@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 +1 -1
- package/src/commands/agent.js +42 -2
- package/src/commands/daemon.js +54 -7
- package/src/lib/adapters/claude.js +112 -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/enforcement.js +18 -8
- package/src/lib/grant-broker-guard.js +10 -2
- package/src/lib/native-runtime-inventory.js +9 -2
- package/src/lib/reply-receipt.js +53 -0
- 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';
|
|
@@ -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),
|
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) {
|
|
@@ -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, {
|
|
419
|
-
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 = '';
|