@prohost/cli 0.15.0 → 0.16.0

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +265 -1
  3. package/dist/agent/accounts.d.ts +11 -0
  4. package/dist/agent/accounts.js +12 -1
  5. package/dist/agent/api.d.ts +6 -0
  6. package/dist/agent/api.js +5 -0
  7. package/dist/agent/claude.d.ts +6 -0
  8. package/dist/agent/claude.js +2 -2
  9. package/dist/agent/daemon.d.ts +33 -0
  10. package/dist/agent/daemon.js +56 -0
  11. package/dist/agent/prompt.d.ts +12 -0
  12. package/dist/agent/prompt.js +5 -0
  13. package/dist/agent/run.d.ts +110 -1
  14. package/dist/agent/run.js +186 -19
  15. package/dist/codehost/host.d.ts +25 -0
  16. package/dist/codehost/host.js +16 -1
  17. package/dist/codehost/host_stats.d.ts +49 -2
  18. package/dist/codehost/host_stats.js +153 -2
  19. package/dist/codehost/relay.d.ts +10 -0
  20. package/dist/codehost/relay.js +17 -1
  21. package/dist/host/api.d.ts +82 -0
  22. package/dist/host/api.js +122 -0
  23. package/dist/host/capacity.d.ts +41 -0
  24. package/dist/host/capacity.js +48 -0
  25. package/dist/host/command.d.ts +59 -0
  26. package/dist/host/command.js +584 -0
  27. package/dist/host/computer.d.ts +120 -0
  28. package/dist/host/computer.js +291 -0
  29. package/dist/host/events.d.ts +24 -0
  30. package/dist/host/events.js +47 -0
  31. package/dist/host/install.d.ts +152 -0
  32. package/dist/host/install.js +755 -0
  33. package/dist/host/ledger.d.ts +52 -0
  34. package/dist/host/ledger.js +131 -0
  35. package/dist/host/legacy.d.ts +38 -0
  36. package/dist/host/legacy.js +115 -0
  37. package/dist/host/logins.d.ts +109 -0
  38. package/dist/host/logins.js +190 -0
  39. package/dist/host/migrate.d.ts +165 -0
  40. package/dist/host/migrate.js +364 -0
  41. package/dist/host/pair.d.ts +62 -0
  42. package/dist/host/pair.js +219 -0
  43. package/dist/host/peak_memory.d.ts +44 -0
  44. package/dist/host/peak_memory.js +139 -0
  45. package/dist/host/scheduler.d.ts +86 -0
  46. package/dist/host/scheduler.js +186 -0
  47. package/dist/host/service.d.ts +114 -0
  48. package/dist/host/service.js +1027 -0
  49. package/dist/host/session_move.d.ts +48 -0
  50. package/dist/host/session_move.js +106 -0
  51. package/dist/host/skills.d.ts +29 -0
  52. package/dist/host/skills.js +135 -0
  53. package/dist/host/status.d.ts +91 -0
  54. package/dist/host/status.js +130 -0
  55. package/dist/index.d.ts +4 -0
  56. package/dist/index.js +47 -3
  57. package/dist/version.d.ts +2 -2
  58. package/dist/version.js +1 -1
  59. package/package.json +1 -1
