@prohost/cli 0.16.7 → 0.16.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,27 @@ builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.16.8
10
+
11
+ - **A dedicated install never takes over a service home that holds another computer.** When the
12
+ service home already held a different computer's key, the install restarted that computer under
13
+ the label, left the invoking home's key where it was, and reported a takeover. It is now refused
14
+ before anything is installed, naming the computer it holds.
15
+ - **A copy that isn't there afterwards is no takeover.** When the key the install copied into the
16
+ service home can't be read back as that computer's, the service is uninstalled and the command
17
+ fails, naming the file to check (no credential is deleted: which file this install wrote can't be
18
+ told from one it didn't). The invoking home keeps its key.
19
+ - **`uninstall` says when a service is still stopping after it gives the key back.** The key goes
20
+ back at once, as before (the service's definition is already gone, so a later run could never
21
+ find it again). A stop that failed or is still draining (`launchctl bootout`, `systemctl disable
22
+ --now`) is then waited for up to 30 s, and a service still stopping fails the command with what
23
+ to wait for before running the computer from that home. A service copy that can't be deleted
24
+ after the give-back fails the command instead of leaving the key in two homes behind a success.
25
+ - **Windows: a scheduled task that couldn't be deleted is a failure.** `uninstall` reported it as
26
+ "No host service was installed" and removed its files, so the key went back while the task could
27
+ start again on it. It now fails, keeps the task's files (a later uninstall still finds it), and
28
+ gives nothing back.
29
+
9
30
  ## 0.16.7
10
31
 
11
32
  - **An undo whose cleanup fails says so.** When a dedicated install undoes itself (its hand-off
@@ -520,6 +520,21 @@ export function serviceStillLoaded(spec, serviceIo, pidAlive = processAlive) {
520
520
  return true;
521
521
  }
522
522
  }
523
+ /** How long a give-back waits for an uninstalled service to finish stopping. */
524
+ const GIVE_BACK_WAIT_MS = 30_000;
525
+ /** Whether `spec`'s service is gone, waiting up to {@link GIVE_BACK_WAIT_MS} for it to stop. */
526
+ function waitUntilGone(spec, serviceIo) {
527
+ const now = serviceIo.now ?? Date.now;
528
+ const sleep = serviceIo.sleep ?? ((ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms));
529
+ const deadline = now() + GIVE_BACK_WAIT_MS;
530
+ for (;;) {
531
+ if (!serviceStillLoaded(spec, serviceIo))
532
+ return true;
533
+ if (now() >= deadline)
534
+ return false;
535
+ sleep(500);
536
+ }
537
+ }
523
538
  function processAlive(pid) {
524
539
  try {
525
540
  process.kill(pid, 0);
@@ -766,7 +781,18 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
766
781
  for (const o of shown)
767
782
  (o.ok ? out : err)(o.message);
768
783
  let ok = shown.every((o) => o.ok);
769
- if (note && fromHome && outcomes.some((o) => o.label === note.handed_to.label && o.ok)) {
784
+ // The key goes back at once: the service's definition is gone, so a later run could never
785
+ // find it again, and a key left only in its home (an interrupted wait, say) would be stranded.
786
+ // Uninstall ignores a stop that failed (`launchctl bootout`, `systemctl disable --now`), so the
787
+ // service may still be draining its runs on the old copy: that is waited for (bounded) only
788
+ // afterwards, to say so.
789
+ const handedSpec = note && handedTo ? { ...spec, label: note.handed_to.label, home: handedTo.home } : undefined;
790
+ // Its definition removed (its task deleted), whatever else failed after: on Windows a copy of
791
+ // the key that couldn't be deleted makes uninstall report not ok, yet the task is gone, and
792
+ // no later run could find the key again. Those failures still fail this command (`ok`).
793
+ const definitionGone = (o) => o.ok || o.message.includes('✓ Removed');
794
+ const handedOutcome = note ? outcomes.find((o) => o.label === note.handed_to.label && definitionGone(o)) : undefined;
795
+ if (note && fromHome && handedOutcome) {
770
796
  const key = handedKeyLive;
771
797
  if (key && key.computer_id === note.computer_id && handedTo) {
772
798
  const problem = rewriteHomeCredential(platform, fromHome, `${JSON.stringify(key, null, 2)}\n`, io.serviceIo);
@@ -777,17 +803,28 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
777
803
  else {
778
804
  // On Windows the (administrators') service home keeps its copy: a later dedicated
779
805
  // install finds that home and writes nothing into it, so it must still hold the key.
780
- if (platform !== 'win32')
781
- clearHandedHome(platform, handedTo.home, 'its key', io.serviceIo ?? {}, err);
806
+ const cleared = platform === 'win32' || clearHandedHome(platform, handedTo.home, 'its key', io.serviceIo ?? {}, err);
807
+ if (!cleared) {
808
+ // Back here, and still there: the same key in two homes. Not done until that copy goes.
809
+ ok = false;
810
+ err(` ✗ The key is back in ${fromHome} but also still in ${handedTo.home}: delete that copy before using either.`);
811
+ }
782
812
  out(` The computer's key is back in ${fromHome}: prohost host install runs it there (prohost host unpair removes it).`);
783
813
  if (platform === 'win32')
784
814
  out(` To install it as a dedicated service again, first remove the folder ${handedTo.home}.`);
785
815
  }
786
816
  }
787
817
  else {
788
- clearHandedHome(platform, fromHome, 'the note', io.serviceIo ?? {}, err);
818
+ if (!clearHandedHome(platform, fromHome, 'the note', io.serviceIo ?? {}, err))
819
+ ok = false;
789
820
  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.`);
790
821
  }
822
+ // Only now (the key is where it belongs): whether the service really stopped, bounded.
823
+ if (handedSpec && !waitUntilGone(handedSpec, io.serviceIo ?? {})) {
824
+ ok = false;
825
+ err(` ! ${note.handed_to.label} was still stopping after ${GIVE_BACK_WAIT_MS / 1000}s: wait until it has exited ` +
826
+ `(${platform === 'darwin' ? `sudo launchctl print system/${note.handed_to.label}` : platform === 'win32' ? 'Task Manager' : `systemctl status ${systemdUnitOf(handedSpec)}`}) before running this computer from ${fromHome}.`);
827
+ }
791
828
  }
792
829
  return ok ? 0 : 1;
793
830
  }
@@ -815,8 +852,17 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
815
852
  (env.SUDO_USER && !env.PROHOST_HOME ? homeOfUser(platform, env.SUDO_USER) : ownHome);
816
853
  // The key this install takes over, read before it runs: only a copy it really made is retired.
817
854
  const handedFrom = dedicated && path.resolve(adoptFrom) !== path.resolve(home) ? tryLoadComputer({ ...env, PROHOST_HOME: adoptFrom }) : null;
818
- // Whether the service home already held a key before this install (then it copies none).
819
- const serviceHadKey = handedFrom ? serviceCredential(platform, home, serviceUser, io.serviceIo ?? {}, spec.label) !== null : false;
855
+ // The key the service home already holds, read before this install (then it copies none in).
856
+ const serviceBefore = handedFrom ? serviceCredential(platform, home, serviceUser, io.serviceIo ?? {}, spec.label) : null;
857
+ // Another computer's: installing would restart that computer under this label and leave this
858
+ // one's key where it is, reported as a takeover. Refused before anything is installed.
859
+ if (handedFrom && serviceBefore && serviceBefore.computer_id !== handedFrom.computer_id) {
860
+ err(`✗ ${home} already holds the key of another computer ("${serviceBefore.name}", ${serviceBefore.computer_id}), not ` +
861
+ `${adoptFrom}'s ("${handedFrom.name}"): nothing was installed. Remove that computer first ` +
862
+ `(sudo prohost host unpair --label ${spec.label}), then install again.`);
863
+ return 1;
864
+ }
865
+ const serviceHadKey = serviceBefore !== null;
820
866
  const outcome = installService(spec, io.serviceIo, {
821
867
  ...(tokenFile ? { claudeTokenFile: tokenFile } : {}),
822
868
  ...(dedicated ? { adoptFrom } : {}),
@@ -828,6 +874,38 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
828
874
  ? ` ! ${home} has no computer yet (none in ${adoptFrom}): pair as yourself, then run this again — or pass --from-home <your PROHOST_HOME>.`
829
875
  : ` This home (${home}) has no computer yet: run prohost host pair.`);
830
876
  }
877
+ // Undo this install (it copied a key in): the service uninstalled, every copy of the key it made
878
+ // removed, and the original home keeps its key. Returns the exit code.
879
+ const undoInstall = (options = { removeKeys: true }) => {
880
+ const undone = (() => {
881
+ try {
882
+ return uninstallService(spec, io.serviceIo);
883
+ }
884
+ catch (e) {
885
+ return { ok: false, message: e instanceof Error ? e.message : String(e) };
886
+ }
887
+ })();
888
+ // Removed and no longer loaded, not just "nothing failed": a Windows task that would not
889
+ // stop removes nothing, and a launchd / systemd service whose stop failed may still run.
890
+ // (Not `undone.ok`: on Windows uninstall reports a key copy it couldn't remove as not ok
891
+ // although the task is gone — that copy is named by removeCopiedKeys below.)
892
+ const serviceGone = undone.message.includes('✓ Removed') && !serviceStillLoaded(spec, io.serviceIo ?? {});
893
+ if (!options.removeKeys) {
894
+ // Which file this install copied can't be told from one it didn't: no credential is deleted.
895
+ err(serviceGone
896
+ ? ` ✗ ${spec.label} is uninstalled. Check ${path.join(home, 'host', 'computer.json')} before installing again; ${adoptFrom} keeps its key.`
897
+ : ` ✗ Could not uninstall ${spec.label}: remove it (sudo prohost host uninstall --label ${spec.label}), then check ${path.join(home, 'host', 'computer.json')}.`);
898
+ return 1;
899
+ }
900
+ const leftover = serviceGone ? removeCopiedKeys(spec, io.serviceIo ?? {}) : [];
901
+ err(serviceGone && leftover.length === 0
902
+ ? ` ✗ Undone: ${spec.label} is uninstalled and ${home} holds no key; ${adoptFrom} keeps its key. Fix the problem above, then ` +
903
+ (platform === 'win32' ? `remove the folder ${home} and install again.` : 'install again.')
904
+ : serviceGone
905
+ ? ` ✗ Partly undone: ${spec.label} is uninstalled, but its key is still in ${leftover.join(' and ')}: delete it yourself — never run \`prohost host unpair\` from ${adoptFrom} meanwhile.`
906
+ : ` ✗ 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.`);
907
+ return 1;
908
+ };
831
909
  if (outcome.ok && dedicated && handedFrom) {
832
910
  // One key, one holder: the home it came from keeps a note of where the computer went, never
833
911
  // the key — so an `unpair`, `pair` or personal `install` there can't revoke or run it twice.
@@ -868,27 +946,7 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
868
946
  return 1;
869
947
  }
870
948
  else if (!serviceHadKey) {
871
- const undone = (() => {
872
- try {
873
- return uninstallService(spec, io.serviceIo);
874
- }
875
- catch (e) {
876
- return { ok: false, message: e instanceof Error ? e.message : String(e) };
877
- }
878
- })();
879
- // Removed and no longer loaded, not just "nothing failed": a Windows task that would not
880
- // stop removes nothing, and a launchd / systemd service whose stop failed may still run.
881
- // (Not `undone.ok`: on Windows uninstall reports a key copy it couldn't remove as not ok
882
- // although the task is gone — that copy is named by removeCopiedKeys below.)
883
- const serviceGone = undone.message.includes('✓ Removed') && !serviceStillLoaded(spec, io.serviceIo ?? {});
884
- const leftover = serviceGone ? removeCopiedKeys(spec, io.serviceIo ?? {}) : [];
885
- err(serviceGone && leftover.length === 0
886
- ? ` ✗ Undone: ${spec.label} is uninstalled and ${home} holds no key; ${adoptFrom} keeps its key. Fix the problem above, then ` +
887
- (platform === 'win32' ? `remove the folder ${home} and install again.` : 'install again.')
888
- : serviceGone
889
- ? ` ✗ Partly undone: ${spec.label} is uninstalled, but its key is still in ${leftover.join(' and ')}: delete it yourself — never run \`prohost host unpair\` from ${adoptFrom} meanwhile.`
890
- : ` ✗ 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.`);
891
- return 1;
949
+ return undoInstall();
892
950
  }
893
951
  else {
894
952
  err(` ✗ ${home} already held this key before this install, so ${adoptFrom} still does too: never run \`prohost host unpair\` from that home. ` +
@@ -901,6 +959,20 @@ export async function runHost(subcommand, flags, printHelp, io = {}) {
901
959
  out(` The computer's key is the service's alone now: ${adoptFrom} keeps a note of where it went.`);
902
960
  }
903
961
  }
962
+ else if (!serviceHadKey) {
963
+ // The copy this install made isn't there afterwards (or can't be read, or is another
964
+ // computer's): no takeover happened, and the service must not run on whatever it holds.
965
+ // Uninstalled; its files are left for a person to judge.
966
+ err(` ✗ ${spec.label} was installed, but ${home} does not hold ${adoptFrom}'s key afterwards ` +
967
+ (serviceHas ? `(it holds ${serviceHas.computer_id}).` : "(it can't be read)."));
968
+ return undoInstall({ removeKeys: false });
969
+ }
970
+ else {
971
+ // It held this computer's key before the install and can't be read now: never reported done.
972
+ err(` ✗ ${home} held this computer's key before this install, but it can't be read now: check ` +
973
+ `${path.join(home, 'host', 'computer.json')}, and never run \`prohost host unpair\` from ${adoptFrom} meanwhile.`);
974
+ return 1;
975
+ }
904
976
  }
905
977
  if (outcome.ok && dedicated) {
906
978
  out(` Its status, for anyone on this computer: ${sharedDirFor(spec)}`);
@@ -1048,9 +1048,17 @@ export function uninstallService(spec, io = {}) {
1048
1048
  const out = run('schtasks', ['/Delete', '/TN', task, '/F']);
1049
1049
  if (out.status === 0)
1050
1050
  removed.push(task);
1051
- for (const file of [serviceFilePath(spec), windowsWrapperPath(spec)]) {
1052
- if (exists(file))
1053
- remove(file);
1051
+ // A delete that failed while the task is still there is a failure, never "nothing installed":
1052
+ // the task can start again on its key. Its files stay, so a later uninstall still finds it.
1053
+ const stillThere = out.status !== 0 && run('schtasks', ['/Query', '/TN', task]).status === 0;
1054
+ if (stillThere) {
1055
+ notes.push(`could not delete the task ${task} (${(out.stderr || out.stdout || `exit ${out.status}`).trim()}): it is still installed`);
1056
+ }
1057
+ else {
1058
+ for (const file of [serviceFilePath(spec), windowsWrapperPath(spec)]) {
1059
+ if (exists(file))
1060
+ remove(file);
1061
+ }
1054
1062
  }
1055
1063
  // The administrators' copy of its key goes with the service: a reinstall never finds an old one.
1056
1064
  const copy = windowsServiceCredentialPath(spec);
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.7";
10
+ export declare const CLI_VERSION = "0.16.8";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.16.7";
12
+ export declare const USER_AGENT = "prohost-cli/0.16.8";
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.7';
10
+ export const CLI_VERSION = '0.16.8';
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.7",
3
+ "version": "0.16.8",
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",