@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.
@@ -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, systemdUnitOf, 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,246 @@ 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
+ /** Whether the service manager still has `spec`'s dedicated service (loaded / active / a task). */
494
+ export function serviceStillLoaded(spec, serviceIo, pidAlive = processAlive) {
495
+ const run = serviceIo.run ?? defaultServiceRunner;
496
+ try {
497
+ if (spec.platform === 'darwin')
498
+ return run('launchctl', ['print', `system/${spec.label}`]).status === 0;
499
+ if (spec.platform === 'win32') {
500
+ if (run('schtasks', ['/Query', '/TN', windowsTaskOf(spec)]).status === 0)
501
+ return true;
502
+ // Deleting a task does not stop what it started: the host's own lock names a live process.
503
+ // Only "no such file" means no lock; any other read error is an unknown, so still there (fail closed).
504
+ const lock = (serviceIo.readFile ?? ((f) => {
505
+ try {
506
+ return readFileSync(f, 'utf8');
507
+ }
508
+ catch (e) {
509
+ if (e.code === 'ENOENT')
510
+ return undefined;
511
+ throw e;
512
+ }
513
+ }))(path.win32.join(spec.home, 'host', 'service.lock'));
514
+ const pid = lock ? JSON.parse(lock).pid : undefined;
515
+ return typeof pid === 'number' && Number.isInteger(pid) && pid > 0 && pidAlive(pid);
516
+ }
517
+ return run('systemctl', ['is-active', '--quiet', systemdUnitOf(spec)]).status === 0;
518
+ }
519
+ catch {
520
+ return true;
521
+ }
522
+ }
523
+ function processAlive(pid) {
524
+ try {
525
+ process.kill(pid, 0);
526
+ return true;
527
+ }
528
+ catch (e) {
529
+ return e.code === 'EPERM';
530
+ }
531
+ }
532
+ /** The home a dedicated install took its computer from: `--from-home`, else (under sudo `$PROHOST_HOME` is root's) `$SUDO_USER`'s. */
533
+ function handedFromHome(flags, env, platform) {
534
+ return str(flags, 'from-home') ?? (env.SUDO_USER && !env.PROHOST_HOME ? homeOfUser(platform, env.SUDO_USER) : prohostHome(env));
535
+ }
536
+ function clearHandedHome(platform, home, what, serviceIo, err) {
537
+ const problem = rewriteHomeCredential(platform, home, undefined, serviceIo);
538
+ if (problem)
539
+ err(` ✗ Could not delete ${what} in ${home} (${problem}): delete ${path.join(home, 'host', 'computer.json')} yourself.`);
540
+ return !problem;
541
+ }
288
542
  export async function runHost(subcommand, flags, printHelp, io = {}) {
289
543
  const env = io.env ?? process.env;
290
544
  const out = io.out ?? ((line) => process.stdout.write(`${line}\n`));
@@ -292,6 +546,13 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
292
546
  const platform = io.platform ?? process.platform;
293
547
  switch (subcommand) {
294
548
  case 'pair': {
549
+ const handedOff = readHandoff(env);
550
+ if (handedOff) {
551
+ const result = { version: 1, outcome: 'handed_off', computer_id: handedOff.computer_id, account: handedOff.account, message: handoffNotice(handedOff) };
552
+ writeHostPairResult(result, env, err);
553
+ err(`✗ ${result.message}`);
554
+ return 1;
555
+ }
295
556
  const baseUrl = str(flags, 'base-url') ?? env.PROHOST_BASE_URL ?? DEFAULT_API_BASE_URL;
296
557
  const problem = baseUrlProblem(baseUrl);
297
558
  if (problem) {
@@ -341,6 +602,12 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
341
602
  }
342
603
  }
343
604
  case 'run': {
605
+ const handedOff = readHandoff(env);
606
+ if (handedOff) {
607
+ // The dedicated service runs this computer (and any homes adopted here are its business now).
608
+ err(`✗ ${handoffNotice(handedOff)}`);
609
+ return 1;
610
+ }
344
611
  const computer = tryLoadComputer(env);
345
612
  const adopted = loadAdopted(env);
346
613
  if (!computer && !adopted?.homes.length) {
@@ -444,17 +711,65 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
444
711
  // A dedicated service is labelled after its own home: without --label,
445
712
  // every one this computer publishes state for is looked at too.
446
713
  const labels = new Set([spec.label]);
447
- const admin = platform === 'win32' ? dedicated : (io.serviceIo?.uid ?? process.getuid?.() ?? 1) === 0;
714
+ // Elevated, whatever the flags say (on Windows an administrator prompt, as unpair checks it).
715
+ const admin = io.serviceIo?.isAdmin ??
716
+ (platform === 'win32'
717
+ ? dedicated || (io.serviceIo?.run ?? defaultServiceRunner)('net', ['session']).status === 0
718
+ : (io.serviceIo?.uid ?? process.getuid?.() ?? 1) === 0);
448
719
  if (!str(flags, 'label') && (admin || dedicated)) {
449
720
  for (const dir of sharedDirs(io.sharedBase ?? sharedBase(platform)))
450
721
  labels.add(path.basename(dir));
451
722
  }
452
- const outcomes = [...labels].map((label) => uninstallService({ ...spec, label }, io.serviceIo));
723
+ // A home that handed its computer to one of these services gets the key back once that
724
+ // service is gone (the mirror of install): `prohost host install` runs it there again.
725
+ const fromHome = admin ? handedFromHome(flags, env, platform) : undefined;
726
+ const note = fromHome ? readHandoff({ ...env, PROHOST_HOME: fromHome }) : undefined;
727
+ const handedTo = note && labels.has(note.handed_to.label) ? installedDedicatedService(platform, note.handed_to.label, io.serviceIo) : undefined;
728
+ // Read before the service goes (on Windows its administrators' copy goes with it), and only
729
+ // from the home the definition names when it is the service account's own folder.
730
+ const handedKey = note && handedTo && !serviceHomeProblem(platform, handedTo.home, handedTo.user, io.serviceIo ?? {})
731
+ ? serviceCredential(platform, handedTo.home, handedTo.user, io.serviceIo ?? {}, note.handed_to.label)
732
+ : null;
733
+ // Windows reads the administrators' copy (an install-time snapshot): it is given back while the
734
+ // service publishes that same computer, or publishes none yet (never started, failed early)
735
+ // and the copy is for the computer the note names. Another computer published: never.
736
+ // A state that can't be read is not "none published": then nothing is given back.
737
+ const published = platform === 'win32' && note ? publishedComputer({ platform, label: note.handed_to.label }, io.serviceIo) : undefined;
738
+ const handedKeyLive = handedKey &&
739
+ (platform !== 'win32' ||
740
+ (published?.kind === 'none' ? handedKey.computer_id === note?.computer_id : published?.kind === 'published' && published.id === handedKey.computer_id))
741
+ ? handedKey
742
+ : null;
743
+ const outcomes = [...labels].map((label) => ({ label, ...uninstallService({ ...spec, label }, io.serviceIo) }));
453
744
  const removed = outcomes.filter((o) => !o.message.startsWith('No host service'));
454
745
  const shown = removed.length > 0 ? removed : outcomes.slice(0, 1);
455
746
  for (const o of shown)
456
747
  (o.ok ? out : err)(o.message);
457
- return shown.every((o) => o.ok) ? 0 : 1;
748
+ let ok = shown.every((o) => o.ok);
749
+ if (note && fromHome && outcomes.some((o) => o.label === note.handed_to.label && o.ok)) {
750
+ const key = handedKeyLive;
751
+ if (key && key.computer_id === note.computer_id && handedTo) {
752
+ const problem = rewriteHomeCredential(platform, fromHome, `${JSON.stringify(key, null, 2)}\n`, io.serviceIo);
753
+ if (problem) {
754
+ ok = false;
755
+ err(` ✗ Could not give the computer's key back to ${fromHome} (${problem}); it stays in ${handedTo.home}.`);
756
+ }
757
+ else {
758
+ // On Windows the (administrators') service home keeps its copy: a later dedicated
759
+ // install finds that home and writes nothing into it, so it must still hold the key.
760
+ if (platform !== 'win32')
761
+ clearHandedHome(platform, handedTo.home, 'its key', io.serviceIo ?? {}, err);
762
+ out(` The computer's key is back in ${fromHome}: prohost host install runs it there (prohost host unpair removes it).`);
763
+ if (platform === 'win32')
764
+ out(` To install it as a dedicated service again, first remove the folder ${handedTo.home}.`);
765
+ }
766
+ }
767
+ else {
768
+ clearHandedHome(platform, fromHome, 'the note', io.serviceIo ?? {}, err);
769
+ 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.`);
770
+ }
771
+ }
772
+ return ok ? 0 : 1;
458
773
  }
459
774
  // 0.16.0 put a Windows dedicated service's home in the account's profile, under another
460
775
  // label: replacing it here would leave two services on one key. Its owner removes it first.
@@ -469,12 +784,23 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
469
784
  return 1;
470
785
  }
471
786
  }
787
+ // A home whose computer went to a dedicated service has no key to run a service of its own on.
788
+ const ownHandoff = dedicated ? undefined : readHandoff(env);
789
+ if (ownHandoff) {
790
+ err(`✗ ${handoffNotice(ownHandoff)}`);
791
+ return 1;
792
+ }
472
793
  // The invoking user's home: under sudo, $PROHOST_HOME and $HOME are root's.
473
794
  const adoptFrom = str(flags, 'from-home') ??
474
795
  (env.SUDO_USER && !env.PROHOST_HOME ? homeOfUser(platform, env.SUDO_USER) : ownHome);
796
+ // The key this install takes over, read before it runs: only a copy it really made is retired.
797
+ const handedFrom = dedicated && path.resolve(adoptFrom) !== path.resolve(home) ? tryLoadComputer({ ...env, PROHOST_HOME: adoptFrom }) : null;
798
+ // Whether the service home already held a key before this install (then it copies none).
799
+ const serviceHadKey = handedFrom ? serviceCredential(platform, home, serviceUser, io.serviceIo ?? {}, spec.label) !== null : false;
475
800
  const outcome = installService(spec, io.serviceIo, {
476
801
  ...(tokenFile ? { claudeTokenFile: tokenFile } : {}),
477
802
  ...(dedicated ? { adoptFrom } : {}),
803
+ ...(dedicated && readHandoff({ ...env, PROHOST_HOME: adoptFrom })?.handed_to.label === spec.label ? { handedOffHere: true } : {}),
478
804
  });
479
805
  (outcome.ok ? out : err)(outcome.message);
480
806
  if (outcome.ok && !tryLoadComputer({ ...env, PROHOST_HOME: home })) {
@@ -482,9 +808,88 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
482
808
  ? ` ! ${home} has no computer yet (none in ${adoptFrom}): pair as yourself, then run this again — or pass --from-home <your PROHOST_HOME>.`
483
809
  : ` This home (${home}) has no computer yet: run prohost host pair.`);
484
810
  }
811
+ if (outcome.ok && dedicated && handedFrom) {
812
+ // One key, one holder: the home it came from keeps a note of where the computer went, never
813
+ // the key — so an `unpair`, `pair` or personal `install` there can't revoke or run it twice.
814
+ const serviceHas = serviceCredential(platform, home, serviceUser, io.serviceIo ?? {}, spec.label);
815
+ if (serviceHas && serviceHas.computer_id === handedFrom.computer_id && serviceHas.api_key !== handedFrom.api_key) {
816
+ // Re-paired since the service got its copy: it holds an older key (no longer valid).
817
+ // This home's key is the live one, so it stays here.
818
+ err(` ! The service in ${home} holds an older key for this computer than ${adoptFrom} (re-paired since?). ` +
819
+ `This home keeps its key. To move it over: sudo prohost host uninstall --label ${spec.label}, ` +
820
+ (platform === 'win32'
821
+ ? // An existing administrators' home is never written into: the whole folder goes.
822
+ `remove the folder ${home}, then install again.`
823
+ : `delete ${path.join(home, 'host', 'computer.json')}, then install again.`));
824
+ }
825
+ else if (serviceHas?.computer_id === handedFrom.computer_id) {
826
+ const note = handoffOf(handedFrom, {
827
+ label: spec.label,
828
+ service_user: serviceUser ?? null,
829
+ service_home: home,
830
+ at: new Date((io.now ?? Date.now)()).toISOString(),
831
+ });
832
+ const problem = rewriteHomeCredential(platform, adoptFrom, `${JSON.stringify(note, null, 2)}\n`, io.serviceIo);
833
+ if (problem) {
834
+ // The note is part of the takeover: without it the key would live in both homes, and an
835
+ // `unpair` here would remove the computer under the service. What this install did is undone.
836
+ err(` ✗ Could not leave the hand-off note in ${adoptFrom} (${problem}).`);
837
+ // What the original home holds now decides the undo (on Windows a rename can land before a
838
+ // later check fails): its key still there → undo; the note there after all → handed over;
839
+ // neither → the service keeps the only key.
840
+ const sourceKey = tryLoadComputer({ ...env, PROHOST_HOME: adoptFrom });
841
+ const sourceNote = readHandoff({ ...env, PROHOST_HOME: adoptFrom });
842
+ const handedAnyway = !sourceKey && sourceNote?.handed_to.label === spec.label && sourceNote?.computer_id === handedFrom.computer_id;
843
+ if (handedAnyway) {
844
+ err(` ! The note is in place after all: the computer's key is the service's alone. Check ${path.join(adoptFrom, 'host', 'computer.json')}.`);
845
+ }
846
+ else if (sourceKey?.computer_id !== handedFrom.computer_id) {
847
+ err(` ✗ ${adoptFrom} no longer holds this computer's key, so ${spec.label} keeps it: check ${path.join(adoptFrom, 'host', 'computer.json')}, never delete ${home}.`);
848
+ return 1;
849
+ }
850
+ else if (!serviceHadKey) {
851
+ const undone = (() => {
852
+ try {
853
+ return uninstallService(spec, io.serviceIo);
854
+ }
855
+ catch (e) {
856
+ return { ok: false, message: e instanceof Error ? e.message : String(e) };
857
+ }
858
+ })();
859
+ // Removed and no longer loaded, not just "nothing failed": a Windows task that would not
860
+ // stop removes nothing, and a launchd / systemd service whose stop failed may still run.
861
+ const serviceGone = undone.ok && undone.message.includes('✓ Removed') && !serviceStillLoaded(spec, io.serviceIo ?? {});
862
+ const keyGone = serviceGone && !rewriteHomeCredential(platform, home, undefined, io.serviceIo);
863
+ if (keyGone && platform === 'win32') {
864
+ try {
865
+ (io.serviceIo?.removeFile ?? ((f) => rmSync(f, { force: true })))(windowsServiceCredentialPath(spec));
866
+ }
867
+ catch {
868
+ /* reported below with the rest */
869
+ }
870
+ }
871
+ err(serviceGone && keyGone
872
+ ? ` ✗ Undone: ${spec.label} is uninstalled and ${home} holds no key; ${adoptFrom} keeps its key. Fix the problem above, then ` +
873
+ (platform === 'win32' ? `remove the folder ${home} and install again.` : 'install again.')
874
+ : ` ✗ Could not fully undo it: remove ${spec.label} (sudo prohost host uninstall --label ${spec.label}) and ${path.join(home, 'host', 'computer.json')} yourself — never run \`prohost host unpair\` from ${adoptFrom} meanwhile.`);
875
+ return 1;
876
+ }
877
+ else {
878
+ err(` ✗ ${home} already held this key before this install, so ${adoptFrom} still does too: never run \`prohost host unpair\` from that home. ` +
879
+ `Fix the problem above, then install again.`);
880
+ }
881
+ if (!handedAnyway)
882
+ return 1;
883
+ }
884
+ else {
885
+ out(` The computer's key is the service's alone now: ${adoptFrom} keeps a note of where it went.`);
886
+ }
887
+ }
888
+ }
485
889
  if (outcome.ok && dedicated) {
486
890
  out(` Its status, for anyone on this computer: ${sharedDirFor(spec)}`);
487
891
  out(` Remove it with: sudo prohost host uninstall --label ${spec.label}`);
892
+ out(` Remove the computer from its workspace too: sudo prohost host unpair --label ${spec.label}`);
488
893
  // The personal service of the home it took the computer from runs on the same key.
489
894
  const personal = personalServiceOf(platform, adoptFrom, env.SUDO_USER);
490
895
  if (personal && (io.serviceIo?.exists ?? existsSync)(personal.file)) {
@@ -495,6 +900,14 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
495
900
  return outcome.ok ? 0 : 1;
496
901
  }
497
902
  case 'unpair': {
903
+ const labelFlag = str(flags, 'label');
904
+ if (labelFlag || flags.dedicated === true)
905
+ return unpairDedicated(flags, env, out, err, io, platform);
906
+ const handedOff = readHandoff(env);
907
+ if (handedOff) {
908
+ err(`✗ ${handoffNotice(handedOff)}`);
909
+ return 1;
910
+ }
498
911
  const computer = tryLoadComputer(env);
499
912
  if (!computer) {
500
913
  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
  }