@prohost/cli 0.16.3 → 0.16.5

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.
@@ -24,12 +24,12 @@ import { claudeQuotaProvider, codexQuotaProvider } from '../agent/runtime_status
24
24
  import { readPipedKey } from '../usage/key-input.js';
25
25
  import { CLI_VERSION } from '../version.js';
26
26
  import { recommendCapacity } from './capacity.js';
27
- import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
27
+ import { existsSync, lstatSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
28
28
  import { addAccount, isValidLabel, loadAccounts, parseAccountRuntime, accountEnv } from '../agent/accounts.js';
29
29
  import { revokeComputer } from './api.js';
30
- import { readSharedObject, acquireHostLock, clearComputer, computerOs, controlPath, hostLockPath, readObject, statePath, tryLoadComputer, writePrivateFile, } from './computer.js';
30
+ import { readSharedObject, acquireHostLock, clearComputer, computerOs, controlPath, handoffNotice, handoffOf, hostLockPath, SERVICE_LABEL, readHandoff, readObject, statePath, tryLoadComputer, writePrivateFile, } from './computer.js';
31
31
  import { journalPath, readEvents } from './events.js';
32
- import { LAUNCHD_HOST_LABEL, defaultLabel, defaultServiceRunner, installService, windowsTaskOf, serviceFilePath, serviceUserHome, sharedBase, sharedDirFor, uninstallService, } from './install.js';
32
+ import { LAUNCHD_HOST_LABEL, defaultLabel, defaultServiceRunner, installService, installedDedicatedService, publishedComputer, publishedComputerId, readServiceCredential, rewriteHomeCredential, windowsServiceCredentialPath, windowsHomesDir, windowsTaskOf, serviceFilePath, serviceUserHome, sharedBase, sharedDirFor, uninstallService, } from './install.js';
33
33
  import { LegacyHarnesses } from './legacy.js';
34
34
  import { detectLogins } from './logins.js';
35
35
  import { adoptComputer, loadAdopted, runMigrate } from './migrate.js';
@@ -97,6 +97,7 @@ export function hardwareFacts() {
97
97
  */
98
98
  export function statusJson(env, now = Date.now(), options = {}) {
99
99
  const computer = tryLoadComputer(env);
100
+ const handoff = options.label ? undefined : readHandoff(env);
100
101
  // A dedicated service is reported only when asked for by its `--label`, and then only from what
101
102
  // it publishes: unlabelled, status is about this home's own service, whatever else runs on the
102
103
  // computer; labelled, nothing of this home's (its pause, its pairing, its lock) is mixed in.
@@ -118,13 +119,18 @@ export function statusJson(env, now = Date.now(), options = {}) {
118
119
  const events = readEvents(labelDir ? path.join(labelDir, 'events.json') : journalPath(env), now);
119
120
  return {
120
121
  // Reporting the dedicated service: its own computer, never this home's (they can differ).
121
- paired: labelDir ? sharedComputer !== undefined : computer !== null,
122
+ // A home that handed its computer to a dedicated service is still that computer's (served
123
+ // elsewhere, `handed_to`): paired, offline here — never read as "not added", which would invite
124
+ // a second pairing beside the service that holds the key.
125
+ paired: labelDir ? sharedComputer !== undefined : computer !== null || handoff !== undefined,
122
126
  computer: labelDir
123
127
  ? (sharedComputer ?? null)
124
128
  : computer
125
129
  ? { id: computer.computer_id, name: (fresh && state?.name) || computer.name, account: computer.account }
126
- : null,
127
- online: Boolean(fresh && state?.online),
130
+ : handoff
131
+ ? { id: handoff.computer_id, name: handoff.name, account: handoff.account }
132
+ : null,
133
+ online: Boolean(!handoff && fresh && state?.online),
128
134
  paused,
129
135
  slots: fresh && state ? state.slots : slotsFor(capacity, 0, paused ? 'paused' : null),
130
136
  running: fresh && state ? state.running.map(localRunning) : [],
@@ -141,11 +147,19 @@ export function statusJson(env, now = Date.now(), options = {}) {
141
147
  ...(labelDir ? { activity_file: path.join(labelDir, 'activity.json') } : {}),
142
148
  },
143
149
  remote_terminal: Boolean(fresh && state?.remote_terminal),
150
+ // This home handed its computer to a dedicated service: where to look instead.
151
+ ...(handoff ? { handed_to: { label: handoff.handed_to.label, service_user: handoff.handed_to.service_user, service_home: handoff.handed_to.service_home } } : {}),
144
152
  version: CLI_VERSION,
145
153
  };
146
154
  }
147
155
  function printStatus(json, out) {
148
156
  const computer = json.computer;
157
+ const handedTo = json.handed_to;
158
+ if (handedTo) {
159
+ out(`This computer runs as the dedicated service ${handedTo.label}${handedTo.service_user ? ` (user ${handedTo.service_user})` : ''}. ` +
160
+ `See it with: prohost host status --label ${handedTo.label}`);
161
+ return;
162
+ }
149
163
  if (!computer) {
150
164
  out('This computer is not added to a workspace yet. Run: prohost host pair');
151
165
  return;
@@ -285,6 +299,207 @@ async function addLogin(flags, env, out, err, io) {
285
299
  out(`✓ Added "${label}". It shows in prohost host logins.`);
286
300
  return 0;
287
301
  }
302
+ /**
303
+ * `sudo prohost host unpair --label <label>` (or `--dedicated`, the label read from this home's
304
+ * hand-off note): remove a dedicated service's computer. As root / an administrator it reads the
305
+ * key from the service's own home — the one its installed definition names (root's file), never a
306
+ * path from the note, which the home's owner can edit — removes the computer from its workspace,
307
+ * uninstalls the service (stopped and its definition removed: without a key it could only fail),
308
+ * deletes the key there and clears the note in the home it was handed from. Nothing is changed
309
+ * when the removal is refused. Exits 0 once the computer is removed, even if some local cleanup
310
+ * could not be done (each such step is reported).
311
+ */
312
+ async function unpairDedicated(flags, env, out, err, io, platform) {
313
+ const serviceIo = io.serviceIo ?? {};
314
+ const run = serviceIo.run ?? defaultServiceRunner;
315
+ const admin = serviceIo.isAdmin ??
316
+ (platform === 'win32' ? run('net', ['session']).status === 0 : (serviceIo.uid ?? process.getuid?.() ?? 1) === 0);
317
+ if (!admin) {
318
+ err(platform === 'win32'
319
+ ? '✗ A dedicated service is removed from an administrator prompt.'
320
+ : '✗ A dedicated service is removed as root: run it with sudo.');
321
+ return 1;
322
+ }
323
+ const fromHome = handedFromHome(flags, env, platform);
324
+ const note = readHandoff({ ...env, PROHOST_HOME: fromHome });
325
+ const explicit = str(flags, 'label');
326
+ // The note is only a hint for the label: everything else comes from what root installed.
327
+ const label = explicit ?? note?.handed_to.label;
328
+ if (!label) {
329
+ err('✗ Which dedicated service? Pass --label <label> (prohost host status --label <label> shows it).');
330
+ return 1;
331
+ }
332
+ if (!SERVICE_LABEL.test(label) || !label.startsWith(LAUNCHD_HOST_LABEL)) {
333
+ err(`✗ "${label}" is not a label this CLI installs services under.`);
334
+ return 1;
335
+ }
336
+ const installed = installedDedicatedService(platform, label, serviceIo);
337
+ if (!installed) {
338
+ err(`✗ No dedicated service ${label} is installed here (no service definition for it): remove the computer on its page in ProhostAI.`);
339
+ return 1;
340
+ }
341
+ const flagUser = str(flags, 'service-user');
342
+ if (flagUser && installed.user && flagUser !== installed.user) {
343
+ err(`✗ ${label} runs as ${installed.user}, not ${flagUser}: nothing was changed.`);
344
+ return 1;
345
+ }
346
+ const serviceUser = installed.user ?? flagUser;
347
+ const serviceHome = installed.home;
348
+ const noted = note?.handed_to.label === label ? note : undefined;
349
+ if (noted && path.resolve(noted.handed_to.service_home) !== path.resolve(serviceHome)) {
350
+ err(`✗ The note in ${fromHome} says ${label} lives in ${noted.handed_to.service_home}, but it is installed with ${serviceHome}: nothing was changed.`);
351
+ return 1;
352
+ }
353
+ // The home the definition names must be the service account's own folder, not a link to elsewhere.
354
+ const homeProblem = serviceHomeProblem(platform, serviceHome, serviceUser, serviceIo);
355
+ if (homeProblem) {
356
+ err(`✗ ${homeProblem}: nothing was changed.`);
357
+ return 1;
358
+ }
359
+ const computer = serviceCredential(platform, serviceHome, serviceUser, serviceIo, label);
360
+ // Named only by the note: only the service that holds that very computer.
361
+ if (!explicit && note && computer && computer.computer_id !== note.computer_id) {
362
+ err(`✗ ${label} runs a different computer than the note in ${fromHome} names: nothing was changed. Pass --label to name the service.`);
363
+ return 1;
364
+ }
365
+ const uninstall = () => {
366
+ const outcome = uninstallService({
367
+ platform,
368
+ dedicated: true,
369
+ ...(serviceUser ? { serviceUser } : {}),
370
+ nodePath: process.execPath,
371
+ scriptPath: scriptPath(),
372
+ home: serviceHome,
373
+ pathEnv: env.PATH ?? '',
374
+ userHome: env.HOME || os.homedir(),
375
+ label,
376
+ }, serviceIo);
377
+ (outcome.ok ? out : err)(` ${outcome.message}`);
378
+ return outcome.ok;
379
+ };
380
+ if (computer && platform === 'win32' && publishedComputerId({ platform, label }, serviceIo) !== computer.computer_id) {
381
+ // The copy is an install-time snapshot: the service now runs another computer (it re-paired),
382
+ // or publishes none. Root never revokes the old one, nor uninstalls the new one, on a guess.
383
+ err(`✗ ${label} no longer publishes the computer its key on record is for: nothing was changed. ` +
384
+ 'Sign the computer out on its page in ProhostAI (AI Employees › Computers).');
385
+ return 1;
386
+ }
387
+ if (!computer && platform === 'win32') {
388
+ // No administrators' copy of the key (installed by a CLI before 0.16.5, or never paired): the
389
+ // key in the account's home is never read by an administrator, so nothing is changed here.
390
+ err(`✗ ${label} has no administrators' copy of its key (${windowsServiceCredentialPath({ label })}): nothing was changed. ` +
391
+ `Remove the computer on its page in ProhostAI, then: prohost host uninstall --label ${label} --dedicated`);
392
+ return 1;
393
+ }
394
+ if (!computer) {
395
+ // Nothing to remove it with: the service can't run either. Only a service named on purpose is
396
+ // uninstalled (never one picked by the note alone); its note goes with it.
397
+ err(`✗ ${serviceHome} holds no computer key for ${label}: remove the computer on its page in ProhostAI.`);
398
+ if (explicit) {
399
+ uninstall();
400
+ if (noted)
401
+ clearHandedHome(platform, fromHome, 'the note', serviceIo, err);
402
+ }
403
+ else {
404
+ err(` To uninstall it anyway: sudo prohost host unpair --label ${label}`);
405
+ }
406
+ return 1;
407
+ }
408
+ const revoked = await revokeComputer({ baseUrl: computer.base_url, apiKey: computer.api_key, fetchImpl: io.fetchImpl }, computer.computer_id);
409
+ if (revoked.result === 'failed') {
410
+ err(`✗ ProhostAI did not remove this computer (${revoked.status === 0 ? revoked.error ?? 'no answer' : `HTTP ${revoked.status}`}). ` +
411
+ 'Nothing was changed; try again, or remove it on its page in ProhostAI.');
412
+ return 1;
413
+ }
414
+ if (platform === 'win32' && revoked.status === 401) {
415
+ // The administrators' copy is an install-time snapshot: a key refused may only mean it was
416
+ // re-paired since, and the service's live key is never read here. Never cleaned up on a guess.
417
+ err(`✗ ProhostAI no longer accepts the key on record for ${label} (re-paired since it was installed?): nothing was changed. ` +
418
+ `Remove the computer on its page in ProhostAI, then: prohost host uninstall --label ${label} --dedicated`);
419
+ return 1;
420
+ }
421
+ out(revoked.result === 'revoked' ? `✓ Removed "${computer.name}" from its workspace.` : `✓ "${computer.name}" was already removed from its workspace.`);
422
+ let clean = uninstall();
423
+ clean = clearHandedHome(platform, serviceHome, 'its key', serviceIo, err) && clean;
424
+ if (platform === 'win32') {
425
+ try {
426
+ (serviceIo.removeFile ?? ((f) => rmSync(f, { force: true })))(windowsServiceCredentialPath({ label }));
427
+ }
428
+ catch (e) {
429
+ clean = false;
430
+ err(` ✗ Could not delete ${windowsServiceCredentialPath({ label })} (${e instanceof Error ? e.message : String(e)}): delete it yourself.`);
431
+ }
432
+ }
433
+ // The home it was handed from: its note, or — installed by a CLI before 0.16.5 — its own copy of this key.
434
+ const fromCopy = tryLoadComputer({ ...env, PROHOST_HOME: fromHome });
435
+ if ((noted || fromCopy?.computer_id === computer.computer_id) && path.resolve(fromHome) !== path.resolve(serviceHome)) {
436
+ clean = clearHandedHome(platform, fromHome, noted ? 'the note' : 'the old copy of its key', serviceIo, err) && clean;
437
+ }
438
+ out(clean ? ` ${label} is uninstalled and this computer's key is gone.` : ` The computer is removed; finish the steps above by hand.`);
439
+ return 0;
440
+ }
441
+ /**
442
+ * What is wrong with the service home an installed definition names, if anything: on macOS / Linux
443
+ * it must be a real folder (no link) owned by the service account; on Windows, the administrators'
444
+ * folder for it (`windowsHomesDir`), no junction.
445
+ */
446
+ function serviceHomeProblem(platform, home, user, serviceIo) {
447
+ if (platform === 'win32') {
448
+ const parent = path.win32.resolve(path.win32.dirname(home));
449
+ if (parent !== path.win32.resolve(windowsHomesDir()))
450
+ return `${home} is not a dedicated service home (${windowsHomesDir()})`;
451
+ const isSymlink = serviceIo.isSymlink ?? ((f) => {
452
+ try {
453
+ return lstatSync(f).isSymbolicLink();
454
+ }
455
+ catch {
456
+ return false;
457
+ }
458
+ });
459
+ return isSymlink(home) ? `${home} is a link or junction` : undefined;
460
+ }
461
+ const owner = serviceIo.ownerOf?.(home) ?? (() => {
462
+ try {
463
+ const stat = lstatSync(home);
464
+ return { uid: stat.uid, link: stat.isSymbolicLink() };
465
+ }
466
+ catch {
467
+ return undefined;
468
+ }
469
+ })();
470
+ if (!owner)
471
+ return `${home} is not there`;
472
+ if (owner.link)
473
+ return `${home} is a link`;
474
+ if (!user)
475
+ return `${home}: the service's account is unknown`;
476
+ const id = (serviceIo.run ?? defaultServiceRunner)('id', ['-u', user]);
477
+ if (id.status !== 0 || Number.parseInt(id.stdout.trim(), 10) !== owner.uid)
478
+ return `${home} is not owned by ${user}`;
479
+ return undefined;
480
+ }
481
+ /** A dedicated service home's key, read as `readServiceCredential` reads it (never through the account's links). */
482
+ function serviceCredential(platform, home, user, serviceIo, label) {
483
+ const text = readServiceCredential(platform, home, user, serviceIo, label);
484
+ if (!text)
485
+ return null;
486
+ try {
487
+ return JSON.parse(text);
488
+ }
489
+ catch {
490
+ return null;
491
+ }
492
+ }
493
+ /** The home a dedicated install took its computer from: `--from-home`, else (under sudo `$PROHOST_HOME` is root's) `$SUDO_USER`'s. */
494
+ function handedFromHome(flags, env, platform) {
495
+ return str(flags, 'from-home') ?? (env.SUDO_USER && !env.PROHOST_HOME ? homeOfUser(platform, env.SUDO_USER) : prohostHome(env));
496
+ }
497
+ function clearHandedHome(platform, home, what, serviceIo, err) {
498
+ const problem = rewriteHomeCredential(platform, home, undefined, serviceIo);
499
+ if (problem)
500
+ err(` ✗ Could not delete ${what} in ${home} (${problem}): delete ${path.join(home, 'host', 'computer.json')} yourself.`);
501
+ return !problem;
502
+ }
288
503
  export async function runHost(subcommand, flags, printHelp, io = {}) {
289
504
  const env = io.env ?? process.env;
290
505
  const out = io.out ?? ((line) => process.stdout.write(`${line}\n`));
@@ -292,6 +507,13 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
292
507
  const platform = io.platform ?? process.platform;
293
508
  switch (subcommand) {
294
509
  case 'pair': {
510
+ const handedOff = readHandoff(env);
511
+ if (handedOff) {
512
+ const result = { version: 1, outcome: 'handed_off', computer_id: handedOff.computer_id, account: handedOff.account, message: handoffNotice(handedOff) };
513
+ writeHostPairResult(result, env, err);
514
+ err(`✗ ${result.message}`);
515
+ return 1;
516
+ }
295
517
  const baseUrl = str(flags, 'base-url') ?? env.PROHOST_BASE_URL ?? DEFAULT_API_BASE_URL;
296
518
  const problem = baseUrlProblem(baseUrl);
297
519
  if (problem) {
@@ -341,6 +563,12 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
341
563
  }
342
564
  }
343
565
  case 'run': {
566
+ const handedOff = readHandoff(env);
567
+ if (handedOff) {
568
+ // The dedicated service runs this computer (and any homes adopted here are its business now).
569
+ err(`✗ ${handoffNotice(handedOff)}`);
570
+ return 1;
571
+ }
344
572
  const computer = tryLoadComputer(env);
345
573
  const adopted = loadAdopted(env);
346
574
  if (!computer && !adopted?.homes.length) {
@@ -444,17 +672,65 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
444
672
  // A dedicated service is labelled after its own home: without --label,
445
673
  // every one this computer publishes state for is looked at too.
446
674
  const labels = new Set([spec.label]);
447
- const admin = platform === 'win32' ? dedicated : (io.serviceIo?.uid ?? process.getuid?.() ?? 1) === 0;
675
+ // Elevated, whatever the flags say (on Windows an administrator prompt, as unpair checks it).
676
+ const admin = io.serviceIo?.isAdmin ??
677
+ (platform === 'win32'
678
+ ? dedicated || (io.serviceIo?.run ?? defaultServiceRunner)('net', ['session']).status === 0
679
+ : (io.serviceIo?.uid ?? process.getuid?.() ?? 1) === 0);
448
680
  if (!str(flags, 'label') && (admin || dedicated)) {
449
681
  for (const dir of sharedDirs(io.sharedBase ?? sharedBase(platform)))
450
682
  labels.add(path.basename(dir));
451
683
  }
452
- const outcomes = [...labels].map((label) => uninstallService({ ...spec, label }, io.serviceIo));
684
+ // A home that handed its computer to one of these services gets the key back once that
685
+ // service is gone (the mirror of install): `prohost host install` runs it there again.
686
+ const fromHome = admin ? handedFromHome(flags, env, platform) : undefined;
687
+ const note = fromHome ? readHandoff({ ...env, PROHOST_HOME: fromHome }) : undefined;
688
+ const handedTo = note && labels.has(note.handed_to.label) ? installedDedicatedService(platform, note.handed_to.label, io.serviceIo) : undefined;
689
+ // Read before the service goes (on Windows its administrators' copy goes with it), and only
690
+ // from the home the definition names when it is the service account's own folder.
691
+ const handedKey = note && handedTo && !serviceHomeProblem(platform, handedTo.home, handedTo.user, io.serviceIo ?? {})
692
+ ? serviceCredential(platform, handedTo.home, handedTo.user, io.serviceIo ?? {}, note.handed_to.label)
693
+ : null;
694
+ // Windows reads the administrators' copy (an install-time snapshot): it is given back while the
695
+ // service publishes that same computer, or publishes none yet (never started, failed early)
696
+ // and the copy is for the computer the note names. Another computer published: never.
697
+ // A state that can't be read is not "none published": then nothing is given back.
698
+ const published = platform === 'win32' && note ? publishedComputer({ platform, label: note.handed_to.label }, io.serviceIo) : undefined;
699
+ const handedKeyLive = handedKey &&
700
+ (platform !== 'win32' ||
701
+ (published?.kind === 'none' ? handedKey.computer_id === note?.computer_id : published?.kind === 'published' && published.id === handedKey.computer_id))
702
+ ? handedKey
703
+ : null;
704
+ const outcomes = [...labels].map((label) => ({ label, ...uninstallService({ ...spec, label }, io.serviceIo) }));
453
705
  const removed = outcomes.filter((o) => !o.message.startsWith('No host service'));
454
706
  const shown = removed.length > 0 ? removed : outcomes.slice(0, 1);
455
707
  for (const o of shown)
456
708
  (o.ok ? out : err)(o.message);
457
- return shown.every((o) => o.ok) ? 0 : 1;
709
+ let ok = shown.every((o) => o.ok);
710
+ if (note && fromHome && outcomes.some((o) => o.label === note.handed_to.label && o.ok)) {
711
+ const key = handedKeyLive;
712
+ if (key && key.computer_id === note.computer_id && handedTo) {
713
+ const problem = rewriteHomeCredential(platform, fromHome, `${JSON.stringify(key, null, 2)}\n`, io.serviceIo);
714
+ if (problem) {
715
+ ok = false;
716
+ err(` ✗ Could not give the computer's key back to ${fromHome} (${problem}); it stays in ${handedTo.home}.`);
717
+ }
718
+ else {
719
+ // On Windows the (administrators') service home keeps its copy: a later dedicated
720
+ // install finds that home and writes nothing into it, so it must still hold the key.
721
+ if (platform !== 'win32')
722
+ clearHandedHome(platform, handedTo.home, 'its key', io.serviceIo ?? {}, err);
723
+ out(` The computer's key is back in ${fromHome}: prohost host install runs it there (prohost host unpair removes it).`);
724
+ if (platform === 'win32')
725
+ out(` To install it as a dedicated service again, first remove the folder ${handedTo.home}.`);
726
+ }
727
+ }
728
+ else {
729
+ clearHandedHome(platform, fromHome, 'the note', io.serviceIo ?? {}, err);
730
+ out(` ${fromHome} no longer points at ${note.handed_to.label}. Its key was not found: add the computer again with prohost host pair, or remove it on its page in ProhostAI.`);
731
+ }
732
+ }
733
+ return ok ? 0 : 1;
458
734
  }
459
735
  // 0.16.0 put a Windows dedicated service's home in the account's profile, under another
460
736
  // label: replacing it here would leave two services on one key. Its owner removes it first.
@@ -469,12 +745,21 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
469
745
  return 1;
470
746
  }
471
747
  }
748
+ // A home whose computer went to a dedicated service has no key to run a service of its own on.
749
+ const ownHandoff = dedicated ? undefined : readHandoff(env);
750
+ if (ownHandoff) {
751
+ err(`✗ ${handoffNotice(ownHandoff)}`);
752
+ return 1;
753
+ }
472
754
  // The invoking user's home: under sudo, $PROHOST_HOME and $HOME are root's.
473
755
  const adoptFrom = str(flags, 'from-home') ??
474
756
  (env.SUDO_USER && !env.PROHOST_HOME ? homeOfUser(platform, env.SUDO_USER) : ownHome);
757
+ // The key this install takes over, read before it runs: only a copy it really made is retired.
758
+ const handedFrom = dedicated && path.resolve(adoptFrom) !== path.resolve(home) ? tryLoadComputer({ ...env, PROHOST_HOME: adoptFrom }) : null;
475
759
  const outcome = installService(spec, io.serviceIo, {
476
760
  ...(tokenFile ? { claudeTokenFile: tokenFile } : {}),
477
761
  ...(dedicated ? { adoptFrom } : {}),
762
+ ...(dedicated && readHandoff({ ...env, PROHOST_HOME: adoptFrom })?.handed_to.label === spec.label ? { handedOffHere: true } : {}),
478
763
  });
479
764
  (outcome.ok ? out : err)(outcome.message);
480
765
  if (outcome.ok && !tryLoadComputer({ ...env, PROHOST_HOME: home })) {
@@ -482,9 +767,41 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
482
767
  ? ` ! ${home} has no computer yet (none in ${adoptFrom}): pair as yourself, then run this again — or pass --from-home <your PROHOST_HOME>.`
483
768
  : ` This home (${home}) has no computer yet: run prohost host pair.`);
484
769
  }
770
+ if (outcome.ok && dedicated && handedFrom) {
771
+ // One key, one holder: the home it came from keeps a note of where the computer went, never
772
+ // the key — so an `unpair`, `pair` or personal `install` there can't revoke or run it twice.
773
+ const serviceHas = serviceCredential(platform, home, serviceUser, io.serviceIo ?? {}, spec.label);
774
+ if (serviceHas && serviceHas.computer_id === handedFrom.computer_id && serviceHas.api_key !== handedFrom.api_key) {
775
+ // Re-paired since the service got its copy: it holds an older key (no longer valid).
776
+ // This home's key is the live one, so it stays here.
777
+ err(` ! The service in ${home} holds an older key for this computer than ${adoptFrom} (re-paired since?). ` +
778
+ `This home keeps its key. To move it over: sudo prohost host uninstall --label ${spec.label}, ` +
779
+ (platform === 'win32'
780
+ ? // An existing administrators' home is never written into: the whole folder goes.
781
+ `remove the folder ${home}, then install again.`
782
+ : `delete ${path.join(home, 'host', 'computer.json')}, then install again.`));
783
+ }
784
+ else if (serviceHas?.computer_id === handedFrom.computer_id) {
785
+ const note = handoffOf(handedFrom, {
786
+ label: spec.label,
787
+ service_user: serviceUser ?? null,
788
+ service_home: home,
789
+ at: new Date((io.now ?? Date.now)()).toISOString(),
790
+ });
791
+ const problem = rewriteHomeCredential(platform, adoptFrom, `${JSON.stringify(note, null, 2)}\n`, io.serviceIo);
792
+ if (problem) {
793
+ err(` ✗ The service has the computer, but its key is still in ${adoptFrom} too (${problem}). ` +
794
+ `Delete ${path.join(adoptFrom, 'host', 'computer.json')} yourself — never run \`prohost host unpair\` from that home: it would remove the computer.`);
795
+ }
796
+ else {
797
+ out(` The computer's key is the service's alone now: ${adoptFrom} keeps a note of where it went.`);
798
+ }
799
+ }
800
+ }
485
801
  if (outcome.ok && dedicated) {
486
802
  out(` Its status, for anyone on this computer: ${sharedDirFor(spec)}`);
487
803
  out(` Remove it with: sudo prohost host uninstall --label ${spec.label}`);
804
+ out(` Remove the computer from its workspace too: sudo prohost host unpair --label ${spec.label}`);
488
805
  // The personal service of the home it took the computer from runs on the same key.
489
806
  const personal = personalServiceOf(platform, adoptFrom, env.SUDO_USER);
490
807
  if (personal && (io.serviceIo?.exists ?? existsSync)(personal.file)) {
@@ -495,6 +812,14 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
495
812
  return outcome.ok ? 0 : 1;
496
813
  }
497
814
  case 'unpair': {
815
+ const labelFlag = str(flags, 'label');
816
+ if (labelFlag || flags.dedicated === true)
817
+ return unpairDedicated(flags, env, out, err, io, platform);
818
+ const handedOff = readHandoff(env);
819
+ if (handedOff) {
820
+ err(`✗ ${handoffNotice(handedOff)}`);
821
+ return 1;
822
+ }
498
823
  const computer = tryLoadComputer(env);
499
824
  if (!computer) {
500
825
  out('This computer is not added to a workspace. Nothing to remove.');
@@ -78,6 +78,37 @@ export interface ComputerCredentials {
78
78
  }
79
79
  export declare class ComputerMissingError extends Error {
80
80
  }
81
+ /**
82
+ * What a home keeps once a dedicated install has taken its computer over (`host install
83
+ * --dedicated`): no key — the dedicated service holds the only one — just where it went, so
84
+ * `host status` / `unpair` / `pair` / `install` here explain instead of acting on a copy of it.
85
+ * `code_host_home` stays for the desktop app, which reads it from this file.
86
+ */
87
+ export interface ComputerHandoff {
88
+ version: 1;
89
+ handed_to: {
90
+ label: string;
91
+ service_user: string | null;
92
+ service_home: string;
93
+ at: string;
94
+ };
95
+ computer_id: string;
96
+ name: string;
97
+ account: ProhostAccount;
98
+ base_url?: string;
99
+ code_host_home?: string;
100
+ }
101
+ export declare function handoffOf(credentials: ComputerCredentials, to: ComputerHandoff['handed_to']): ComputerHandoff;
102
+ /** A service label (launchd label, systemd unit, Windows task): the one rule, never a path. */
103
+ export declare const SERVICE_LABEL: RegExp;
104
+ /**
105
+ * This home's hand-off note, when its computer went to a dedicated service. The note is the home
106
+ * owner's file: its label is checked like any label, and nothing run as an administrator takes a
107
+ * path from it.
108
+ */
109
+ export declare function readHandoff(env?: NodeJS.ProcessEnv): ComputerHandoff | undefined;
110
+ /** How to reach the dedicated service a home handed its computer to — the one line every refusal here says. */
111
+ export declare function handoffNotice(handoff: ComputerHandoff): string;
81
112
  export declare function saveComputer(credentials: ComputerCredentials, env?: NodeJS.ProcessEnv): string;
82
113
  export declare function loadComputer(env?: NodeJS.ProcessEnv): ComputerCredentials;
83
114
  /** The `claude setup-token` token a dedicated install put in this home (`host/claude-oauth-token`). */
@@ -209,6 +209,39 @@ export function readObject(file) {
209
209
  }
210
210
  export class ComputerMissingError extends Error {
211
211
  }
212
+ export function handoffOf(credentials, to) {
213
+ return {
214
+ version: 1,
215
+ handed_to: to,
216
+ computer_id: credentials.computer_id,
217
+ name: credentials.name,
218
+ account: credentials.account,
219
+ base_url: credentials.base_url,
220
+ ...(credentials.code_host_home ? { code_host_home: credentials.code_host_home } : {}),
221
+ };
222
+ }
223
+ /** A service label (launchd label, systemd unit, Windows task): the one rule, never a path. */
224
+ export const SERVICE_LABEL = /^[A-Za-z0-9][A-Za-z0-9.-]{0,127}$/;
225
+ /**
226
+ * This home's hand-off note, when its computer went to a dedicated service. The note is the home
227
+ * owner's file: its label is checked like any label, and nothing run as an administrator takes a
228
+ * path from it.
229
+ */
230
+ export function readHandoff(env = process.env) {
231
+ const raw = readObject(computerPath(env));
232
+ const to = raw?.handed_to;
233
+ if (!raw || typeof raw.api_key === 'string' || !to || typeof to.label !== 'string' || typeof to.service_home !== 'string')
234
+ return undefined;
235
+ if (!SERVICE_LABEL.test(to.label))
236
+ return undefined;
237
+ return raw;
238
+ }
239
+ /** How to reach the dedicated service a home handed its computer to — the one line every refusal here says. */
240
+ export function handoffNotice(handoff) {
241
+ const { label, service_user: user } = handoff.handed_to;
242
+ return (`This computer ("${handoff.name}") runs as the dedicated service ${label}${user ? ` (user ${user})` : ''}; this home no longer holds its key. ` +
243
+ `See it with: prohost host status --label ${label} — remove it as an administrator with: sudo prohost host unpair --label ${label}`);
244
+ }
212
245
  export function saveComputer(credentials, env = process.env) {
213
246
  const file = computerPath(env);
214
247
  writePrivateFile(file, credentials);
@@ -219,6 +252,9 @@ export function loadComputer(env = process.env) {
219
252
  const raw = readObject(file);
220
253
  if (!raw)
221
254
  throw new ComputerMissingError(`This computer isn't added yet (no ${file}). Run: prohost host pair`);
255
+ const handoff = readHandoff(env);
256
+ if (handoff)
257
+ throw new ComputerMissingError(handoffNotice(handoff));
222
258
  if (typeof raw.api_key !== 'string' || !raw.api_key || typeof raw.computer_id !== 'string' || !raw.computer_id) {
223
259
  throw new ComputerMissingError(`${file} has no computer key. Add the computer again: prohost host pair`);
224
260
  }
@@ -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