@commonlyai/cli 0.1.231 → 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.
@@ -0,0 +1,16 @@
1
+ // Public seats run only on a harness version smoke-tested in its sandbox, with
2
+ // one tolerance: a stable patch release after the newest tested one, on that
3
+ // same major.minor line. Harnesses ship patches every few days, and refusing
4
+ // them refused every new BYO user on the current npm release (D245, 11-04).
5
+ // A newer minor or major, an older patch, an older line, or a prerelease still
6
+ // refuses until it is smoke-tested and added to the adapter's list.
7
+ const STABLE = /^(\d+)\.(\d+)\.(\d+)$/;
8
+
9
+ export const isPatchAfterNewestTested = (version, tested) => {
10
+ const installed = STABLE.exec(String(version || ''));
11
+ const newest = STABLE.exec(String(tested[tested.length - 1] || ''));
12
+ if (!installed || !newest) return false;
13
+ return Number(installed[1]) === Number(newest[1])
14
+ && Number(installed[2]) === Number(newest[2])
15
+ && Number(installed[3]) > Number(newest[3]);
16
+ };
@@ -0,0 +1,256 @@
1
+ /**
2
+ * D245: the Windows form of `commonly daemon install` (ADR-026 D1).
3
+ *
4
+ * macOS has launchd and Linux has systemd; Windows has Task Scheduler. A
5
+ * per-user task needs no administrator, so the install stays a one-liner a
6
+ * person can run in their own PowerShell. The shape mirrors the other two:
7
+ *
8
+ * - The task starts at this user's logon (a LogonTrigger naming the user; a
9
+ * trigger for ANY user's logon needs an administrator).
10
+ * - Keep-alive lives in the launcher, a PowerShell script that loops on
11
+ * `node <cli> daemon run --foreground`: it restarts on every exit except 77
12
+ * and 78, the same codes `RestartPreventExitStatus=77 78` exempts on systemd
13
+ * (a revoked key and a bad local registration). Task Scheduler's own
14
+ * "restart on failure" fires when a task fails to START, not when its
15
+ * program exits non-zero, so it cannot be the keep-alive.
16
+ * - A one-minute repeating trigger with `IgnoreNew` is the watchdog for the
17
+ * launcher itself: while the task runs it starts nothing, and if the
18
+ * launcher is killed the next minute brings it back.
19
+ * - On 77/78 the launcher disables the task before it exits, so the watchdog
20
+ * does not restart a daemon that has been told to stop. That is launchd's
21
+ * "park on a successful exit" in Task Scheduler's terms; `daemon start` and
22
+ * `daemon restart` enable it again.
23
+ *
24
+ * A task cannot carry environment variables, so the provider keys the seats
25
+ * need (TASK-049) go in a JSON file the launcher loads before it starts node.
26
+ * The file is written through the same 0600-at-creation writer as the unit
27
+ * files, and its ACL is then narrowed to this user (best effort; the profile
28
+ * directory is already private by default).
29
+ *
30
+ * Windows has no Seatbelt or bwrap, so a daemon-run seat here is unconfined.
31
+ * That is stated on the BYO page and in docs/agents/daemon-service-windows.md.
32
+ */
33
+
34
+ import { join } from 'path';
35
+
36
+ export const WINDOWS_TASK_NAME = 'CommonlyDaemon';
37
+
38
+ export const windowsServicePaths = (home) => {
39
+ const dir = join(home, '.commonly', 'daemon', 'service');
40
+ return {
41
+ kind: 'schtasks',
42
+ // The task definition doubles as the "is it installed" record, the way the
43
+ // plist and the unit file do on the other two platforms.
44
+ file: join(dir, 'commonly-daemon-task.xml'),
45
+ launcher: join(dir, 'commonly-daemon.ps1'),
46
+ envFile: join(dir, 'service-env.json'),
47
+ taskName: WINDOWS_TASK_NAME,
48
+ };
49
+ };
50
+
51
+ const xmlEscape = (value) => String(value)
52
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
53
+ .replace(/"/g, '&quot;');
54
+
55
+ // A PowerShell single-quoted string ends at ANY of the four single-quote
56
+ // characters it recognises (' and the curly ‘ ’ ‚ ‛), and each is escaped by
57
+ // doubling. A user named O'Brien, or a path with a curly quote in it, would
58
+ // otherwise close the string and run the rest of the path as script.
59
+ export const psQuote = (value) => `'${String(value).replace(/['‘’‚‛]/g, (q) => q + q)}'`;
60
+
61
+ export const windowsLauncherScript = ({ nodePath, cliPath, logPath, envFile, taskName = WINDOWS_TASK_NAME }) => `# Commonly daemon launcher. Written by \`commonly daemon install\`; re-run that command instead of editing this file.
62
+ # It loads the service environment, then keeps \`commonly daemon run\` alive: every exit restarts it after 5 s,
63
+ # except 77 (this computer's key was revoked) and 78 (no valid local registration), which disable the task and stop.
64
+ $ErrorActionPreference = 'Continue'
65
+ $node = ${psQuote(nodePath)}
66
+ $cli = ${psQuote(cliPath)}
67
+ $log = ${psQuote(logPath)}
68
+ $envFile = ${psQuote(envFile)}
69
+ $task = ${psQuote(taskName)}
70
+ if (Test-Path -LiteralPath $envFile) {
71
+ $cfg = Get-Content -LiteralPath $envFile -Raw -Encoding UTF8 | ConvertFrom-Json
72
+ foreach ($p in $cfg.PSObject.Properties) { [Environment]::SetEnvironmentVariable($p.Name, [string]$p.Value, 'Process') }
73
+ }
74
+ $env:Path = (Split-Path -Parent $node) + ';' + $env:Path
75
+ # Ending the task kills this script, not the node it started. The daemon watches this pid and
76
+ # exits (stopping its seats) once it is gone, so stop, restart and reinstall leave no daemon behind.
77
+ $env:COMMONLY_DAEMON_LAUNCHER_PID = "$PID"
78
+ # Read node's output as UTF-8; Windows PowerShell 5.1 otherwise decodes it in the console code page.
79
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
80
+ while ($true) {
81
+ & $node $cli daemon run --foreground 2>&1 | ForEach-Object { "$_" } | Add-Content -LiteralPath $log -Encoding UTF8
82
+ $code = $LASTEXITCODE
83
+ if ($code -eq 77 -or $code -eq 78) {
84
+ & schtasks.exe /Change /TN $task /DISABLE | Out-Null
85
+ if ($LASTEXITCODE -eq 0) { exit $code }
86
+ # The task could not be disabled, so exiting would let the watchdog start
87
+ # it again within a minute. Park here instead; stop or uninstall ends it.
88
+ "commonly daemon exited $code and the task could not be disabled; parked" | Add-Content -LiteralPath $log -Encoding UTF8
89
+ while ($true) { Start-Sleep -Seconds 3600 }
90
+ }
91
+ Start-Sleep -Seconds 5
92
+ }
93
+ `;
94
+
95
+ // UTF-8 with a BOM: Windows PowerShell 5.1 reads a BOM-less .ps1 in the ANSI
96
+ // code page, which would mangle a non-ASCII user name in any of the paths.
97
+ export const encodeLauncher = (script) => Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(script, 'utf8')]);
98
+
99
+ export const POWERSHELL = '%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
100
+
101
+ export const windowsTaskXml = ({ launcher, userId, home }) => `<?xml version="1.0" encoding="UTF-16"?>
102
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
103
+ <RegistrationInfo>
104
+ <Description>Commonly local agent daemon (ADR-026). Installed by: commonly daemon install</Description>
105
+ </RegistrationInfo>
106
+ <Triggers>
107
+ <LogonTrigger>
108
+ <Enabled>true</Enabled>
109
+ <UserId>${xmlEscape(userId)}</UserId>
110
+ </LogonTrigger>
111
+ <TimeTrigger>
112
+ <Repetition>
113
+ <Interval>PT1M</Interval>
114
+ <StopAtDurationEnd>false</StopAtDurationEnd>
115
+ </Repetition>
116
+ <StartBoundary>2026-01-01T00:00:00</StartBoundary>
117
+ <Enabled>true</Enabled>
118
+ </TimeTrigger>
119
+ </Triggers>
120
+ <Principals>
121
+ <Principal id="Author">
122
+ <UserId>${xmlEscape(userId)}</UserId>
123
+ <LogonType>InteractiveToken</LogonType>
124
+ <RunLevel>LeastPrivilege</RunLevel>
125
+ </Principal>
126
+ </Principals>
127
+ <Settings>
128
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
129
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
130
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
131
+ <AllowHardTerminate>true</AllowHardTerminate>
132
+ <StartWhenAvailable>true</StartWhenAvailable>
133
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
134
+ <IdleSettings>
135
+ <StopOnIdleEnd>false</StopOnIdleEnd>
136
+ <RestartOnIdle>false</RestartOnIdle>
137
+ </IdleSettings>
138
+ <AllowStartOnDemand>true</AllowStartOnDemand>
139
+ <Enabled>true</Enabled>
140
+ <Hidden>false</Hidden>
141
+ <RunOnlyIfIdle>false</RunOnlyIfIdle>
142
+ <WakeToRun>false</WakeToRun>
143
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
144
+ <Priority>7</Priority>
145
+ </Settings>
146
+ <Actions Context="Author">
147
+ <Exec>
148
+ <Command>${xmlEscape(POWERSHELL)}</Command>
149
+ <Arguments>${xmlEscape(`-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "${launcher}"`)}</Arguments>
150
+ <WorkingDirectory>${xmlEscape(home)}</WorkingDirectory>
151
+ </Exec>
152
+ </Actions>
153
+ </Task>
154
+ `;
155
+
156
+ // schtasks reads a task file as UTF-16; the declaration above says so, and the
157
+ // bytes must agree with it (little-endian, with the BOM).
158
+ export const encodeTaskXml = (xml) => Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(xml, 'utf16le')]);
159
+
160
+ // Task Scheduler names a user DOMAIN\user. A bare name resolves too, but the
161
+ // qualified one cannot be mistaken for a same-named account on the domain.
162
+ export const windowsUserId = ({ env = process.env, lookupUsername }) => {
163
+ if (env.USERNAME) return env.USERDOMAIN ? `${env.USERDOMAIN}\\${env.USERNAME}` : env.USERNAME;
164
+ return lookupUsername();
165
+ };
166
+
167
+ export const installWindowsService = async ({
168
+ home, nodePath, cliPath, logPath, providerEnv, env, writeFile, execCmd, lookupUsername, warn,
169
+ }) => {
170
+ const target = windowsServicePaths(home);
171
+ const userId = windowsUserId({ env, lookupUsername });
172
+ writeFile(target.envFile, `${JSON.stringify(Object.fromEntries(providerEnv), null, 2)}\n`);
173
+ try {
174
+ await execCmd(['icacls', target.envFile, '/inheritance:r', '/grant:r', `${userId}:F`]);
175
+ } catch (error) {
176
+ warn(`Could not narrow the permissions on ${target.envFile} (${error.message}). It holds your provider keys; it sits in your profile, which only you can read by default.`);
177
+ }
178
+ writeFile(target.launcher, encodeLauncher(windowsLauncherScript({ nodePath, cliPath, logPath, envFile: target.envFile })));
179
+ writeFile(target.file, encodeTaskXml(windowsTaskXml({ launcher: target.launcher, userId, home })));
180
+ // A running launcher keeps the environment it started with, so end it first
181
+ // (an absent task fails by design) and start the new definition after.
182
+ await execCmd(['schtasks', '/End', '/TN', target.taskName]).catch(() => {});
183
+ await execCmd(['schtasks', '/Create', '/TN', target.taskName, '/XML', target.file, '/F']);
184
+ await execCmd(['schtasks', '/Run', '/TN', target.taskName]);
185
+ return target;
186
+ };
187
+
188
+ export const LAUNCHER_PID_ENV = 'COMMONLY_DAEMON_LAUNCHER_PID';
189
+
190
+ const pidAlive = (pid) => {
191
+ try {
192
+ process.kill(pid, 0);
193
+ return true;
194
+ } catch (error) {
195
+ return error.code === 'EPERM';
196
+ }
197
+ };
198
+
199
+ /**
200
+ * `schtasks /End` terminates the launcher only; the node it started would
201
+ * keep running, unsupervised and invisible to the task (seen on windows-latest,
202
+ * 11-04: a stopped task left its daemon checking in). A daemon started by the
203
+ * launcher therefore polls the launcher's pid and calls `onGone` once it has
204
+ * exited. Returns a stop function; does nothing outside the launcher.
205
+ */
206
+ export const watchWindowsLauncher = ({
207
+ env = process.env,
208
+ platform = process.platform,
209
+ isAlive = pidAlive,
210
+ onGone,
211
+ everyMs = 2000,
212
+ setIntervalFn = setInterval,
213
+ clearIntervalFn = clearInterval,
214
+ streams = [process.stdout, process.stderr],
215
+ }) => {
216
+ const pid = Number(env[LAUNCHER_PID_ENV]);
217
+ if (platform !== 'win32' || !Number.isInteger(pid) || pid <= 0) return () => {};
218
+ // The launcher holds this process's stdout and stderr. Once it exits, any
219
+ // log line (a poll, a heartbeat) fails with EPIPE, and an unhandled stream
220
+ // error would end the daemon before it stops its seats. So the streams
221
+ // swallow errors from the moment the watch is armed, not only after the
222
+ // launcher's exit is noticed up to two seconds later.
223
+ for (const stream of streams) stream?.on?.('error', () => {});
224
+ const timer = setIntervalFn(() => {
225
+ if (isAlive(pid)) return;
226
+ clearIntervalFn(timer);
227
+ onGone(pid);
228
+ }, everyMs);
229
+ if (typeof timer?.unref === 'function') timer.unref();
230
+ return () => clearIntervalFn(timer);
231
+ };
232
+
233
+ export const uninstallWindowsService = async ({ home, execCmd, removeFile, existsFile }) => {
234
+ const target = windowsServicePaths(home);
235
+ await execCmd(['schtasks', '/End', '/TN', target.taskName]).catch(() => {});
236
+ await execCmd(['schtasks', '/Delete', '/TN', target.taskName, '/F']).catch(() => {});
237
+ for (const file of [target.file, target.launcher, target.envFile]) {
238
+ if (existsFile(file)) removeFile(file);
239
+ }
240
+ return target;
241
+ };
242
+
243
+ export const windowsServiceAction = async ({ action, home, execCmd }) => {
244
+ const target = windowsServicePaths(home);
245
+ const name = target.taskName;
246
+ if (action === 'stop') {
247
+ // Disabled first, so the one-minute watchdog cannot start it again.
248
+ await execCmd(['schtasks', '/Change', '/TN', name, '/DISABLE']);
249
+ await execCmd(['schtasks', '/End', '/TN', name]).catch(() => {});
250
+ return target;
251
+ }
252
+ if (action === 'restart') await execCmd(['schtasks', '/End', '/TN', name]).catch(() => {});
253
+ await execCmd(['schtasks', '/Change', '/TN', name, '/ENABLE']);
254
+ await execCmd(['schtasks', '/Run', '/TN', name]);
255
+ return target;
256
+ };
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * ADR-026 D1: install-once service registration for the resident daemon.
3
3
  *