@@ -0,0 +1,755 @@
1
+ /**
2
+ * `prohost host install` / `uninstall` — the background service, per OS,
3
+ * installed by the CLI itself.
4
+ *
5
+ * | | Personal (default) | Dedicated (`--dedicated --service-user <name>`) |
6
+ * | --- | --- | --- |
7
+ * | macOS | LaunchAgent `ai.prohost.host` (runs while you're logged in) | LaunchDaemon in `/Library/LaunchDaemons`, `UserName` = the service user |
8
+ * | Linux | systemd user unit `prohost-host.service` | system unit in `/etc/systemd/system`, `User=` the service user |
9
+ * | Windows | Task Scheduler task at logon | Task Scheduler task at boot running as the service user (S4U: no password stored) |
10
+ *
11
+ * Dedicated mode keeps working when no one is logged in; it must be run as
12
+ * root / an administrator. On Windows it is a boot-start scheduled task rather
13
+ * than a Windows service: Node cannot answer the Service Control Manager
14
+ * without a native wrapper, and an S4U task gives the same "runs whether or
15
+ * not anyone is logged on" without storing a password.
16
+ *
17
+ * Every variant runs `node <cli> host run` with this shell's `PATH` and the
18
+ * service's `PROHOST_HOME`, restarts it when it exits (an update drains and
19
+ * exits on purpose), and appends to `$PROHOST_HOME/host/host.log`.
20
+ */
21
+ import { spawnSync } from 'node:child_process';
22
+ import { createHash } from 'node:crypto';
23
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+ import { buildLaunchdPlist, waitUntilUnloaded } from '../agent/daemon.js';
27
+ export const LAUNCHD_HOST_LABEL = 'ai.prohost.host';
28
+ export const SYSTEMD_HOST_UNIT = 'prohost-host.service';
29
+ export const WINDOWS_TASK_NAME = '\\ProhostAI\\Host';
30
+ /** launchd / systemd wait this long after SIGTERM: the service drains (finishes runs going) first. */
31
+ export const STOP_TIMEOUT_SECONDS = 600;
32
+ /**
33
+ * How long a macOS re-install waits for the previous service to leave launchd
34
+ * before bootstrapping the new one: its whole drain, plus a margin. Bootstrapping
35
+ * sooner fails, and would leave the computer with no service once the old one exits.
36
+ */
37
+ export const RESTART_WAIT_MS = (STOP_TIMEOUT_SECONDS + 30) * 1000;
38
+ /** Set in a dedicated service's environment: where it publishes its state for the logged-in user. */
39
+ export const SHARED_DIR_ENV = 'PROHOST_HOST_SHARED_DIR';
40
+ /** The `claude setup-token` token a dedicated install hands the service user (read by `host run`). */
41
+ export const CLAUDE_TOKEN_FILE = 'claude-oauth-token';
42
+ export const defaultServiceRunner = (command, args, input) => {
43
+ const result = spawnSync(command, args, { encoding: 'utf8', ...(input !== undefined ? { input } : {}) });
44
+ return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? result.error?.message ?? '' };
45
+ };
46
+ const SERVICE_USER = /^[A-Za-z0-9._-][A-Za-z0-9._ -]{0,63}$/;
47
+ const LABEL = /^[A-Za-z0-9][A-Za-z0-9.-]{0,127}$/;
48
+ /** Paths for the OS the service is for (tests build every OS's files on one). */
49
+ function joinFor(platform) {
50
+ return platform === 'win32' ? path.win32.join : path.posix.join;
51
+ }
52
+ /** The label for a home: the plain one for the default home, else one with a short digest of the home. */
53
+ export function defaultLabel(home, defaultHome) {
54
+ if (path.resolve(home) === path.resolve(defaultHome))
55
+ return LAUNCHD_HOST_LABEL;
56
+ return `${LAUNCHD_HOST_LABEL}.${createHash('sha256').update(path.resolve(home)).digest('hex').slice(0, 8)}`;
57
+ }
58
+ export function labelOf(spec) {
59
+ return spec.label ?? LAUNCHD_HOST_LABEL;
60
+ }
61
+ export function systemdUnitOf(spec) {
62
+ const label = labelOf(spec);
63
+ return label === LAUNCHD_HOST_LABEL ? SYSTEMD_HOST_UNIT : `${label}.service`;
64
+ }
65
+ export function windowsTaskOf(spec) {
66
+ const label = labelOf(spec);
67
+ return label === LAUNCHD_HOST_LABEL ? WINDOWS_TASK_NAME : `\\ProhostAI\\${label}`;
68
+ }
69
+ export function serviceArgs(spec) {
70
+ return [spec.nodePath, spec.scriptPath, 'host', 'run'];
71
+ }
72
+ /** Where a dedicated service publishes its state, read by `host status` as the logged-in user. */
73
+ export function sharedBase(platform) {
74
+ if (platform === 'darwin')
75
+ return '/Library/Application Support/Prohost/hosts';
76
+ if (platform === 'win32')
77
+ return 'C:\\ProgramData\\Prohost\\hosts';
78
+ return '/var/lib/prohost/hosts';
79
+ }
80
+ export function sharedDirFor(spec) {
81
+ return joinFor(spec.platform)(sharedBase(spec.platform), labelOf(spec));
82
+ }
83
+ /**
84
+ * Where the service logs. Personal: `$PROHOST_HOME/host/host.log`. Dedicated:
85
+ * never under the service user's home — the service manager opens the log as
86
+ * root, and a link planted there would point root at any file. macOS: a
87
+ * root-owned `/Library/Logs/Prohost`; Windows: an ACL'd `ProgramData` folder;
88
+ * Linux: the journal (`undefined`: no file).
89
+ */
90
+ export function logPathFor(spec) {
91
+ if (!spec.dedicated)
92
+ return joinFor(spec.platform)(spec.home, 'host', 'host.log');
93
+ if (spec.platform === 'darwin')
94
+ return `/Library/Logs/Prohost/${labelOf(spec)}.log`;
95
+ if (spec.platform === 'win32')
96
+ return path.win32.join('C:\\ProgramData\\Prohost\\Logs', `${labelOf(spec)}.log`);
97
+ return undefined;
98
+ }
99
+ /** The environment the service runs with. */
100
+ export function serviceEnv(spec) {
101
+ return {
102
+ PATH: spec.pathEnv,
103
+ PROHOST_HOME: spec.home,
104
+ ...(spec.dedicated ? { PROHOST_HOST_DEDICATED: '1', [SHARED_DIR_ENV]: sharedDirFor(spec) } : {}),
105
+ ...(spec.dedicated && spec.platform === 'darwin' && spec.serviceUser
106
+ ? { HOME: path.posix.join('/Users', spec.serviceUser) }
107
+ : {}),
108
+ ...(spec.dedicated && spec.platform === 'win32' ? { PROHOST_CLAUDE_TOKEN_FILE: windowsTokenPath(spec) } : {}),
109
+ // Electron as Node: without this, the service would start the desktop app.
110
+ ...(spec.electron ? { ELECTRON_RUN_AS_NODE: '1', PROHOST_CLI_RUN: '1' } : {}),
111
+ };
112
+ }
113
+ /** Where the service definition lives. */
114
+ export function serviceFilePath(spec) {
115
+ const label = labelOf(spec);
116
+ if (spec.platform === 'darwin') {
117
+ return spec.dedicated
118
+ ? path.join('/Library/LaunchDaemons', `${label}.plist`)
119
+ : path.join(spec.userHome, 'Library', 'LaunchAgents', `${label}.plist`);
120
+ }
121
+ if (spec.platform === 'win32') {
122
+ return spec.dedicated
123
+ ? path.win32.join(windowsServiceDir(spec), 'task.xml')
124
+ : joinFor('win32')(spec.home, 'host', `${label}-task.xml`);
125
+ }
126
+ return spec.dedicated
127
+ ? path.join('/etc/systemd/system', systemdUnitOf(spec))
128
+ : path.join(spec.userHome, '.config', 'systemd', 'user', systemdUnitOf(spec));
129
+ }
130
+ export function buildHostPlist(spec) {
131
+ const log = logPathFor(spec);
132
+ return buildLaunchdPlist({
133
+ label: labelOf(spec),
134
+ programArguments: serviceArgs(spec),
135
+ workdir: spec.home,
136
+ logPath: log,
137
+ env: serviceEnv(spec),
138
+ ...(spec.dedicated && spec.serviceUser ? { userName: spec.serviceUser } : {}),
139
+ exitTimeoutSeconds: STOP_TIMEOUT_SECONDS,
140
+ });
141
+ }
142
+ function systemdQuote(value) {
143
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%')}"`;
144
+ }
145
+ export function buildHostSystemdUnit(spec) {
146
+ const log = logPathFor(spec);
147
+ return [
148
+ '[Unit]',
149
+ 'Description=ProhostAI computer host (AI employees and terminal sessions)',
150
+ 'After=network-online.target',
151
+ 'Wants=network-online.target',
152
+ '',
153
+ '[Service]',
154
+ 'Type=simple',
155
+ ...(spec.dedicated && spec.serviceUser ? [`User=${spec.serviceUser}`] : []),
156
+ `WorkingDirectory=${systemdQuote(spec.home)}`,
157
+ ...Object.entries(serviceEnv(spec)).map(([key, value]) => `Environment=${key}=${systemdQuote(value)}`),
158
+ `ExecStart=${serviceArgs(spec).map(systemdQuote).join(' ')}`,
159
+ // A system unit logs to the journal: an `append:` path would be opened by root.
160
+ ...(log ? [`StandardOutput=append:${log}`, `StandardError=append:${log}`] : ['StandardOutput=journal', 'StandardError=journal']),
161
+ 'Restart=always',
162
+ 'RestartSec=10',
163
+ 'KillSignal=SIGTERM',
164
+ // SIGTERM to the host alone, so it can drain; the rest of the group only at the timeout.
165
+ 'KillMode=mixed',
166
+ `TimeoutStopSec=${STOP_TIMEOUT_SECONDS}`,
167
+ '',
168
+ '[Install]',
169
+ `WantedBy=${spec.dedicated ? 'multi-user.target' : 'default.target'}`,
170
+ '',
171
+ ].join('\n');
172
+ }
173
+ function xmlEscape(value) {
174
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
175
+ }
176
+ /** The `.cmd` the Windows task runs: sets the environment, then the CLI, logging to its log file. */
177
+ export function buildWindowsWrapper(spec) {
178
+ const quote = (value) => value.replace(/%/g, '%%');
179
+ return [
180
+ '@echo off',
181
+ ...Object.entries(serviceEnv(spec)).map(([key, value]) => `set "${key}=${quote(value)}"`),
182
+ `"${quote(spec.nodePath)}" "${quote(spec.scriptPath)}" host run >> "${quote(logPathFor(spec))}" 2>&1`,
183
+ '',
184
+ ].join('\r\n');
185
+ }
186
+ export function windowsWrapperPath(spec) {
187
+ // Dedicated: in an administrators' folder, never in the account's own home.
188
+ if (spec.dedicated && spec.platform === 'win32')
189
+ return path.win32.join(windowsServiceDir(spec), 'host-service.cmd');
190
+ return joinFor(spec.platform)(spec.home, 'host', `${labelOf(spec)}-service.cmd`);
191
+ }
192
+ /** Where a Windows dedicated install keeps its task definition and wrapper: administrators write, the account reads. */
193
+ export function windowsServiceDir(spec) {
194
+ return path.win32.join('C:\\ProgramData\\Prohost\\services', labelOf(spec));
195
+ }
196
+ /** Task Scheduler XML: at logon (personal) or at boot as the service user via S4U (dedicated). */
197
+ export function buildWindowsTaskXml(spec, currentUser) {
198
+ const user = xmlEscape(spec.dedicated && spec.serviceUser ? spec.serviceUser : currentUser);
199
+ const trigger = spec.dedicated
200
+ ? ' <BootTrigger><Enabled>true</Enabled></BootTrigger>'
201
+ : ` <LogonTrigger><Enabled>true</Enabled><UserId>${user}</UserId></LogonTrigger>`;
202
+ return [
203
+ '<?xml version="1.0" encoding="UTF-16"?>',
204
+ '<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
205
+ ' <RegistrationInfo><Description>ProhostAI computer host (AI employees and terminal sessions)</Description></RegistrationInfo>',
206
+ ' <Triggers>',
207
+ trigger,
208
+ ' </Triggers>',
209
+ ' <Principals>',
210
+ ' <Principal id="Author">',
211
+ ` <UserId>${user}</UserId>`,
212
+ ` <LogonType>${spec.dedicated ? 'S4U' : 'InteractiveToken'}</LogonType>`,
213
+ ' <RunLevel>LeastPrivilege</RunLevel>',
214
+ ' </Principal>',
215
+ ' </Principals>',
216
+ ' <Settings>',
217
+ ' <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>',
218
+ ' <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>',
219
+ ' <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>',
220
+ ' <AllowHardTerminate>true</AllowHardTerminate>',
221
+ ' <StartWhenAvailable>true</StartWhenAvailable>',
222
+ ' <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>',
223
+ ' <IdleSettings><StopOnIdleEnd>false</StopOnIdleEnd><RestartOnIdle>false</RestartOnIdle></IdleSettings>',
224
+ ' <AllowStartOnDemand>true</AllowStartOnDemand>',
225
+ ' <Enabled>true</Enabled>',
226
+ ' <Hidden>false</Hidden>',
227
+ ' <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>',
228
+ ' <Priority>7</Priority>',
229
+ ' <RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>',
230
+ ' </Settings>',
231
+ ' <Actions Context="Author">',
232
+ ' <Exec>',
233
+ ` <Command>${xmlEscape(windowsWrapperPath(spec))}</Command>`,
234
+ ` <WorkingDirectory>${xmlEscape(spec.home)}</WorkingDirectory>`,
235
+ ' </Exec>',
236
+ ' </Actions>',
237
+ '</Task>',
238
+ '',
239
+ ].join('\r\n');
240
+ }
241
+ function writer(io) {
242
+ return (io.writeFile ??
243
+ ((file, content, mode, flag = 'w') => {
244
+ mkdirSync(path.dirname(file), { recursive: true });
245
+ writeFileSync(file, content, { mode, flag });
246
+ chmodSync(file, mode);
247
+ }));
248
+ }
249
+ function reader(io) {
250
+ return (io.readFile ??
251
+ ((file) => {
252
+ try {
253
+ return readFileSync(file, 'utf8');
254
+ }
255
+ catch {
256
+ return undefined;
257
+ }
258
+ }));
259
+ }
260
+ function failed(step, outcome) {
261
+ return `${step} failed${outcome.stderr.trim() ? `: ${outcome.stderr.trim()}` : ` (exit ${String(outcome.status)})`}`;
262
+ }
263
+ /** Validate a spec before anything is written. */
264
+ export function specProblem(spec, io = {}, setup = {}) {
265
+ if (spec.label !== undefined && !LABEL.test(spec.label)) {
266
+ return `"${spec.label}" is not a usable label (letters, digits, "." and "-", like ai.prohost.desktop.host).`;
267
+ }
268
+ if (setup.claudeTokenFile && !spec.dedicated)
269
+ return '--claude-token-file is for a dedicated install (--dedicated).';
270
+ if (spec.dedicated) {
271
+ if (!spec.serviceUser)
272
+ return '--dedicated needs --service-user <name>: the account the service runs as.';
273
+ if (!SERVICE_USER.test(spec.serviceUser))
274
+ return `"${spec.serviceUser}" is not a usable account name.`;
275
+ const admin = io.isAdmin ??
276
+ (spec.platform === 'win32'
277
+ ? // `net session` succeeds only from an elevated prompt.
278
+ (io.run ?? defaultServiceRunner)('net', ['session']).status === 0
279
+ : (io.uid ?? process.getuid?.() ?? 1) === 0);
280
+ if (!admin) {
281
+ return spec.platform === 'win32'
282
+ ? 'A dedicated service is installed from an administrator prompt.'
283
+ : 'A dedicated service is installed as root: run it with sudo.';
284
+ }
285
+ }
286
+ if (!['darwin', 'linux', 'win32'].includes(spec.platform))
287
+ return `No background service for ${spec.platform}.`;
288
+ return undefined;
289
+ }
290
+ /** Create the hidden service account if it does not exist (macOS, Linux). */
291
+ function ensureServiceUser(spec, run) {
292
+ const user = spec.serviceUser;
293
+ if (spec.platform === 'win32') {
294
+ // An S4U task needs an existing local account; making one means choosing a password policy.
295
+ return run('net', ['user', user]).status === 0
296
+ ? undefined
297
+ : `The account "${user}" does not exist. Create it first (net user ${user} /add), then install again.`;
298
+ }
299
+ if (run('id', ['-u', user]).status === 0)
300
+ return undefined;
301
+ if (spec.platform === 'linux') {
302
+ const made = run('useradd', ['--system', '--create-home', '--home-dir', `/home/${user}`, '--shell', '/usr/sbin/nologin', user]);
303
+ return made.status === 0 ? undefined : failed(`useradd ${user}`, made);
304
+ }
305
+ // macOS: a hidden account with a UniqueID in 401–499 (below the login window's range).
306
+ const listed = run('dscl', ['.', '-list', '/Users', 'UniqueID']);
307
+ const taken = new Set(listed.stdout
308
+ .split('\n')
309
+ .map((line) => Number.parseInt(line.trim().split(/\s+/)[1] ?? '', 10))
310
+ .filter(Number.isFinite));
311
+ let uid = 401;
312
+ while (taken.has(uid) && uid < 500)
313
+ uid += 1;
314
+ if (uid >= 500)
315
+ return 'No free hidden user id (401–499) for the service account.';
316
+ const record = `/Users/${user}`;
317
+ const steps = [
318
+ ['dscl', '.', '-create', record],
319
+ ['dscl', '.', '-create', record, 'UserShell', '/usr/bin/false'],
320
+ ['dscl', '.', '-create', record, 'RealName', 'ProhostAI host'],
321
+ ['dscl', '.', '-create', record, 'UniqueID', String(uid)],
322
+ ['dscl', '.', '-create', record, 'PrimaryGroupID', '20'],
323
+ ['dscl', '.', '-create', record, 'NFSHomeDirectory', record],
324
+ ['dscl', '.', '-create', record, 'IsHidden', '1'],
325
+ ['mkdir', '-p', record],
326
+ ['chown', `${user}:staff`, record],
327
+ ];
328
+ for (const [command, ...args] of steps) {
329
+ const out = run(command, args);
330
+ if (out.status !== 0)
331
+ return failed(`${command} ${args.slice(0, 3).join(' ')}`, out);
332
+ }
333
+ return undefined;
334
+ }
335
+ /** Where a Windows dedicated install keeps the Claude token: an administrators' folder the account may only read. */
336
+ export function windowsTokenPath(spec) {
337
+ return path.win32.join('C:\\ProgramData\\Prohost\\secrets', labelOf(spec), CLAUDE_TOKEN_FILE);
338
+ }
339
+ function mkdirOnce(dir) {
340
+ try {
341
+ mkdirSync(dir);
342
+ return 'created';
343
+ }
344
+ catch (err) {
345
+ if (err.code === 'EEXIST')
346
+ return 'exists';
347
+ throw err;
348
+ }
349
+ }
350
+ function ownerOf(file) {
351
+ try {
352
+ const stat = lstatSync(file);
353
+ return { uid: stat.uid, link: stat.isSymbolicLink() };
354
+ }
355
+ catch {
356
+ return undefined;
357
+ }
358
+ }
359
+ /**
360
+ * Create or take over the ProgramData folders a Windows dedicated install
361
+ * uses, top-down: each one is checked for a junction, owned by
362
+ * Administrators, and given exactly its ACL, before the next one inside it.
363
+ */
364
+ /** The principals `icacls <dir>` lists (`BUILTIN\\Administrators:(OI)(CI)(F)` → `Administrators`). */
365
+ export function aclPrincipals(icaclsOutput, dir) {
366
+ return icaclsOutput
367
+ .split(/\r?\n/)
368
+ .map((line) => (line.startsWith(dir) ? line.slice(dir.length) : line).trim())
369
+ .filter((line) => /:\(/.test(line))
370
+ .map((line) => line.slice(0, line.indexOf(':(')).split('\\').pop().trim());
371
+ }
372
+ function windowsOwner(run, dir) {
373
+ const owner = run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `(Get-Acl -LiteralPath '${dir}').Owner`]);
374
+ return owner.status === 0 ? owner.stdout.trim() : '';
375
+ }
376
+ function claimWindowsDirs(spec, run, mkdirOnce, isSymlink) {
377
+ const user = spec.serviceUser;
378
+ const base = 'C:\\ProgramData\\Prohost';
379
+ const admin = ['SYSTEM:(OI)(CI)F', 'Administrators:(OI)(CI)F'];
380
+ const dirs = [
381
+ [base, [...admin, 'Users:(OI)(CI)RX']],
382
+ [path.win32.join(base, 'hosts'), [...admin, 'Users:(OI)(CI)RX']],
383
+ [sharedDirFor(spec), [...admin, 'Users:(OI)(CI)RX']],
384
+ [path.win32.join(base, 'Logs'), [...admin, `${user}:(OI)(CI)M`]],
385
+ [path.win32.join(base, 'secrets'), admin],
386
+ [path.win32.dirname(windowsTokenPath(spec)), [...admin, `${user}:(OI)(CI)R`]],
387
+ [path.win32.join(base, 'services'), admin],
388
+ [windowsServiceDir(spec), [...admin, `${user}:(OI)(CI)RX`]],
389
+ ];
390
+ const trusted = (owner) => /\\(Administrators|SYSTEM)$/i.test(owner);
391
+ for (const [dir, grants] of dirs) {
392
+ // Made in one step, or found: never "checked absent, then made" (someone could make it in between).
393
+ if (mkdirOnce(dir) === 'exists') {
394
+ if (isSymlink(dir))
395
+ return `${dir} is a link or junction: remove it, then install again.`;
396
+ // Made by someone else first: never trusted, whatever its ACL says now.
397
+ const owner = windowsOwner(run, dir);
398
+ if (!trusted(owner))
399
+ return `${dir} is owned by ${owner || 'someone else'}: remove it, then install again.`;
400
+ }
401
+ // Exactly this ACL: the owner, then no explicit entries left from before, nothing inherited, ours granted.
402
+ for (const args of [
403
+ [dir, '/setowner', 'Administrators'],
404
+ [dir, '/reset'],
405
+ [dir, '/inheritance:r', '/grant:r', ...grants],
406
+ ]) {
407
+ const out = run('icacls', args);
408
+ if (out.status !== 0)
409
+ return failed(`icacls ${args.slice(0, 2).join(' ')}`, out);
410
+ }
411
+ // And checked after: still ours, still not a link, and only the principals we granted.
412
+ if (isSymlink(dir))
413
+ return `${dir} became a link or junction: remove it, then install again.`;
414
+ const owner = windowsOwner(run, dir);
415
+ if (!trusted(owner))
416
+ return `${dir} is owned by ${owner || 'someone else'} after taking it over: install stopped.`;
417
+ const listed = run('icacls', [dir]);
418
+ const allowed = new Set(grants.map((grant) => grant.slice(0, grant.indexOf(':')).toLowerCase()));
419
+ const extra = aclPrincipals(listed.stdout, dir).filter((name) => !allowed.has(name.toLowerCase()));
420
+ if (listed.status !== 0 || extra.length > 0) {
421
+ return `${dir} still grants ${extra.join(', ') || 'unknown principals'} after taking it over: install stopped.`;
422
+ }
423
+ }
424
+ return undefined;
425
+ }
426
+ /** The files a dedicated service publishes in its shared folder, and the pause switch. */
427
+ export const SHARED_FILES = ['state.json', 'activity.json', 'events.json'];
428
+ /**
429
+ * Run a short shell script as the service account (`sudo -u` on macOS,
430
+ * `runuser` on Linux), with `input` on stdin. Everything root writes into the
431
+ * account's home goes through here: as the account, a link it planted there
432
+ * can only point at what it could write anyway.
433
+ */
434
+ function asUser(run, platform, user, script, args, input) {
435
+ const shell = ['/bin/sh', '-c', `umask 077; ${script}`, 'sh', ...args];
436
+ return platform === 'darwin'
437
+ ? run('sudo', ['-u', user, '-H', '--', ...shell], input)
438
+ : run('runuser', ['-u', user, '--', ...shell], input);
439
+ }
440
+ /**
441
+ * The parts of a dedicated install that are not the service: the account, its
442
+ * home (credential and Claude token in place — written as the account), the
443
+ * root-owned shared folder its state is published in, and a log location root
444
+ * controls. Root never writes through a path the account controls.
445
+ */
446
+ function prepareDedicated(spec, io, setup) {
447
+ const run = io.run ?? defaultServiceRunner;
448
+ const write = writer(io);
449
+ const read = reader(io);
450
+ const exists = io.exists ?? existsSync;
451
+ const isLink = io.isLink ?? ((file) => {
452
+ try {
453
+ const stat = lstatSync(file);
454
+ return !stat.isFile() || stat.nlink > 1;
455
+ }
456
+ catch {
457
+ return false;
458
+ }
459
+ });
460
+ const isSymlink = io.isSymlink ?? ((file) => {
461
+ try {
462
+ return lstatSync(file).isSymbolicLink();
463
+ }
464
+ catch {
465
+ return false;
466
+ }
467
+ });
468
+ const remove = io.removeFile ?? ((file) => rmSync(file, { force: true }));
469
+ const mkdir = io.mkdir ?? ((dir, mode) => mkdirSync(dir, { recursive: true, mode }));
470
+ const join = joinFor(spec.platform);
471
+ const user = spec.serviceUser;
472
+ const problem = ensureServiceUser(spec, run);
473
+ if (problem)
474
+ return problem;
475
+ const hostDir = join(spec.home, 'host');
476
+ const credential = setup.adoptFrom && path.resolve(setup.adoptFrom) !== path.resolve(spec.home)
477
+ ? read(join(setup.adoptFrom, 'host', 'computer.json'))
478
+ : undefined;
479
+ let token;
480
+ if (setup.claudeTokenFile) {
481
+ token = read(setup.claudeTokenFile)?.trim();
482
+ if (!token || token.length > 4096 || /\s/.test(token))
483
+ return `${setup.claudeTokenFile} holds no usable token.`;
484
+ }
485
+ if (spec.platform === 'win32') {
486
+ // Everything an administrator touches is claimed top-down (owner
487
+ // Administrators, nothing inherited) before anything goes in, and a
488
+ // junction where a folder should be stops the install: a folder another
489
+ // user made first, or one the account controls, is never written through.
490
+ const claimed = claimWindowsDirs(spec, run, io.mkdirOnce ?? mkdirOnce, isSymlink);
491
+ if (claimed)
492
+ return claimed;
493
+ if (token) {
494
+ // Always a new file, in the folder just claimed: whatever was there is replaced, never written through.
495
+ const tokenFile = windowsTokenPath(spec);
496
+ if (exists(tokenFile))
497
+ remove(tokenFile);
498
+ write(tokenFile, `${token}\n`, 0o600, 'wx');
499
+ }
500
+ // The account's home is ACL'd and filled only when this install creates
501
+ // it: an existing one is the account's, and an administrator writing into
502
+ // it could be redirected.
503
+ mkdir(path.win32.dirname(spec.home), 0o755);
504
+ if ((io.mkdirOnce ?? mkdirOnce)(spec.home) === 'created') {
505
+ mkdir(hostDir, 0o700);
506
+ const acl = run('icacls', [spec.home, '/inheritance:r', '/grant:r', 'SYSTEM:(OI)(CI)F', 'Administrators:(OI)(CI)F', `${user}:(OI)(CI)F`]);
507
+ if (acl.status !== 0)
508
+ return failed(`icacls ${spec.home}`, acl);
509
+ if (credential)
510
+ write(join(hostDir, 'computer.json'), credential, 0o600);
511
+ }
512
+ else if (isSymlink(spec.home) || isSymlink(hostDir)) {
513
+ return `${spec.home} is a link: remove it, then install again.`;
514
+ }
515
+ }
516
+ else {
517
+ let made = asUser(run, spec.platform, user, 'mkdir -p "$1"', [hostDir]);
518
+ if (made.status !== 0 && !exists(spec.home)) {
519
+ // A service home where the account can't create it (--service-home under
520
+ // a root-owned folder): root makes that one folder, if its parent is
521
+ // root's and no link, and hands it over; everything inside is the account's.
522
+ const parent = path.posix.dirname(spec.home);
523
+ const owner = io.ownerOf?.(parent) ?? ownerOf(parent);
524
+ if (!owner || owner.uid !== 0 || owner.link)
525
+ return `${parent} is not a root-owned folder: create ${spec.home} for ${user} first.`;
526
+ mkdir(spec.home, 0o700);
527
+ const handed = run('chown', ['-h', user, spec.home]);
528
+ if (handed.status !== 0)
529
+ return failed(`chown ${spec.home}`, handed);
530
+ made = asUser(run, spec.platform, user, 'mkdir -p "$1"', [hostDir]);
531
+ }
532
+ if (made.status !== 0)
533
+ return failed(`creating ${hostDir} as ${user}`, made);
534
+ if (credential) {
535
+ // Only when the home has none; checked as the account too.
536
+ const copied = asUser(run, spec.platform, user, 'test -e "$1" || cat > "$1"', [join(hostDir, 'computer.json')], credential);
537
+ if (copied.status !== 0)
538
+ return failed('copying computer.json', copied);
539
+ }
540
+ if (token) {
541
+ const stored = asUser(run, spec.platform, user,
542
+ // A fresh temp file (noclobber: never one that is already there), then renamed over.
543
+ 'rm -f "$1.new" && set -C && cat > "$1.new" && mv -f "$1.new" "$1"', [join(hostDir, CLAUDE_TOKEN_FILE)], `${token}\n`);
544
+ if (stored.status !== 0)
545
+ return failed('installing the Claude token', stored);
546
+ }
547
+ }
548
+ // The shared folder is root's (the account can't swap what's in it); the
549
+ // account gets only its three files, and everyone the pause switch.
550
+ const shared = sharedDirFor(spec);
551
+ if (spec.platform !== 'win32') {
552
+ mkdir(shared, 0o755);
553
+ for (const [command, ...args] of [['chown', 'root', shared], ['chmod', '755', shared]]) {
554
+ const out = run(command, args);
555
+ if (out.status !== 0)
556
+ return failed(`${command} ${shared}`, out);
557
+ }
558
+ }
559
+ const managed = [
560
+ ...SHARED_FILES.map((name) => ({ name, content: '{}\n', mode: 0o644, owner: 'service' })),
561
+ { name: 'control.json', content: `${JSON.stringify({ v: 1, paused: false })}\n`, mode: 0o666, owner: 'everyone' },
562
+ ];
563
+ for (const file of managed) {
564
+ const target = join(shared, file.name);
565
+ // Left by an older install that let the account own this folder.
566
+ if (exists(target) && isLink(target))
567
+ remove(target);
568
+ if (!exists(target))
569
+ write(target, file.content, file.mode, 'wx');
570
+ if (spec.platform === 'win32') {
571
+ const grant = file.owner === 'service' ? `${user}:M` : 'Users:M';
572
+ const acl = run('icacls', [target, '/grant', grant]);
573
+ if (acl.status !== 0)
574
+ return failed(`icacls ${target}`, acl);
575
+ }
576
+ else if (file.owner === 'service') {
577
+ const owned = run('chown', ['-h', user, target]);
578
+ if (owned.status !== 0)
579
+ return failed(`chown ${target}`, owned);
580
+ }
581
+ else {
582
+ const open = run('chmod', ['666', target]);
583
+ if (open.status !== 0)
584
+ return failed(`chmod ${target}`, open);
585
+ }
586
+ }
587
+ if (spec.platform === 'darwin') {
588
+ // Root's folder, the service's file: it can write its log, never swap it for a link.
589
+ const log = logPathFor(spec);
590
+ mkdir(path.posix.dirname(log), 0o755);
591
+ if (exists(log) && isLink(log))
592
+ remove(log);
593
+ if (!exists(log))
594
+ write(log, '', 0o644, 'wx');
595
+ const logOwned = run('chown', ['-h', user, log]);
596
+ if (logOwned.status !== 0)
597
+ return failed(`chown ${log}`, logOwned);
598
+ }
599
+ return undefined;
600
+ }
601
+ export function installService(spec, io = {}, setup = {}) {
602
+ const problem = specProblem(spec, io, setup);
603
+ if (problem)
604
+ return { ok: false, message: problem };
605
+ const run = io.run ?? defaultServiceRunner;
606
+ const write = writer(io);
607
+ const read = reader(io);
608
+ const file = serviceFilePath(spec);
609
+ const label = labelOf(spec);
610
+ const mkdir = io.mkdir ?? ((dir, mode) => mkdirSync(dir, { recursive: true, mode }));
611
+ if (spec.dedicated) {
612
+ const prepared = prepareDedicated(spec, io, setup);
613
+ if (prepared)
614
+ return { ok: false, message: prepared };
615
+ }
616
+ else {
617
+ mkdir(path.dirname(logPathFor(spec)), 0o700);
618
+ }
619
+ const logs = logPathFor(spec) ?? `the journal (journalctl -u ${systemdUnitOf(spec)})`;
620
+ if (spec.platform === 'darwin') {
621
+ const domain = spec.dedicated ? 'system' : `gui/${io.uid ?? process.getuid?.() ?? 0}`;
622
+ const plist = buildHostPlist(spec);
623
+ const loaded = run('launchctl', ['print', `${domain}/${label}`]).status === 0;
624
+ // Re-running it changes nothing that is already right: the service keeps
625
+ // working through every app update that didn't move it.
626
+ if (loaded && read(file) === plist) {
627
+ return { ok: true, file, message: `✓ ${label} is installed and running (${file})\n logs: ${logs}` };
628
+ }
629
+ write(file, plist, spec.dedicated ? 0o644 : 0o600);
630
+ if (loaded)
631
+ run('launchctl', ['bootout', `${domain}/${label}`]);
632
+ // A re-install: the running service drains on SIGTERM, and launchd keeps
633
+ // its label until it exits — bootstrapping before then fails.
634
+ const oldGone = !loaded ||
635
+ waitUntilUnloaded((command, args) => {
636
+ const out = run(command, args);
637
+ return { status: out.status, stderr: out.stderr };
638
+ }, `${domain}/${label}`, {
639
+ sleep: io.sleep ?? ((ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)),
640
+ now: io.now ?? Date.now,
641
+ timeoutMs: RESTART_WAIT_MS,
642
+ onWaiting: (line) => process.stderr.write(`${line.replace('agent', 'host service')}\n`),
643
+ });
644
+ const boot = run('launchctl', ['bootstrap', domain, file]);
645
+ if (boot.status !== 0) {
646
+ const still = oldGone ? '' : ` (the previous host service was still finishing its runs after ${RESTART_WAIT_MS / 1000}s)`;
647
+ return { ok: false, file, message: `Wrote ${file}, but ${failed('launchctl bootstrap', boot)}${still}` };
648
+ }
649
+ run('launchctl', ['enable', `${domain}/${label}`]);
650
+ return { ok: true, file, message: `✓ Installed ${label} (${file})\n logs: ${logs}` };
651
+ }
652
+ if (spec.platform === 'linux') {
653
+ const user = spec.dedicated ? [] : ['--user'];
654
+ const unitName = systemdUnitOf(spec);
655
+ const unit = buildHostSystemdUnit(spec);
656
+ const changed = read(file) !== unit;
657
+ if (changed) {
658
+ write(file, unit, 0o644);
659
+ const reload = run('systemctl', [...user, 'daemon-reload']);
660
+ if (reload.status !== 0)
661
+ return { ok: false, file, message: `Wrote ${file}, but ${failed('systemctl daemon-reload', reload)}` };
662
+ }
663
+ const enable = run('systemctl', [...user, 'enable', '--now', unitName]);
664
+ if (enable.status !== 0)
665
+ return { ok: false, file, message: `Wrote ${file}, but ${failed('systemctl enable', enable)}` };
666
+ // A changed unit only takes effect on a restart; the service drains on SIGTERM first.
667
+ if (changed)
668
+ run('systemctl', [...user, 'try-restart', unitName]);
669
+ const linger = spec.dedicated
670
+ ? ''
671
+ : '\n It runs while you are logged in. To keep it running after you log out: loginctl enable-linger';
672
+ return { ok: true, file, message: `✓ Installed ${unitName} (${file})\n logs: ${logs}${linger}` };
673
+ }
674
+ const task = windowsTaskOf(spec);
675
+ const currentUser = io.currentUser ?? os.userInfo().username;
676
+ const xml = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(buildWindowsTaskXml(spec, currentUser), 'utf16le')]);
677
+ write(windowsWrapperPath(spec), buildWindowsWrapper(spec), 0o600);
678
+ // Task Scheduler reads its XML as UTF-16 (little-endian, with a BOM).
679
+ write(file, xml, 0o600);
680
+ const create = run('schtasks', ['/Create', '/TN', task, '/XML', file, '/F']);
681
+ if (create.status !== 0)
682
+ return { ok: false, file, message: failed('schtasks /Create', create) };
683
+ // Already running (`IgnoreNew`): left to finish; the next start runs the new definition.
684
+ const start = run('schtasks', ['/Run', '/TN', task]);
685
+ if (start.status !== 0)
686
+ return { ok: false, file, message: `Created ${task}, but ${failed('schtasks /Run', start)}` };
687
+ return { ok: true, file, message: `✓ Installed the ${task} task\n logs: ${logs}` };
688
+ }
689
+ export function uninstallService(spec, io = {}) {
690
+ const run = io.run ?? defaultServiceRunner;
691
+ const remove = io.removeFile ?? ((file) => rmSync(file, { force: true }));
692
+ const exists = io.exists ?? existsSync;
693
+ const removed = [];
694
+ const notes = [];
695
+ const label = labelOf(spec);
696
+ if (spec.platform === 'darwin') {
697
+ for (const dedicated of [false, true]) {
698
+ const variant = { ...spec, dedicated };
699
+ const file = serviceFilePath(variant);
700
+ if (!exists(file))
701
+ continue;
702
+ const domain = dedicated ? 'system' : `gui/${io.uid ?? process.getuid?.() ?? 0}`;
703
+ const out = run('launchctl', ['bootout', `${domain}/${label}`]);
704
+ if (dedicated && out.status !== 0 && (io.uid ?? process.getuid?.() ?? 1) !== 0) {
705
+ notes.push(`${file} is a system service: remove it with sudo prohost host uninstall`);
706
+ continue;
707
+ }
708
+ remove(file);
709
+ removed.push(file);
710
+ }
711
+ }
712
+ else if (spec.platform === 'linux') {
713
+ for (const dedicated of [false, true]) {
714
+ const variant = { ...spec, dedicated };
715
+ const file = serviceFilePath(variant);
716
+ if (!exists(file))
717
+ continue;
718
+ const user = dedicated ? [] : ['--user'];
719
+ const out = run('systemctl', [...user, 'disable', '--now', systemdUnitOf(spec)]);
720
+ if (dedicated && out.status !== 0 && (io.uid ?? process.getuid?.() ?? 1) !== 0) {
721
+ notes.push(`${file} is a system service: remove it with sudo prohost host uninstall`);
722
+ continue;
723
+ }
724
+ remove(file);
725
+ run('systemctl', [...user, 'daemon-reload']);
726
+ removed.push(file);
727
+ }
728
+ }
729
+ else if (spec.platform === 'win32') {
730
+ const task = windowsTaskOf(spec);
731
+ run('schtasks', ['/End', '/TN', task]);
732
+ const out = run('schtasks', ['/Delete', '/TN', task, '/F']);
733
+ if (out.status === 0)
734
+ removed.push(task);
735
+ for (const file of [serviceFilePath(spec), windowsWrapperPath(spec)]) {
736
+ if (exists(file))
737
+ remove(file);
738
+ }
739
+ }
740
+ if (removed.length === 0 && notes.length === 0) {
741
+ return { ok: true, message: 'No host service was installed. Nothing to remove.' };
742
+ }
743
+ return {
744
+ ok: notes.length === 0,
745
+ message: [...removed.map((file) => `✓ Removed ${file}`), ...notes.map((note) => `! ${note}`)].join('\n'),
746
+ };
747
+ }
748
+ /** Where a dedicated service user's `PROHOST_HOME` is by default. */
749
+ export function serviceUserHome(platform, user) {
750
+ if (platform === 'darwin')
751
+ return path.join('/Users', user, '.prohost');
752
+ if (platform === 'win32')
753
+ return path.win32.join('C:\\Users', user, '.prohost');
754
+ return path.join('/home', user, '.prohost');
755
+ }