@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.
@@ -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');
@@ -668,6 +668,9 @@ export const deliverChatReply = async ({
668
668
  attachThreshold = 800,
669
669
  uploadName = 'reply.md',
670
670
  log = () => {},
671
+ // D245 item 4: how this reply was made (lib/reply-receipt.js). It rides
672
+ // the first message that actually reaches the pod and no later one.
673
+ receipt = null,
671
674
  }) => {
672
675
  const messagesPath = `/api/agents/runtime/pods/${podId}/messages`;
673
676
  const chunks = splitForChat(text, { limit });
@@ -697,7 +700,14 @@ export const deliverChatReply = async ({
697
700
  // override per delivery; a post that still fails throws to the next rung as
698
701
  // before. Nothing here loops.
699
702
  let freshnessOverride = false;
700
- const postMessage = async (body) => {
703
+ let receiptDelivered = !receipt;
704
+ const postOnce = async (rawBody) => {
705
+ const body = receiptDelivered ? rawBody : { ...rawBody, receipt };
706
+ const response = await postGuarded(body);
707
+ if (response?.refused !== true && response?.skipped !== true) receiptDelivered = true;
708
+ return response;
709
+ };
710
+ const postGuarded = async (body) => {
701
711
  if (freshnessOverride) return client.post(messagesPath, { ...body, continueAnyway: true });
702
712
  try {
703
713
  return await client.post(messagesPath, body);
@@ -774,7 +784,7 @@ export const deliverChatReply = async ({
774
784
  // the gate (found by the fleet's implementation audit).
775
785
  const hasIndivisibleOversize = chunks.some((c) => c.length > attachThreshold);
776
786
  if (chunks.length <= 1 && !hasIndivisibleOversize) {
777
- const response = await postMessage({ content: chunks[0] ?? text });
787
+ const response = await postOnce({ content: chunks[0] ?? text });
778
788
  if (undelivered(response)) return nonDelivery(response, 0, 1);
779
789
  return { mode: 'single', messages: 1 };
780
790
  }
@@ -782,7 +792,7 @@ export const deliverChatReply = async ({
782
792
  let messages = 0;
783
793
  for (const chunk of chunks) {
784
794
  // eslint-disable-next-line no-await-in-loop
785
- const response = await postMessage({ content: chunk }); // in order, so the reply reads top-down
795
+ const response = await postOnce({ content: chunk }); // in order, so the reply reads top-down
786
796
  if (undelivered(response)) return nonDelivery(response, messages, chunks.length);
787
797
  messages += 1;
788
798
  }
@@ -802,7 +812,7 @@ export const deliverChatReply = async ({
802
812
  // fail at the root-id step before any continuation has posted.
803
813
  let posted = 0;
804
814
  try {
805
- const rootRes = await postMessage({ content: chunks[0] });
815
+ const rootRes = await postOnce({ content: chunks[0] });
806
816
  if (undelivered(rootRes)) return nonDelivery(rootRes, 0, chunks.length);
807
817
  posted = 1;
808
818
  // The runtime route answers `res.json(result)` with the created row on
@@ -814,7 +824,7 @@ export const deliverChatReply = async ({
814
824
  if (!rootId) throw new Error('no message id in post response — cannot root the thread');
815
825
  for (const chunk of chunks.slice(1)) {
816
826
  // eslint-disable-next-line no-await-in-loop
817
- const response = await postMessage({ content: chunk, threadRootId: String(rootId) });
827
+ const response = await postOnce({ content: chunk, threadRootId: String(rootId) });
818
828
  if (undelivered(response)) return nonDelivery(response, posted, chunks.length);
819
829
  posted += 1;
820
830
  }
@@ -829,7 +839,7 @@ export const deliverChatReply = async ({
829
839
  log(`thread continuation failed (${err.message}) — posting the remainder top-level`);
830
840
  for (const chunk of chunks.slice(posted)) {
831
841
  // eslint-disable-next-line no-await-in-loop
832
- const response = await postMessage({ content: chunk });
842
+ const response = await postOnce({ content: chunk });
833
843
  if (undelivered(response)) return nonDelivery(response, posted, chunks.length);
834
844
  posted += 1;
835
845
  }
@@ -855,7 +865,7 @@ export const deliverChatReply = async ({
855
865
  const lead = chunks[0] && chunks[0].length <= limit
856
866
  ? chunks[0]
857
867
  : '(reply too large for chat — attached in full)';
858
- const response = await postMessage({ content: `${lead}\n\n${directive}` });
868
+ const response = await postOnce({ content: `${lead}\n\n${directive}` });
859
869
  if (undelivered(response)) return nonDelivery(response, 0, 1);
860
870
  return { mode: 'attach', messages: 1 };
861
871
  } catch (err) {
@@ -863,7 +873,7 @@ export const deliverChatReply = async ({
863
873
  let messages = 0;
864
874
  for (const chunk of chunks) {
865
875
  // eslint-disable-next-line no-await-in-loop
866
- const response = await postMessage({ content: chunk });
876
+ const response = await postOnce({ content: chunk });
867
877
  if (undelivered(response)) return nonDelivery(response, messages, chunks.length);
868
878
  messages += 1;
869
879
  }
@@ -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) : [] };
@@ -0,0 +1,53 @@
1
+ // D245 item 4: the receipt the wrapper posts with a reply, so a BYO reply
2
+ // carries "how this reply was made" the way a hosted reply has its run trace.
3
+ // It is the wrapper's own account and the server stores it as self-reported
4
+ // (backend/services/byoReceipt.ts), which also bounds and checks every field.
5
+ // This side keeps to what it actually observed: an adapter that reports no
6
+ // stats sends only its name and the measured duration.
7
+
8
+ const isName = (value) => typeof value === 'string' && value.length > 0;
9
+ const isCount = (value) => Number.isInteger(value) && value >= 0;
10
+
11
+ const parseLine = (raw) => {
12
+ try { return JSON.parse(raw); } catch { return null; }
13
+ };
14
+
15
+ /**
16
+ * What a Claude Code stream-json run says about itself: the model from the
17
+ * init event, the turn count from the result event, and each tool it called,
18
+ * once, in first-use order.
19
+ */
20
+ export const claudeRunStats = (stdout) => {
21
+ const events = String(stdout || '').split('\n').map(parseLine).filter(Boolean);
22
+ const stats = {};
23
+ const init = events.find((event) => event.type === 'system' && event.subtype === 'init');
24
+ if (isName(init?.model)) stats.model = init.model;
25
+ const result = [...events].reverse().find((event) => event.type === 'result');
26
+ if (isCount(result?.num_turns)) stats.turns = result.num_turns;
27
+ const tools = [];
28
+ for (const event of events) {
29
+ if (event.type !== 'assistant') continue;
30
+ for (const block of event.message?.content || []) {
31
+ if (block?.type === 'tool_use' && isName(block.name) && !tools.includes(block.name)) tools.push(block.name);
32
+ }
33
+ }
34
+ if (tools.length) stats.tools = tools;
35
+ return stats;
36
+ };
37
+
38
+ /** The receipt for one reply, or null when there is no adapter to name. */
39
+ export const buildReplyReceipt = ({ adapterName, stats = {}, durationMs } = {}) => {
40
+ if (!isName(adapterName)) return null;
41
+ const receipt = { adapter: adapterName };
42
+ // A cloud-hosted model can be named by an ARN or a resource path that
43
+ // carries the account or project it runs in; that is not this reply's to
44
+ // publish, so such a model id is left out.
45
+ if (isName(stats?.model) && !/^arn:|(^|\/)projects\//i.test(stats.model)) receipt.model = stats.model;
46
+ if (isCount(stats?.turns)) receipt.turns = stats.turns;
47
+ if (Array.isArray(stats?.tools)) {
48
+ const tools = stats.tools.filter(isName);
49
+ if (tools.length) receipt.tools = tools;
50
+ }
51
+ if (Number.isFinite(durationMs) && durationMs >= 0) receipt.durationMs = Math.round(durationMs);
52
+ return receipt;
53
+ };
@@ -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') {