4
- * macOS gets a launchd LaunchAgent, Linux a systemd user unit. Both run the
4
+ * macOS gets a launchd LaunchAgent, Linux a systemd user unit, Windows a
5
+ * per-user Task Scheduler task (daemon-service-windows.js). All three run the
5
6
  * exact interpreter + CLI entry that executed `daemon install`, resolved at
6
7
  * install time — a PATH lookup at boot would race version managers and
7
8
  * silently run a different install than the one the user tested.
@@ -14,17 +15,20 @@ import { randomBytes } from 'crypto';
14
15
  import { existsSync, renameSync, unlinkSync, writeFileSync } from 'fs';
15
16
  import { homedir, userInfo } from 'os';
16
17
  import { join, dirname, resolve } from 'path';
18
+ import {
19
+ installWindowsService, uninstallWindowsService, windowsServiceAction, windowsServicePaths,
20
+ } from './daemon-service-windows.js';
17
21
 
18
22
  export const LAUNCHD_LABEL = 'me.commonly.daemon';
19
23
  export const SYSTEMD_UNIT = 'commonly-daemon.service';
20
24
 
21
25
  export const daemonLogPath = (home = homedir()) => join(home, '.commonly', 'logs', 'daemon', 'daemon.log');
22
26
 
23
- export const servicePaths = (platform = process.platform, home = homedir()) => (
24
- platform === 'darwin'
25
- ? { kind: 'launchd', file: join(home, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`) }
26
- : { kind: 'systemd', file: join(home, '.config', 'systemd', 'user', SYSTEMD_UNIT) }
27
- );
27
+ export const servicePaths = (platform = process.platform, home = homedir()) => {
28
+ if (platform === 'darwin') return { kind: 'launchd', file: join(home, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`) };
29
+ if (platform === 'win32') return windowsServicePaths(home);
30
+ return { kind: 'systemd', file: join(home, '.config', 'systemd', 'user', SYSTEMD_UNIT) };
31
+ };
28
32
 
