@commonlyai/cli 0.1.232 → 0.1.233
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/agent.js +31 -2
- package/src/commands/daemon.js +54 -7
- package/src/lib/adapters/claude.js +108 -22
- package/src/lib/adapters/codex.js +27 -5
- package/src/lib/daemon-service-windows.js +256 -0
- package/src/lib/daemon-service.js +32 -7
- package/src/lib/daemon-store.js +8 -3
- package/src/lib/daemon-supervisor.js +8 -1
- package/src/lib/grant-broker-guard.js +10 -2
- package/src/lib/native-runtime-inventory.js +9 -2
- package/src/lib/sandbox/bwrap.js +9 -3
- package/src/lib/sandbox/host-check.js +16 -2
- package/src/lib/sandbox/windows-unsandboxed.js +131 -0
- package/src/lib/windows-command.js +348 -0
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
53
|
+
.replace(/"/g, '"');
|
|
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
|
|
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
|
-
|
|
26
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
@@ -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 === '
|
|
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
|
package/src/lib/daemon-store.js
CHANGED
|
@@ -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
|
-
|
|
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`, {
|
|
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 ({
|
|
21
|
-
|
|
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) : [] };
|
package/src/lib/sandbox/bwrap.js
CHANGED
|
@@ -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)
|
|
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:
|
|
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(
|
|
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.
|
|
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')
|
|
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') {
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D245: the user's own opt-in to run an agent without a sandbox on native
|
|
3
|
+
* Windows.
|
|
4
|
+
*
|
|
5
|
+
* Commonly confines a public seat with Seatbelt on macOS and bubblewrap on
|
|
6
|
+
* Linux (mode.js). Windows has neither, so without this a public Claude Code,
|
|
7
|
+
* OpenCode or Qwen seat is refused on Windows (host-check.js). The opt-in
|
|
8
|
+
* keeps that refusal as the default and lets the person at the keyboard choose
|
|
9
|
+
* otherwise, for this machine only:
|
|
10
|
+
*
|
|
11
|
+
* - `commonly agent run <name> --unsandboxed` for one foreground run, or
|
|
12
|
+
* - `commonly daemon install --unsandboxed`, which writes the marker below; the
|
|
13
|
+
* seats that daemon spawns read it. Installing again without the flag, or
|
|
14
|
+
* uninstalling, removes it.
|
|
15
|
+
*
|
|
16
|
+
* Only a LOCAL action sets it. Nothing the server sends (a seat's declared
|
|
17
|
+
* environment, an adopted-seat row, a pod's settings) can create the marker or
|
|
18
|
+
* stand in for the flag: `hostUnconfinedForRun` takes the decision as an
|
|
19
|
+
* argument, and the adapter reads it from its spawn context, never from the
|
|
20
|
+
* environment.
|
|
21
|
+
*
|
|
22
|
+
* Only the OS jail is skipped. The seat keeps its trust, and with it the
|
|
23
|
+
* public tool policy (--setting-sources '', --strict-mcp-config, dontAsk,
|
|
24
|
+
* Read(./**), the denied tools), the sanitized environment and the TASK-294
|
|
25
|
+
* per-turn placement: on a host with no OS isolation that policy IS the
|
|
26
|
+
* sandbox. An earlier form of this rewrote the environment to `mode: 'none'`,
|
|
27
|
+
* which dropped the trust and the policy with it (review of #3388). The
|
|
28
|
+
* decision now travels as `hostUnconfined` on the adapter's spawn context,
|
|
29
|
+
* set only by `agent run`, so the environment, stored or derived, never
|
|
30
|
+
* carries it and nothing the server sends can set it.
|
|
31
|
+
*
|
|
32
|
+
* Claude Code only: it is the adapter that applies the public policy without
|
|
33
|
+
* its jail. OpenCode and Qwen stay refused on Windows, and Codex is never
|
|
34
|
+
* refused there (it confines with its own profiles). No seat on native Windows
|
|
35
|
+
* gets the grant broker (grant-broker-guard.js), and the server refuses grant
|
|
36
|
+
* use for every seat on a machine whose daemon reports the opt-in.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { existsSync, readFileSync, rmSync } from 'fs';
|
|
40
|
+
import { homedir } from 'os';
|
|
41
|
+
import { join } from 'path';
|
|
42
|
+
|
|
43
|
+
import { writeServiceFile } from '../daemon-service.js';
|
|
44
|
+
import { UNSANDBOXED_FLAG } from './host-check.js';
|
|
45
|
+
|
|
46
|
+
export { UNSANDBOXED_FLAG };
|
|
47
|
+
|
|
48
|
+
export const unsandboxedMarkerPath = (home = homedir()) => join(home, '.commonly', 'daemon', 'unsandboxed.json');
|
|
49
|
+
|
|
50
|
+
/** The flag is a Windows answer to a Windows gap; anywhere else it is refused. */
|
|
51
|
+
export const unsandboxedFlagRefusal = (platform = process.platform) => (platform === 'win32'
|
|
52
|
+
? null
|
|
53
|
+
: `${UNSANDBOXED_FLAG} is only for native Windows, which has no sandbox for agents yet. On this machine agents run in a sandbox.`);
|
|
54
|
+
|
|
55
|
+
export const loadUnsandboxedOptIn = ({
|
|
56
|
+
platform = process.platform, home = homedir(), exists = existsSync, read = readFileSync,
|
|
57
|
+
} = {}) => {
|
|
58
|
+
if (platform !== 'win32') return false;
|
|
59
|
+
const file = unsandboxedMarkerPath(home);
|
|
60
|
+
if (!exists(file)) return false;
|
|
61
|
+
try {
|
|
62
|
+
const value = JSON.parse(read(file, 'utf8'));
|
|
63
|
+
return value?.unsandboxed === true && value?.platform === 'win32';
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const saveUnsandboxedOptIn = ({ home = homedir(), writeFile = writeServiceFile, now = () => new Date() } = {}) => {
|
|
70
|
+
writeFile(unsandboxedMarkerPath(home), `${JSON.stringify({ unsandboxed: true, platform: 'win32', optedInAt: now().toISOString() })}\n`);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const clearUnsandboxedOptIn = ({ home = homedir(), exists = existsSync, remove = rmSync } = {}) => {
|
|
74
|
+
const file = unsandboxedMarkerPath(home);
|
|
75
|
+
if (!exists(file)) return false;
|
|
76
|
+
remove(file);
|
|
77
|
+
return true;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Did the user opt in for THIS run? The typed flag, or, for a seat the daemon
|
|
82
|
+
* spawned, the marker that `daemon install --unsandboxed` wrote. A foreground
|
|
83
|
+
* run never reads the marker, so installing the daemon unsandboxed does not
|
|
84
|
+
* quietly change what a hand-started `agent run` does.
|
|
85
|
+
*/
|
|
86
|
+
export const runOptedInUnsandboxed = ({ flag, env = process.env, load = loadUnsandboxedOptIn } = {}) => (
|
|
87
|
+
flag === true || (env.COMMONLY_MANAGED_RUNNER === '1' && load() === true)
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
/** The adapters that can run their trust's tool policy without the OS jail. */
|
|
91
|
+
export const POLICY_ONLY_ADAPTERS = new Set(['claude']);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Does this run skip the OS jail? Only on win32, only by the user's own
|
|
95
|
+
* opt-in, only for an adapter that keeps its policy without the jail, and only
|
|
96
|
+
* for a seat whose trust brings that policy (public or workspace). A seat with
|
|
97
|
+
* no confined trust is the owner's own and is never jailed anywhere, so the
|
|
98
|
+
* opt-in changes nothing for it, and the notice must not promise it a policy.
|
|
99
|
+
*/
|
|
100
|
+
export const hostUnconfinedForRun = ({
|
|
101
|
+
optedIn, adapterName, environment, platform = process.platform,
|
|
102
|
+
}) => {
|
|
103
|
+
const trust = environment?.sandbox?.trust;
|
|
104
|
+
return platform === 'win32' && optedIn === true && POLICY_ONLY_ADAPTERS.has(adapterName)
|
|
105
|
+
&& (trust === 'public' || trust === 'workspace');
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* What the daemon's check-in says about this host. The server refuses grant
|
|
110
|
+
* use for every seat on a machine that reports either Windows value.
|
|
111
|
+
*/
|
|
112
|
+
export const hostSandboxReport = ({ platform = process.platform, load = () => loadUnsandboxedOptIn({ platform }) } = {}) => {
|
|
113
|
+
if (platform !== 'win32') return 'default';
|
|
114
|
+
return load() === true ? 'unsandboxed-opted-in' : 'native-windows';
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** One plain line for the run log and `daemon status`. */
|
|
118
|
+
export const UNSANDBOXED_NOTICE = 'unsandboxed (opted in): Windows has no sandbox, so nothing at the OS level confines this agent. It keeps Commonly\'s restricted tool settings, gets no shell even with --allow write, and gets no connector grants.';
|
|
119
|
+
|
|
120
|
+
export default {
|
|
121
|
+
UNSANDBOXED_FLAG,
|
|
122
|
+
UNSANDBOXED_NOTICE,
|
|
123
|
+
clearUnsandboxedOptIn,
|
|
124
|
+
loadUnsandboxedOptIn,
|
|
125
|
+
runOptedInUnsandboxed,
|
|
126
|
+
saveUnsandboxedOptIn,
|
|
127
|
+
unsandboxedFlagRefusal,
|
|
128
|
+
unsandboxedMarkerPath,
|
|
129
|
+
hostUnconfinedForRun,
|
|
130
|
+
hostSandboxReport,
|
|
131
|
+
};
|