@prohost/cli 0.16.4 → 0.16.6

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.
@@ -100,6 +100,13 @@ export declare function buildWindowsWrapper(spec: ServiceSpec): string;
100
100
  export declare function windowsWrapperPath(spec: ServiceSpec): string;
101
101
  /** Where a Windows dedicated install keeps its task definition and wrapper: administrators write, the account reads. */
102
102
  export declare function windowsServiceDir(spec: Pick<ServiceSpec, 'label'>): string;
103
+ /**
104
+ * The administrators' copy of a Windows dedicated service's key (`services\<label>`: the account
105
+ * may read it, never change it) — what an administrator's `unpair` / `uninstall` reads. Written
106
+ * when an install creates the service's home; a home installed before 0.16.5 has none, and is
107
+ * then never handed a note nor unpaired by label (its computer is removed on its page).
108
+ */
109
+ export declare function windowsServiceCredentialPath(spec: Pick<ServiceSpec, 'label'>): string;
103
110
  /** Task Scheduler XML: at logon (personal) or at boot as the service user via S4U (dedicated). */
104
111
  export declare function buildWindowsTaskXml(spec: ServiceSpec, currentUser: string): string;
105
112
  export interface InstallOutcome {
@@ -126,6 +133,19 @@ export interface InstallIo {
126
133
  link: boolean;
127
134
  } | undefined;
128
135
  removeFile?: (file: string) => void;
136
+ /** Rename `from` over `to` (Windows hand-off note: a fresh file, then renamed into place). */
137
+ rename?: (from: string, to: string) => void;
138
+ /** The real path of a folder, links resolved (Windows hand-off note: checked before and after). */
139
+ realpath?: (file: string) => string;
140
+ /**
141
+ * Open a file once and read it through that handle, with what `fstat` says about the very file
142
+ * opened (Windows service key: no re-open between the check and the read). Undefined: not there.
143
+ */
144
+ readHandle?: (file: string) => {
145
+ isFile: boolean;
146
+ nlink: number;
147
+ text: string;
148
+ } | undefined;
129
149
  exists?: (file: string) => boolean;
130
150
  mkdir?: (dir: string, mode: number) => void;
131
151
  /** Seams for waiting on a re-install's previous service to stop (macOS). */
@@ -138,6 +158,11 @@ export interface DedicatedSetup {
138
158
  claudeTokenFile?: string;
139
159
  /** The invoking user's `PROHOST_HOME`: its computer credential is copied into the service home if that has none. */
140
160
  adoptFrom?: string;
161
+ /**
162
+ * That home already handed its computer to this very service (its note names this label): a
163
+ * reinstall, whose administrators' key copy (Windows) is kept although no credential is handed.
164
+ */
165
+ handedOffHere?: boolean;
141
166
  }
142
167
  /** Validate a spec before anything is written. */
143
168
  export declare function specProblem(spec: ServiceSpec, io?: InstallIo, setup?: DedicatedSetup): string | undefined;
@@ -145,6 +170,45 @@ export declare function specProblem(spec: ServiceSpec, io?: InstallIo, setup?: D
145
170
  export declare function windowsHomesDir(): string;
146
171
  /** Where a Windows dedicated install keeps the Claude token: an administrators' folder the account may only read. */
147
172
  export declare function windowsTokenPath(spec: Pick<ServiceSpec, 'label'>): string;
173
+ /**
174
+ * The home (and account) an installed dedicated service runs with, read from its definition —
175
+ * root's / the administrators' own file (`/Library/LaunchDaemons`, `/etc/systemd/system`,
176
+ * `ProgramData\\Prohost\\services`) — or undefined when none is installed under `label`. What
177
+ * an administrator's `unpair` / `uninstall` trusts, never a note in an account's home.
178
+ */
179
+ export declare function installedDedicatedService(platform: NodeJS.Platform, label: string, io?: InstallIo): {
180
+ home: string;
181
+ user?: string;
182
+ } | undefined;
183
+ /**
184
+ * A dedicated service home's `computer.json` text, when it holds a key, read the safe way for an
185
+ * administrator: on macOS / Linux as the service's account (`sudo -u` / `runuser … cat`), so a link
186
+ * the account planted at `host` or `computer.json` reaches only what that account may read itself;
187
+ * on Windows from the service's administrators-only copy, never from the account's home.
188
+ */
189
+ export declare function readServiceCredential(platform: NodeJS.Platform, home: string, user: string | undefined, io?: InstallIo, label?: string): string | undefined;
190
+ /**
191
+ * What a dedicated service last published about its computer (`hosts\\<label>\\state.json`): its id,
192
+ * `none` when it has published nothing yet (no file, or the `{}` an install starts it with), or
193
+ * `unreadable` (a read error or JSON that does not parse, even after a retry) — never taken as none.
194
+ */
195
+ export declare function publishedComputer(spec: Pick<ServiceSpec, 'platform' | 'label'>, io?: InstallIo): {
196
+ kind: 'published';
197
+ id: string;
198
+ } | {
199
+ kind: 'none';
200
+ } | {
201
+ kind: 'unreadable';
202
+ };
203
+ /** The computer id a dedicated service last published, if it did (see `publishedComputer`). */
204
+ export declare function publishedComputerId(spec: Pick<ServiceSpec, 'platform' | 'label'>, io?: InstallIo): string | undefined;
205
+ /**
206
+ * Write (`content`) or remove (`undefined`) `<home>/host/computer.json` in a home another user
207
+ * owns — the one a dedicated install took its computer from. As root on macOS / Linux it is done
208
+ * as the folder's owner (root never writes through a path an account controls); anyone else, and
209
+ * an administrator on Windows, writes it directly. Returns what went wrong, if anything.
210
+ */
211
+ export declare function rewriteHomeCredential(platform: NodeJS.Platform, home: string, content: string | undefined, io?: InstallIo): string | undefined;
148
212
  /**
149
213
  * Create or take over the ProgramData folders a Windows dedicated install
150
214
  * uses, top-down: each one is checked for a junction, owned by
@@ -20,11 +20,12 @@
20
20
  */
21
21
  import { spawnSync } from 'node:child_process';
22
22
  import { createHash } from 'node:crypto';
23
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
23
+ import { chmodSync, closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
24
24
  import os from 'node:os';
25
25
  import path from 'node:path';
26
26
  import { buildLaunchdPlist, waitUntilUnloaded } from '../agent/daemon.js';
27
27
  import { TERMINAL_SESSIONS_ENV } from '../codehost/settings.js';
28
+ import { SERVICE_LABEL, readSharedObject } from './computer.js';
28
29
  export const LAUNCHD_HOST_LABEL = 'ai.prohost.host';
29
30
  export const SYSTEMD_HOST_UNIT = 'prohost-host.service';
30
31
  export const WINDOWS_TASK_NAME = '\\ProhostAI\\Host';
@@ -46,7 +47,7 @@ export const defaultServiceRunner = (command, args, input) => {
46
47
  return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? result.error?.message ?? '' };
47
48
  };
48
49
  const SERVICE_USER = /^[A-Za-z0-9._-][A-Za-z0-9._ -]{0,63}$/;
49
- const LABEL = /^[A-Za-z0-9][A-Za-z0-9.-]{0,127}$/;
50
+ const LABEL = SERVICE_LABEL;
50
51
  /** Paths for the OS the service is for (tests build every OS's files on one). */
51
52
  function joinFor(platform) {
52
53
  return platform === 'win32' ? path.win32.join : path.posix.join;
@@ -197,6 +198,15 @@ export function windowsWrapperPath(spec) {
197
198
  export function windowsServiceDir(spec) {
198
199
  return path.win32.join('C:\\ProgramData\\Prohost\\services', labelOf(spec));
199
200
  }
201
+ /**
202
+ * The administrators' copy of a Windows dedicated service's key (`services\<label>`: the account
203
+ * may read it, never change it) — what an administrator's `unpair` / `uninstall` reads. Written
204
+ * when an install creates the service's home; a home installed before 0.16.5 has none, and is
205
+ * then never handed a note nor unpaired by label (its computer is removed on its page).
206
+ */
207
+ export function windowsServiceCredentialPath(spec) {
208
+ return path.win32.join(windowsServiceDir(spec), 'computer.json');
209
+ }
200
210
  /** Task Scheduler XML: at logon (personal) or at boot as the service user via S4U (dedicated). */
201
211
  export function buildWindowsTaskXml(spec, currentUser) {
202
212
  const user = xmlEscape(spec.dedicated && spec.serviceUser ? spec.serviceUser : currentUser);
@@ -267,7 +277,12 @@ function failed(step, outcome) {
267
277
  /** Validate a spec before anything is written. */
268
278
  export function specProblem(spec, io = {}, setup = {}) {
269
279
  if (spec.label !== undefined && !LABEL.test(spec.label)) {
270
- return `"${spec.label}" is not a usable label (letters, digits, "." and "-", like ai.prohost.desktop.host).`;
280
+ return `"${spec.label}" is not a usable label (letters, digits, "." and "-", like ai.prohost.host.office).`;
281
+ }
282
+ // A dedicated service is one root may later remove by its label (`host unpair --label`), which
283
+ // only ever acts on ours: the label says so.
284
+ if (spec.dedicated && spec.label !== undefined && !spec.label.startsWith(LAUNCHD_HOST_LABEL)) {
285
+ return `A dedicated service's label starts with ${LAUNCHD_HOST_LABEL} (for example ${LAUNCHD_HOST_LABEL}.office), not "${spec.label}".`;
271
286
  }
272
287
  if (setup.claudeTokenFile && !spec.dedicated)
273
288
  return '--claude-token-file is for a dedicated install (--dedicated).';
@@ -359,6 +374,247 @@ function mkdirOnce(dir) {
359
374
  throw err;
360
375
  }
361
376
  }
377
+ /**
378
+ * The home (and account) an installed dedicated service runs with, read from its definition —
379
+ * root's / the administrators' own file (`/Library/LaunchDaemons`, `/etc/systemd/system`,
380
+ * `ProgramData\\Prohost\\services`) — or undefined when none is installed under `label`. What
381
+ * an administrator's `unpair` / `uninstall` trusts, never a note in an account's home.
382
+ */
383
+ export function installedDedicatedService(platform, label, io = {}) {
384
+ if (!LABEL.test(label))
385
+ return undefined;
386
+ const read = reader(io);
387
+ const unxml = (v) => v.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
388
+ if (platform === 'darwin') {
389
+ const plist = read(path.join('/Library/LaunchDaemons', `${label}.plist`));
390
+ const home = plist && /<key>WorkingDirectory<\/key>\s*<string>([^<]*)<\/string>/.exec(plist)?.[1];
391
+ const user = plist && /<key>UserName<\/key>\s*<string>([^<]*)<\/string>/.exec(plist)?.[1];
392
+ return home ? { home: unxml(home), ...(user ? { user: unxml(user) } : {}) } : undefined;
393
+ }
394
+ if (platform === 'linux') {
395
+ const unit = read(path.join('/etc/systemd/system', systemdUnitOf({ label })));
396
+ const quoted = unit && /^WorkingDirectory="((?:[^"\\]|\\.)*)"$/m.exec(unit)?.[1];
397
+ const user = unit && /^User=(\S+)$/m.exec(unit)?.[1];
398
+ const home = quoted?.replace(/%%/g, '%').replace(/\\(.)/g, '$1');
399
+ return home ? { home, ...(user ? { user } : {}) } : undefined;
400
+ }
401
+ if (platform === 'win32') {
402
+ const wrapper = read(path.win32.join(windowsServiceDir({ label }), 'host-service.cmd'));
403
+ const home = wrapper && /^set "PROHOST_HOME=([^"\r\n]*)"\r?$/m.exec(wrapper)?.[1];
404
+ return home ? { home: home.replace(/%%/g, '%') } : undefined;
405
+ }
406
+ return undefined;
407
+ }
408
+ /**
409
+ * A dedicated service home's `computer.json` text, when it holds a key, read the safe way for an
410
+ * administrator: on macOS / Linux as the service's account (`sudo -u` / `runuser … cat`), so a link
411
+ * the account planted at `host` or `computer.json` reaches only what that account may read itself;
412
+ * on Windows from the service's administrators-only copy, never from the account's home.
413
+ */
414
+ export function readServiceCredential(platform, home, user, io = {}, label) {
415
+ const join = joinFor(platform);
416
+ const dir = join(home, 'host');
417
+ const file = join(dir, 'computer.json');
418
+ const uid = io.uid ?? process.getuid?.() ?? 1;
419
+ if (platform === 'win32') {
420
+ // Never from the account's home (it can swap a junction in there while an administrator opens
421
+ // a path, and Windows can't read as the account): from the copy the install put in the
422
+ // service's administrators-only folder (`windowsServiceCredentialPath`), read through one handle.
423
+ if (!label)
424
+ return undefined;
425
+ const opened = (() => {
426
+ try {
427
+ return (io.readHandle ?? readThroughHandle)(windowsServiceCredentialPath({ label }));
428
+ }
429
+ catch {
430
+ return undefined;
431
+ }
432
+ })();
433
+ if (!opened || !opened.isFile || opened.nlink !== 1)
434
+ return undefined;
435
+ return keyedCredential(opened.text);
436
+ }
437
+ if (uid !== 0 || !user)
438
+ return keyedCredential(reader(io)(file));
439
+ const read = asUser(io.run ?? defaultServiceRunner, platform, user, 'cat "$1"', [file]);
440
+ return read.status === 0 ? keyedCredential(read.stdout) : undefined;
441
+ }
442
+ /**
443
+ * What a dedicated service last published about its computer (`hosts\\<label>\\state.json`): its id,
444
+ * `none` when it has published nothing yet (no file, or the `{}` an install starts it with), or
445
+ * `unreadable` (a read error or JSON that does not parse, even after a retry) — never taken as none.
446
+ */
447
+ export function publishedComputer(spec, io = {}) {
448
+ const file = joinFor(spec.platform)(sharedDirFor(spec), 'state.json');
449
+ const parse = () => {
450
+ if (io.readFile) {
451
+ const text = io.readFile(file);
452
+ return text === undefined ? null : JSON.parse(text);
453
+ }
454
+ if (!(io.exists ?? existsSync)(file))
455
+ return null;
456
+ // The service rewrites it in place: a read caught mid-write is tried again (`readSharedObject`).
457
+ const state = readSharedObject(file);
458
+ if (state === undefined)
459
+ throw new Error('unreadable');
460
+ return state;
461
+ };
462
+ let state;
463
+ try {
464
+ state = parse();
465
+ }
466
+ catch {
467
+ return { kind: 'unreadable' };
468
+ }
469
+ if (state === null)
470
+ return { kind: 'none' };
471
+ if (typeof state !== 'object' || Array.isArray(state))
472
+ return { kind: 'unreadable' };
473
+ const id = state.computer?.id;
474
+ return typeof id === 'string' && id ? { kind: 'published', id } : { kind: 'none' };
475
+ }
476
+ /** The computer id a dedicated service last published, if it did (see `publishedComputer`). */
477
+ export function publishedComputerId(spec, io = {}) {
478
+ const published = publishedComputer(spec, io);
479
+ return published.kind === 'published' ? published.id : undefined;
480
+ }
481
+ /** One open: `fstat` and the text read through the same descriptor. */
482
+ function readThroughHandle(file) {
483
+ let fd;
484
+ try {
485
+ fd = openSync(file, 'r');
486
+ }
487
+ catch {
488
+ return undefined;
489
+ }
490
+ try {
491
+ const stat = fstatSync(fd);
492
+ return { isFile: stat.isFile(), nlink: stat.nlink, text: stat.isFile() ? readFileSync(fd, 'utf8') : '' };
493
+ }
494
+ finally {
495
+ closeSync(fd);
496
+ }
497
+ }
498
+ /** `computer.json`'s text when it holds a computer key — never a hand-off note (`ComputerHandoff`), which is copied nowhere. */
499
+ function keyedCredential(text) {
500
+ if (!text)
501
+ return undefined;
502
+ try {
503
+ const parsed = JSON.parse(text);
504
+ return typeof parsed.api_key === 'string' && parsed.api_key && typeof parsed.computer_id === 'string' ? text : undefined;
505
+ }
506
+ catch {
507
+ return undefined;
508
+ }
509
+ }
510
+ /**
511
+ * Write (`content`) or remove (`undefined`) `<home>/host/computer.json` in a home another user
512
+ * owns — the one a dedicated install took its computer from. As root on macOS / Linux it is done
513
+ * as the folder's owner (root never writes through a path an account controls); anyone else, and
514
+ * an administrator on Windows, writes it directly. Returns what went wrong, if anything.
515
+ */
516
+ export function rewriteHomeCredential(platform, home, content, io = {}) {
517
+ const join = joinFor(platform);
518
+ const dir = join(home, 'host');
519
+ const file = join(dir, 'computer.json');
520
+ const run = io.run ?? defaultServiceRunner;
521
+ const uid = io.uid ?? process.getuid?.() ?? 1;
522
+ if (platform === 'win32') {
523
+ // An administrator in someone else's home: never through a junction or link the account
524
+ // could have put there (`isSymlink` is true for both), and a fresh file renamed into place.
525
+ // Windows offers no owner-run write here the way `sudo -u` does elsewhere: the folders' real
526
+ // paths are checked before the write, between it and the rename, and after. That narrows a
527
+ // folder swapped in between, it does not close it (best effort; a delete has one check).
528
+ const isSymlink = io.isSymlink ?? ((f) => {
529
+ try {
530
+ return lstatSync(f).isSymbolicLink();
531
+ }
532
+ catch {
533
+ return false;
534
+ }
535
+ });
536
+ const linked = [home, dir, file, `${file}.handoff`].find((f) => isSymlink(f));
537
+ if (linked)
538
+ return `${linked} is a link or junction`;
539
+ // The folders are where they say they are: checked right before the write and again after it.
540
+ const real = io.realpath ?? ((f) => realpathSync.native(f));
541
+ const inPlace = () => {
542
+ try {
543
+ const realHome = real(home);
544
+ if (path.win32.resolve(realHome).toLowerCase() !== path.win32.resolve(home).toLowerCase())
545
+ return `${home} resolves to ${realHome}`;
546
+ const realDir = real(dir);
547
+ if (path.win32.resolve(realDir).toLowerCase() !== path.win32.join(path.win32.resolve(realHome), 'host').toLowerCase())
548
+ return `${dir} resolves to ${realDir}`;
549
+ return undefined;
550
+ }
551
+ catch (e) {
552
+ return e instanceof Error ? e.message : String(e);
553
+ }
554
+ };
555
+ const before = inPlace();
556
+ if (before)
557
+ return before;
558
+ try {
559
+ const remove = io.removeFile ?? ((f) => rmSync(f, { force: true }));
560
+ if (content === undefined) {
561
+ remove(file);
562
+ return undefined;
563
+ }
564
+ remove(`${file}.handoff`);
565
+ writer(io)(`${file}.handoff`, content, 0o600, 'wx');
566
+ const between = inPlace();
567
+ if (between)
568
+ return `${between} (moved while writing: nothing renamed)`;
569
+ (io.rename ?? renameSync)(`${file}.handoff`, file);
570
+ const after = inPlace();
571
+ if (after)
572
+ return `${after} (moved while writing: check ${file} by hand)`;
573
+ return undefined;
574
+ }
575
+ catch (e) {
576
+ return e instanceof Error ? e.message : String(e);
577
+ }
578
+ }
579
+ if (uid !== 0) {
580
+ try {
581
+ if (content === undefined)
582
+ (io.removeFile ?? ((f) => rmSync(f, { force: true })))(file);
583
+ else
584
+ writer(io)(file, content, 0o600);
585
+ return undefined;
586
+ }
587
+ catch (e) {
588
+ return e instanceof Error ? e.message : String(e);
589
+ }
590
+ }
591
+ const owner = io.ownerOf?.(dir) ?? ownerOf(dir);
592
+ if (!owner)
593
+ return `${dir} is not there`;
594
+ if (owner.link)
595
+ return `${dir} is a link`;
596
+ if (owner.uid === 0) {
597
+ try {
598
+ if (content === undefined)
599
+ rmSync(file, { force: true });
600
+ else
601
+ writer(io)(file, content, 0o600);
602
+ return undefined;
603
+ }
604
+ catch (e) {
605
+ return e instanceof Error ? e.message : String(e);
606
+ }
607
+ }
608
+ const name = run('id', ['-nu', String(owner.uid)]);
609
+ const user = name.stdout.trim();
610
+ if (name.status !== 0 || !user)
611
+ return failed(`finding the owner of ${dir}`, name);
612
+ const done = content === undefined
613
+ ? asUser(run, platform, user, 'rm -f "$1"', [file])
614
+ : // A fresh file beside it (noclobber), then renamed over: never written through.
615
+ asUser(run, platform, user, 'rm -f "$1.handoff" && set -C && cat > "$1.handoff" && mv -f "$1.handoff" "$1"', [file], content);
616
+ return done.status === 0 ? undefined : failed(`${content === undefined ? 'removing' : 'rewriting'} ${file} as ${user}`, done);
617
+ }
362
618
  function ownerOf(file) {
363
619
  try {
364
620
  const stat = lstatSync(file);
@@ -487,7 +743,7 @@ function prepareDedicated(spec, io, setup) {
487
743
  return problem;
488
744
  const hostDir = join(spec.home, 'host');
489
745
  const credential = setup.adoptFrom && path.resolve(setup.adoptFrom) !== path.resolve(spec.home)
490
- ? read(join(setup.adoptFrom, 'host', 'computer.json'))
746
+ ? keyedCredential(read(join(setup.adoptFrom, 'host', 'computer.json')))
491
747
  : undefined;
492
748
  let token;
493
749
  if (setup.claudeTokenFile) {
@@ -517,6 +773,13 @@ function prepareDedicated(spec, io, setup) {
517
773
  // in one step with no access for the account, filled, and only then opened to it — so it can
518
774
  // never swap a path in it while an administrator writes there. An existing one is never
519
775
  // written into; a --service-home elsewhere is the account's own business.
776
+ // No computer handed to this install (and not a reinstall of the one handed to it before): no
777
+ // administrators' copy of an older one may outlive it.
778
+ if (!credential && !setup.handedOffHere) {
779
+ const copy = windowsServiceCredentialPath(spec);
780
+ if (exists(copy))
781
+ remove(copy);
782
+ }
520
783
  if (path.win32.resolve(path.win32.dirname(spec.home)) === path.win32.resolve(windowsHomesDir())) {
521
784
  const made = (io.mkdirOnce ?? mkdirOnce)(spec.home);
522
785
  if (made === 'exists') {
@@ -525,6 +788,13 @@ function prepareDedicated(spec, io, setup) {
525
788
  const owner = windowsOwner(run, spec.home);
526
789
  if (!/\\(Administrators|SYSTEM)$/i.test(owner))
527
790
  return `${spec.home} is owned by ${owner || 'someone else'}: remove it, then install again.`;
791
+ // A computer handed to a home that already exists but has no administrators' copy of its key
792
+ // (uninstalled before, or installed before 0.16.5): nothing here could vouch for the key in
793
+ // it, so it would run without the hand-off. A fresh home gets both.
794
+ // (A copy that is there but holds no usable key, e.g. an interrupted write, counts as none.)
795
+ if (credential && !setup.handedOffHere && !keyedCredential(read(windowsServiceCredentialPath(spec)))) {
796
+ return `${spec.home} is left from an earlier install and has no administrators' copy of its key: remove that folder, then install again.`;
797
+ }
528
798
  }
529
799
  else {
530
800
  for (const args of [
@@ -536,8 +806,14 @@ function prepareDedicated(spec, io, setup) {
536
806
  return failed(`icacls ${args.slice(0, 2).join(' ')}`, out);
537
807
  }
538
808
  mkdir(hostDir, 0o700);
539
- if (credential)
809
+ if (credential) {
540
810
  write(join(hostDir, 'computer.json'), credential, 0o600, 'wx');
811
+ // The administrators' copy, in the folder just claimed: what root-level commands read.
812
+ const copy = windowsServiceCredentialPath(spec);
813
+ if (exists(copy))
814
+ remove(copy);
815
+ write(copy, credential, 0o600, 'wx');
816
+ }
541
817
  }
542
818
  // The account may now use its home.
543
819
  const open = run('icacls', [spec.home, '/grant', `${user}:(OI)(CI)M`]);
@@ -654,8 +930,13 @@ export function installService(spec, io = {}, setup = {}) {
654
930
  const plist = buildHostPlist(spec);
655
931
  const loaded = run('launchctl', ['print', `${domain}/${label}`]).status === 0;
656
932
  // Re-running it changes nothing that is already right: the service keeps
657
- // working through every app update that didn't move it.
933
+ // working through every app update that didn't move it. Enablement is
934
+ // persistent state separate from whether launchd currently has the job
935
+ // loaded, so repair it before returning through this fast path.
658
936
  if (loaded && read(file) === plist) {
937
+ const enable = run('launchctl', ['enable', `${domain}/${label}`]);
938
+ if (enable.status !== 0)
939
+ return { ok: false, file, message: `Wrote ${file}, but ${failed('launchctl enable', enable)}` };
659
940
  return { ok: true, file, message: `✓ ${label} is installed and running (${file})\n logs: ${logs}` };
660
941
  }
661
942
  write(file, plist, spec.dedicated ? 0o644 : 0o600);
@@ -673,12 +954,15 @@ export function installService(spec, io = {}, setup = {}) {
673
954
  timeoutMs: RESTART_WAIT_MS,
674
955
  onWaiting: (line) => process.stderr.write(`${line.replace('agent', 'host service')}\n`),
675
956
  });
957
+ // A disabled label cannot load: enabling must succeed before bootstrap.
958
+ const enable = run('launchctl', ['enable', `${domain}/${label}`]);
959
+ if (enable.status !== 0)
960
+ return { ok: false, file, message: `Wrote ${file}, but ${failed('launchctl enable', enable)}` };
676
961
  const boot = run('launchctl', ['bootstrap', domain, file]);
677
962
  if (boot.status !== 0) {
678
963
  const still = oldGone ? '' : ` (the previous host service was still finishing its runs after ${RESTART_WAIT_MS / 1000}s)`;
679
964
  return { ok: false, file, message: `Wrote ${file}, but ${failed('launchctl bootstrap', boot)}${still}` };
680
965
  }
681
- run('launchctl', ['enable', `${domain}/${label}`]);
682
966
  return { ok: true, file, message: `✓ Installed ${label} (${file})\n logs: ${logs}` };
683
967
  }
684
968
  if (spec.platform === 'linux') {
@@ -768,6 +1052,32 @@ export function uninstallService(spec, io = {}) {
768
1052
  if (exists(file))
769
1053
  remove(file);
770
1054
  }
1055
+ // The administrators' copy of its key goes with the service: a reinstall never finds an old one.
1056
+ const copy = windowsServiceCredentialPath(spec);
1057
+ if (out.status === 0 && exists(copy)) {
1058
+ try {
1059
+ remove(copy);
1060
+ }
1061
+ catch (e) {
1062
+ notes.push(`could not delete ${copy} (${e instanceof Error ? e.message : String(e)}): delete it yourself`);
1063
+ }
1064
+ }
1065
+ }
1066
+ // A dedicated service gone: its published state goes too, so nothing (the desktop's note check)
1067
+ // reads a service that ran once as one that is installed.
1068
+ // (Windows: a task by that label, removed with or without --dedicated; only a dedicated one publishes state.)
1069
+ const dedicatedGone = spec.platform === 'win32'
1070
+ ? removed.length > 0
1071
+ : removed.some((file) => file === serviceFilePath({ ...spec, dedicated: true }));
1072
+ if (dedicatedGone) {
1073
+ const state = joinFor(spec.platform)(sharedDirFor(spec), 'state.json');
1074
+ try {
1075
+ if (exists(state))
1076
+ remove(state);
1077
+ }
1078
+ catch {
1079
+ /* the next install rewrites it */
1080
+ }
771
1081
  }
772
1082
  if (removed.length === 0 && notes.length === 0) {
773
1083
  return { ok: true, message: 'No host service was installed. Nothing to remove.' };
@@ -49,7 +49,7 @@ import { loadCredentials, prohostHome } from '../agent/credentials.js';
49
49
  import { LAUNCHD_LABEL_PREFIX } from '../agent/daemon.js';
50
50
  import { resolveAgentAccount } from '../agent/accounts.js';
51
51
  import { PAIRING_ADOPT_PATH, callJson } from './api.js';
52
- import { machineFacts } from './computer.js';
52
+ import { handoffNotice, machineFacts, readHandoff } from './computer.js';
53
53
  import { credentialsFrom } from './pair.js';
54
54
  import { isCodexExec } from '../agent/codex.js';
55
55
  import { SessionStore } from '../agent/sessionstore.js';
@@ -328,6 +328,10 @@ export async function adoptComputer(options) {
328
328
  const env = options.env ?? process.env;
329
329
  if (tryLoadComputer(env))
330
330
  return { status: 'already_paired' };
331
+ // Handed to a dedicated service: never a second key over its note.
332
+ const handedOff = readHandoff(env);
333
+ if (handedOff)
334
+ return { status: 'not_adopted', reason: handoffNotice(handedOff) };
331
335
  let facts;
332
336
  try {
333
337
  facts = options.facts ?? machineFacts({ env, name: options.name });
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import type { ProhostAccount } from '../agent/credentials.js';
23
23
  import type { ComputerCredentials, MachineFacts } from './computer.js';
24
- export declare const HOST_PAIR_OUTCOMES: readonly ["paired", "expired", "token_rejected", "workspace_unconfirmed", "declined", "rate_limited", "unreachable", "invalid_arguments", "failed"];
24
+ export declare const HOST_PAIR_OUTCOMES: readonly ["paired", "expired", "token_rejected", "workspace_unconfirmed", "declined", "rate_limited", "unreachable", "invalid_arguments", "handed_off", "failed"];
25
25
  export type HostPairOutcome = (typeof HOST_PAIR_OUTCOMES)[number];
26
26
  export interface HostPairResult {
27
27
  version: 1;
package/dist/host/pair.js CHANGED
@@ -38,6 +38,8 @@ export const HOST_PAIR_OUTCOMES = [
38
38
  'rate_limited',
39
39
  'unreachable',
40
40
  'invalid_arguments',
41
+ /** This home handed its computer to a dedicated service (`host install --dedicated`): it pairs nothing. */
42
+ 'handed_off',
41
43
  'failed',
42
44
  ];
43
45
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -38,7 +38,7 @@ import { HeartbeatLoop } from '../agent/heartbeat.js';
38
38
  import { executeHostedRun } from '../agent/run.js';
39
39
  import { SessionStore, sessionKeyFor } from '../agent/sessionstore.js';
40
40
  import { verifySignature } from '../agent/signature.js';
41
- import { CODE_THREAD_FOLDERS_CAPABILITY, CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY, CODE_THREADS_CAPABILITY, CodeHost, EVENT_CODE_THREAD_APPROVAL_DECIDED, EVENT_CODE_THREAD_INPUT_REQUESTED, EVENT_CODE_THREAD_START_REQUESTED, REMOTE_TERMINAL_CAPABILITY, RefusedDeliveryError, } from '../codehost/host.js';
41
+ import { CODE_THREAD_FOLDERS_CAPABILITY, CODE_THREAD_RESTART_SKIP_PERMISSIONS_CAPABILITY, CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY, CODE_THREADS_CAPABILITY, CodeHost, EVENT_CODE_THREAD_APPROVAL_DECIDED, EVENT_CODE_THREAD_INPUT_REQUESTED, EVENT_CODE_THREAD_RESTART_REQUESTED, EVENT_CODE_THREAD_START_REQUESTED, REMOTE_TERMINAL_CAPABILITY, RefusedDeliveryError, } from '../codehost/host.js';
42
42
  import { HostStatsSampler } from '../codehost/host_stats.js';
43
43
  import { TERMINAL_SESSIONS_ENV, loadSettings as loadCodeHostSettings, recordServiceSwitch } from '../codehost/settings.js';
44
44
  import { isWebhookEvent } from '../forward.js';
@@ -248,8 +248,12 @@ async function serveHost(options) {
248
248
  if (codeHost?.authorized() && config.terminal_sessions_enabled) {
249
249
  caps.push(CODE_THREADS_CAPABILITY, CODE_THREAD_FOLDERS_CAPABILITY, codeHost.instanceCapability());
250
250
  caps.push(...(codeHost.agentCapabilities?.() ?? []));
251
- if (codeHost.remoteTerminalAllowed())
251
+ if (codeHost.remoteTerminalAllowed()) {
252
252
  caps.push(REMOTE_TERMINAL_CAPABILITY, CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY);
253
+ // Relaunching a running thread's agent without prompts (`code_thread.restart_requested`): the same rule.
254
+ if (codeHost.handleRestart)
255
+ caps.push(CODE_THREAD_RESTART_SKIP_PERMISSIONS_CAPABILITY);
256
+ }
253
257
  }
254
258
  return caps;
255
259
  }
@@ -972,7 +976,8 @@ async function serveHost(options) {
972
976
  }
973
977
  if (frame.event === EVENT_CODE_THREAD_START_REQUESTED ||
974
978
  frame.event === EVENT_CODE_THREAD_INPUT_REQUESTED ||
975
- frame.event === EVENT_CODE_THREAD_APPROVAL_DECIDED) {
979
+ frame.event === EVENT_CODE_THREAD_APPROVAL_DECIDED ||
980
+ frame.event === EVENT_CODE_THREAD_RESTART_REQUESTED) {
976
981
  if (!codeHost || !config.terminal_sessions_enabled) {
977
982
  ack(ACK_IGNORED);
978
983
  return;
@@ -1004,7 +1009,9 @@ async function serveHost(options) {
1004
1009
  ? codeHost.handleStart(data)
1005
1010
  : frame.event === EVENT_CODE_THREAD_INPUT_REQUESTED
1006
1011
  ? codeHost.handleInput(data, frame.delivery_id)
1007
- : codeHost.handleApproval(data, frame.delivery_id);
1012
+ : frame.event === EVENT_CODE_THREAD_RESTART_REQUESTED
1013
+ ? (codeHost.handleRestart?.(data, frame.delivery_id) ?? Promise.resolve(false))
1014
+ : codeHost.handleApproval(data, frame.delivery_id);
1008
1015
  background.push(handled
1009
1016
  .then((ok) => {
1010
1017
  ack(starting || ok ? ACK_ACCEPTED : ACK_IGNORED);
package/dist/index.js CHANGED
@@ -33,6 +33,7 @@ function printHelp() {
33
33
  ' prohost host pair [--name <name>] [--token <redeem-token>|-] [--expect-account <workspace-id>]',
34
34
  ' Add this computer to a workspace (prints a code to enter in ProhostAI)',
35
35
  ' prohost host unpair Remove this computer from its workspace (revokes its key)',
36
+ ' sudo prohost host unpair --label <id> [--service-user <name>] Remove a dedicated service\'s computer and uninstall it',
36
37
  ' prohost host run One service for every AI employee placed on this computer',
37
38
  ' prohost host install [--label <id>] [--dedicated --service-user <name> [--service-home <dir>]',
38
39
  ' [--claude-token-file <path>] [--remote-terminal|--no-remote-terminal]] | uninstall [--label <id>]',
@@ -81,7 +82,10 @@ function printHelp() {
81
82
  ' or one derived from $PROHOST_HOME. Re-running install changes nothing that is right.',
82
83
  ' Under the ProhostAI desktop app, host install keeps ELECTRON_RUN_AS_NODE=1 in the service it writes',
83
84
  ' (and the PATH it was launched with), so the service runs the CLI, never the app.',
84
- ' `host unpair` removes this computer from its workspace and deletes its key here.',
85
+ ' `host unpair` removes this computer from its workspace and deletes its key here. A dedicated install',
86
+ ' takes the key over: the home it came from keeps a note instead, and `unpair` there',
87
+ ' points at `sudo prohost host unpair --label <id>`, which uses the service\'s key, uninstalls',
88
+ ' the service and clears both homes.',
85
89
  ' `host logins add` signs in another Claude Code / Codex login in this terminal.',
86
90
  ' `host pause` takes no new work until `host resume`; work already going finishes.',
87
91
  ' `host migrate` adopts existing agent homes and the desktop app\'s terminal-hosting home, keeping',
package/dist/version.d.ts CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export declare const CLI_VERSION = "0.16.4";
10
+ export declare const CLI_VERSION = "0.16.6";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.16.4";
12
+ export declare const USER_AGENT = "prohost-cli/0.16.6";
package/dist/version.js CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export const CLI_VERSION = '0.16.4';
10
+ export const CLI_VERSION = '0.16.6';
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
12
  export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.16.4",
3
+ "version": "0.16.6",
4
4
  "description": "Run your own AI agent as a ProhostAI teammate, and stream your account's webhooks to your laptop.",
5
5
  "type": "module",
6
6
  "license": "MIT",