29
33
  const xmlEscape = (value) => String(value)
30
34
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -287,7 +291,22 @@ export const installDaemonService = async ({
287
291
  const missing = missingProviderKeys({ names: providerKeyEnvNames, env });
288
292
 
289
293
  let startsAtBoot = false;
290
- if (target.kind === 'launchd') {
294
+ if (target.kind === 'schtasks') {
295
+ // A logon task, like a LaunchAgent: it starts when this user logs in.
296
+ mkdirp(dirname(target.launcher));
297
+ await installWindowsService({
298
+ home,
299
+ nodePath,
300
+ cliPath,
301
+ logPath: daemonLogPath(home),
302
+ providerEnv,
303
+ env,
304
+ writeFile,
305
+ execCmd,
306
+ lookupUsername,
307
+ warn,
308
+ });
309
+ } else if (target.kind === 'launchd') {
291
310
  // The mode and the atomicity are the WRITER's, not an ordering rule here:
292
311
  // `writeServiceFile` creates a 0600 sibling and renames it over the target,
293
312
  // so no chmod is needed on this path and no half-written unit is ever
@@ -335,6 +354,11 @@ export const uninstallDaemonService = async ({
335
354
  log('No installed daemon service found.');
336
355
  return null;
337
356
  }
357
+ if (target.kind === 'schtasks') {
358
+ await uninstallWindowsService({ home, execCmd, removeFile, existsFile });
359
+ log(`Removed the ${target.taskName} scheduled task and its files (${dirname(target.file)}).`);
360
+ return target;
361
+ }
338
362
  if (target.kind === 'launchd') {
339
363
  await execCmd(['launchctl', 'unload', '-w', target.file]).catch(() => {});
340
364
  } else {
@@ -352,6 +376,7 @@ const serviceAction = async ({
352
376
  execCmd,
353
377
  }) => {
354
378
  const target = servicePaths(platform, home);
379
+ if (target.kind === 'schtasks') return windowsServiceAction({ action, home, execCmd });
355
380
  if (target.kind === 'launchd') {
356
381
  if (action === 'start') {
357
382
  // `install` already loads the plist. A second `start` should be
@@ -64,14 +64,19 @@ const validateRecord = (record) => {
64
64
  return record;
65
65
  };
66
66
 
67
- export const loadDaemonRecord = () => {
67
+ export const loadDaemonRecord = ({ platform = process.platform } = {}) => {
68
68
  const path = daemonRecordPath();
69
69
  const dir = daemonDir();
70
70
  if (!existsSync(path)) return null;
71
- if ((statSync(dir).mode & 0o077) !== 0) {
71
+ // D245: Windows has no POSIX mode bits. Node reports every file there as
72
+ // 0o666 (0o444 when read-only) whatever its ACL, so this check refused every
73
+ // registration on Windows. Access there is the ACL's job; the record sits in
74
+ // the user's profile, which only that user (and administrators) can read.
75
+ const posix = platform !== 'win32';
76
+ if (posix && (statSync(dir).mode & 0o077) !== 0) {
72
77
  throw new Error(`Daemon credential directory permissions are insecure at ${dir}. Set them to 0700 before running the daemon.`);
73
78
  }
74
- if ((statSync(path).mode & 0o077) !== 0) {
79
+ if (posix && (statSync(path).mode & 0o077) !== 0) {
75
80
  throw new Error(`Daemon credential file permissions are insecure at ${path}. Set them to 0600 before running the daemon.`);
76
81
  }
77
82
  try {
@@ -65,6 +65,9 @@ export const createDaemonSupervisor = ({
65
65
  loadToken, // (agentName) => token record | null
66
66
  saveToken, // (agentName, record) => void
67
67
  runtimeInventory = async () => undefined,
68
+ // Machine-level facts for the check-in, read locally each beat (D245: the
69
+ // Windows unsandboxed opt-in). Never derived from anything the server sent.
70
+ machineReport = () => ({}),
68
71
  resolveAdapter, // async (runtime) => adapter name for THIS machine
69
72
  persistState = () => {}, // (agentStates) => void; must not persist secrets
70
73
  log = () => {},
@@ -555,7 +558,11 @@ export const createDaemonSupervisor = ({
555
558
  const heartbeat = async () => {
556
559
  if (stopped || now() < reconnectAfter) return;
557
560
  try {
558
- await client.post(`/api/machines/${record.machineDbId}/heartbeat`, { agents: agentStates(), runtimeInventory: await runtimeInventory() });
561
+ await client.post(`/api/machines/${record.machineDbId}/heartbeat`, {
562
+ ...machineReport(),
563
+ agents: agentStates(),
564
+ runtimeInventory: await runtimeInventory(),
565
+ });
559
566
  connectionRestored();
560
567
  } catch (error) {
561
568
  connectionFailure(error, 'heartbeat failed');
@@ -184,13 +184,21 @@ export const confinementReason = (environment, adapter, platform = process.platf
184
184
  const declared = sandbox;
185
185
  if (declared.mode === 'none') return 'sandbox_mode_none';
186
186
  if (effectiveSandboxTrust(declared.trust) !== 'public') return 'sandbox_trust_not_public';
187
+ // D245: native Windows has no OS jail for any adapter. Claude there runs
188
+ // only by the user's --unsandboxed (policy without a jail), and Codex runs on
189
+ // its own profiles with no Commonly sandbox, so neither carries a grant.
190
+ if (platform === 'win32') return 'sandbox_mode_unenforceable';
187
191
  const mode = resolvePublicSandboxMode(declared, platform);
188
192
  if (typeof mode !== 'string' || !ENFORCING_MODES.has(mode)) return 'sandbox_mode_unenforceable';
189
193
  return null;
190
194
  };
191
195
 
192
- const detailFor = (reason, adapter, environment) => {
196
+ const detailFor = (reason, adapter, environment, platform = process.platform) => {
193
197
  const drop = 'or drop the grant broker from this seat';
198
+ if (reason === 'sandbox_mode_unenforceable' && platform === 'win32') {
199
+ return 'native Windows has no sandbox Commonly can enforce, so no agent on this computer gets connector grants;'
200
+ + ` run the agent on macOS or Linux to use grants, ${drop}`;
201
+ }
194
202
  if (reason === 'adapter_cannot_confine') {
195
203
  return `the seat runs the '${adapter || 'unresolved'}' adapter, which is not approved to receive the grant broker;`
196
204
  + ' move this seat to the claude or codex'
@@ -245,7 +253,7 @@ export const withholdGrantBroker = (environment, adapter, {
245
253
  code: GRANT_BROKER_REFUSAL_CODE,
246
254
  decidedBy: 'daemon',
247
255
  reason,
248
- detail: detailFor(reason, adapter, environment),
256
+ detail: detailFor(reason, adapter, environment, platform),
249
257
  }, withheld);
250
258
  }
251
259
  return { ...environment, mcp: kept };
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
2
2
  import { readFile, stat } from 'node:fs/promises';
3
3
  import { homedir, tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
+ import { resolveWindowsCommandWithReason } from './windows-command.js';
5
6
 
6
7
  export const claudeEffortsFromHelp = (help) => {
7
8
  const section = String(help).match(/--effort\s+<[^>]+>([\s\S]*?)(?=\n\s+--|$)/)?.[1];
@@ -17,8 +18,14 @@ export const codexModelsFromCache = (cache) => {
17
18
  // Only levels supported by every advertised model are offered globally.
18
19
  efforts: levels.length ? levels[0].filter((level) => levels.every((list) => list.includes(level))) : [] };
19
20
  };
20
- export const claudeRuntimeInventory = async ({ spawnSyncImpl = spawnSync, env = process.env } = {}) => {
21
- const result = spawnSyncImpl('claude', ['--help'], {
21
+ export const claudeRuntimeInventory = async ({
22
+ spawnSyncImpl = spawnSync, env = process.env, commandResolution = {},
23
+ } = {}) => {
24
+ // Windows: an npm `claude.cmd` is resolved to its target, never run through
25
+ // a shell (#2141). Elsewhere this is `claude --help`, unchanged.
26
+ const located = resolveWindowsCommandWithReason('claude', { env, ...commandResolution });
27
+ if (!located.command) return { models: null, efforts: [] };
28
+ const result = spawnSyncImpl(located.command, [...located.args, '--help'], {
22
29
  encoding: 'utf8', timeout: 5000, maxBuffer: 128 * 1024, cwd: tmpdir(), env,
23
30
  });
24
31
  return { models: null, efforts: result.status === 0 ? claudeEffortsFromHelp(result.stdout) : [] };
@@ -34,7 +34,13 @@ const expandHome = (p) => {
34
34
  // Never advise `sandbox.mode: none` here: a public seat refuses it, and the
35
35
  // mechanisms that do exist are named instead.
36
36
  const MACOS_MESSAGE = 'bwrap is Linux-only. On macOS a public seat runs under Seatbelt '
37
- + '(sandbox.mode workspace or read-only); Windows has no supported sandbox yet, so use WSL 2.';
37
+ + '(sandbox.mode workspace or read-only).';
38
+ // D245: Windows has no sandbox for agents, and WSL does not give Claude Code
39
+ // one either (host-check.js linuxClaudeMessage), so the only honest way
40
+ // forward named here is the user's own opt-in (sandbox/windows-unsandboxed.js).
41
+ const WINDOWS_MESSAGE = 'bwrap is Linux-only, and native Windows has no sandbox for agents yet. '
42
+ + 'To run this agent without one, with your own access to files and commands, add --unsandboxed.';
43
+ const nonLinuxMessage = (platform) => (platform === 'win32' ? WINDOWS_MESSAGE : MACOS_MESSAGE);
38
44
 
39
45
  const DEFAULT_RO_BINDS = [
40
46
  '/etc/ssl/certs',
@@ -55,7 +61,7 @@ const NAMESPACE_PROBE = ['--unshare-all', '--ro-bind', '/', '/', 'true'];
55
61
 
56
62
  export const detectBwrap = ({ spawn = spawnSync, platform = process.platform } = {}) => {
57
63
  if (platform !== 'linux') {
58
- return { available: false, error: MACOS_MESSAGE };
64
+ return { available: false, error: nonLinuxMessage(platform) };
59
65
  }
60
66
  try {
61
67
  const res = spawn('bwrap', ['--version'], { encoding: 'utf8' });
@@ -98,7 +104,7 @@ export const detectBwrap = ({ spawn = spawnSync, platform = process.platform } =
98
104
  */
99
105
  export const wrapArgvWithBwrap = (innerArgv, env, opts = {}) => {
100
106
  if (process.platform !== 'linux') {
101
- throw new Error(MACOS_MESSAGE);
107
+ throw new Error(nonLinuxMessage(process.platform));
102
108
  }
103
109
  if (!Array.isArray(innerArgv) || innerArgv.length === 0) {
104
110
  throw new Error('wrapArgvWithBwrap requires a non-empty innerArgv');
@@ -21,6 +21,9 @@ import { PUBLIC_SANDBOX_MODES, resolvePublicSandboxMode } from './mode.js';
21
21
  import { detectBwrap as defaultDetectBwrap } from './bwrap.js';
22
22
  import { detectSeatbelt as defaultDetectSeatbelt } from './seatbelt.js';
23
23
 
24
+ // D245: the Windows opt-in (windows-unsandboxed.js re-exports it; defined here to avoid an import cycle).
25
+ export const UNSANDBOXED_FLAG = '--unsandboxed';
26
+
24
27
  // The Seatbelt modes come from mode.js, as grant-broker-guard's do, so a mode
25
28
  // added there is not silently missing here.
26
29
  const SEATBELT_MODES = PUBLIC_SANDBOX_MODES;
@@ -36,7 +39,13 @@ export const windowsSandboxMessage = (adapterName) => {
36
39
  // Code (see linuxClaudeMessage).
37
40
  return [
38
41
  `Commonly runs a connected ${label} inside a sandbox, and has no sandbox for Windows yet,`,
39
- 'so this agent cannot listen for @mentions from native Windows. Instead:',
42
+ 'so by default this agent cannot listen for @mentions from native Windows. You can:',
43
+ // D245: the user's own choice, never a default (sandbox/windows-unsandboxed.js).
44
+ ...(adapterName === 'claude'
45
+ ? [` - Run it without an OS sandbox: add ${UNSANDBOXED_FLAG}`
46
+ + ` (commonly agent run <name> ${UNSANDBOXED_FLAG}, or commonly daemon install ${UNSANDBOXED_FLAG}).`
47
+ + " It keeps Commonly's restricted tool settings and gets no shell, but nothing at the OS level confines it, and it gets no connector grants."]
48
+ : []),
40
49
  ...(adapterName === 'claude'
41
50
  ? [' - Use Commonly from your own Claude Code sessions: the Connect page shows a PowerShell `claude mcp add` command.']
42
51
  : [` - WSL 2 (Ubuntu): install Node and ${label} inside WSL, run \`sudo apt install bubblewrap\`,`
@@ -66,6 +75,9 @@ export const hostSandboxRefusal = ({
66
75
  environment,
67
76
  adapterName,
68
77
  platform = process.platform,
78
+ // D245: the user's own opt-in for this run (windows-unsandboxed.js
79
+ // hostUnconfinedForRun). Never derived from the environment.
80
+ hostUnconfined = false,
69
81
  detectBwrap = defaultDetectBwrap,
70
82
  detectSeatbelt = defaultDetectSeatbelt,
71
83
  } = {}) => {
@@ -77,7 +89,9 @@ export const hostSandboxRefusal = ({
77
89
  const mode = sandbox.mode ?? resolvePublicSandboxMode(sandbox, platform);
78
90
  if (mode !== 'bwrap' && !SEATBELT_MODES.has(mode)) return null;
79
91
 
80
- if (platform === 'win32') return windowsSandboxMessage(adapterName);
92
+ if (platform === 'win32') {
93
+ return hostUnconfined === true && adapterName === 'claude' ? null : windowsSandboxMessage(adapterName);
94
+ }
81
95
  // A workspace claude seat falls through to the bubblewrap probe below; the
82
96
  // probe failing, for any reason, is a refusal like any other adapter's.
83
97
  if (mode === 'bwrap' && adapterName === 'claude' && sandbox.trust !== 'workspace') {