@bitkyc08/opencodex 2.50.0 → 2.52.0-preview.20260911

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/bin/ocx.mjs +222 -71
  2. package/gui/dist/assets/{index-C39tnjXO.js → index-Dx0xv2EA.js} +1 -1
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/qoder/adapter.ts +69 -1
  6. package/src/adapters/qoder/scaffold-guard.ts +233 -0
  7. package/src/claude/agents-inject.ts +29 -5
  8. package/src/claude/desktop-3p.ts +31 -3
  9. package/src/claude/gateway-cache.ts +12 -21
  10. package/src/cli/capabilities.ts +28 -0
  11. package/src/cli/claude-agent-startup-sync.ts +26 -1
  12. package/src/cli/claude.ts +138 -20
  13. package/src/cli/config-command.ts +67 -1
  14. package/src/cli/connect.ts +181 -14
  15. package/src/cli/dispatch.ts +53 -9
  16. package/src/cli/doctor.ts +9 -2
  17. package/src/cli/ensure-desired-integrations.ts +10 -0
  18. package/src/cli/gui-pair-client.ts +1 -12
  19. package/src/cli/help.ts +4 -1
  20. package/src/cli/hub.ts +367 -0
  21. package/src/cli/index.ts +94 -30
  22. package/src/cli/launcher-context.ts +1 -1
  23. package/src/cli/registry.ts +43 -3
  24. package/src/cli/status.ts +325 -5
  25. package/src/cli/version-skew.ts +4 -1
  26. package/src/cli.ts +2 -2
  27. package/src/client/catalog-compatibility.ts +192 -0
  28. package/src/client/connect.ts +31 -0
  29. package/src/client/hub-client.ts +52 -0
  30. package/src/client/hub-state.ts +214 -0
  31. package/src/codex/account-usability.ts +48 -12
  32. package/src/codex/auth-api.ts +49 -5
  33. package/src/codex/catalog/effort.ts +67 -8
  34. package/src/codex/catalog/sync.ts +85 -0
  35. package/src/codex/codex-write-lock.ts +11 -2
  36. package/src/codex/desired-state.ts +47 -1
  37. package/src/codex/inject-coordination.ts +10 -5
  38. package/src/codex/inject.ts +26 -10
  39. package/src/codex/loopback-target.ts +45 -0
  40. package/src/codex/routing.ts +48 -1
  41. package/src/codex/runtime.ts +37 -3
  42. package/src/codex/sync.ts +29 -9
  43. package/src/codex/warmup.ts +21 -4
  44. package/src/config/pending-teardown.ts +1 -1
  45. package/src/config.ts +126 -12
  46. package/src/generated/compatibility-version.json +136 -76
  47. package/src/grok/status.ts +9 -1
  48. package/src/integrations/config-io.ts +54 -1
  49. package/src/lib/bun-runtime.ts +1 -1
  50. package/src/lib/gui-pair-capability.ts +27 -0
  51. package/src/lib/local-destinations.ts +162 -0
  52. package/src/lib/package-tree-integrity.ts +1 -1
  53. package/src/lib/process-control.ts +130 -20
  54. package/src/lib/service-secrets.ts +28 -0
  55. package/src/lib/test-home-guard.ts +49 -0
  56. package/src/providers/opencode-go-transport.ts +9 -1
  57. package/src/providers/quota.ts +5 -1
  58. package/src/providers/registry.ts +34 -5
  59. package/src/remote/hub-state.ts +182 -0
  60. package/src/server/auth-cors.ts +5 -0
  61. package/src/server/chat-completions.ts +6 -3
  62. package/src/server/claude-messages.ts +7 -1
  63. package/src/server/hub-state.ts +98 -0
  64. package/src/server/index.ts +124 -6
  65. package/src/server/management/api-access.ts +14 -3
  66. package/src/server/management/config-routes.ts +2 -2
  67. package/src/server/management/cursor-integration-routes.ts +13 -4
  68. package/src/server/proxy-liveness.ts +7 -1
  69. package/src/server/request-log-conversation.ts +41 -1
  70. package/src/server/responses/codex-auth-error.ts +18 -1
  71. package/src/server/responses/codex-ws-exchange.ts +36 -4
  72. package/src/server/responses/codex-ws-wire.ts +75 -4
  73. package/src/server/responses/compact.ts +20 -9
  74. package/src/server/responses/core.ts +57 -10
  75. package/src/server/responses/policy-fallback.ts +7 -1
  76. package/src/server/system-env-shell.ts +14 -2
  77. package/src/server/system-env.ts +106 -14
  78. package/src/service.ts +906 -94
  79. package/src/types/config.ts +57 -4
  80. package/src/update/badge.ts +3 -2
  81. package/src/update/index.ts +317 -64
  82. package/src/update/install-detection.d.mts +6 -0
  83. package/src/update/install-detection.mjs +73 -0
  84. package/src/update/job.ts +101 -49
  85. package/src/update/pnpm-global-install.d.mts +144 -0
  86. package/src/update/pnpm-global-install.mjs +591 -0
  87. package/src/update/pnpm-invocation.d.mts +43 -0
  88. package/src/update/pnpm-invocation.mjs +141 -0
  89. package/src/update/registry-integrity.d.mts +16 -0
  90. package/src/update/registry-integrity.mjs +37 -0
  91. package/src/update/transactional-install.d.mts +1 -1
  92. package/src/update/transactional-install.mjs +101 -7
  93. package/src/update/tray-update-plan.mjs +1 -1
  94. package/src/vision/plan.ts +13 -3
  95. package/src/vision/routed-describe.ts +51 -20
package/src/service.ts CHANGED
@@ -25,10 +25,10 @@ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from
25
25
  export const SERVICE_MANAGED_ENV = "OCX_SERVICE_MANAGED";
26
26
  import type { BunRuntimeSource, DurableBunRuntime } from "./lib/bun-runtime";
27
27
  import { isProcessAlive, stopProxy } from "./lib/process-control";
28
- import { serviceApiTokenFilePath } from "./lib/service-secrets";
28
+ import { readServiceApiTokenState, serviceApiTokenFilePath } from "./lib/service-secrets";
29
29
  import { tokenCollidesWithAdmin } from "./lib/admin-secrets";
30
30
  import { PROXY_ENV_KEYS } from "./lib/proxy-env";
31
- import { randomUUID } from "node:crypto";
31
+ import { randomBytes, randomUUID } from "node:crypto";
32
32
  import {
33
33
  ELEVATION_REQUEST_TIMEOUT_MS,
34
34
  OCX_ELEVATED_PROTOCOL_FAILED,
@@ -63,7 +63,7 @@ import { killWindowsSchedulerWrappers } from "./lib/windows-service-wrappers";
63
63
  import { withWindowsServiceMutationLock } from "./lib/windows-service-mutation-lock";
64
64
  import { maybeShowStarPrompt } from "./cli/star-prompt";
65
65
  import { systemdProperty } from "./service-manager-probe";
66
- import { isTestHomeGuardArmed } from "./lib/test-home-guard";
66
+ import { assertNotRealLaunchAgentsUnderTest, isProtectedHomeUnderTest, isTestHomeGuardArmed } from "./lib/test-home-guard";
67
67
 
68
68
  const LABEL = "com.opencodex.proxy";
69
69
  const TASK = "opencodex-proxy";
@@ -71,7 +71,7 @@ const TASK = "opencodex-proxy";
71
71
  export type ServiceBackend = "scheduler" | "native";
72
72
 
73
73
  function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } {
74
- // Bake the bundled Bun (npm global prefix, survives `ocx update`) rather than
74
+ // Bake the bundled Bun (manager-owned global package directory, survives `ocx update`) rather than
75
75
  // a transient system Bun, so launchd/systemd/schtasks keep resolving even if a
76
76
  // standalone Bun is later removed. The CLI entry lives at src/cli/index.ts.
77
77
  //
@@ -98,11 +98,27 @@ function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bun: stri
98
98
  * Only an absolute path is accepted. A bare `ocx` would be re-resolved through `PATH` on
99
99
  * every restart, which turns a service definition into a PATH-hijacking surface; naming
100
100
  * one validated absolute file keeps the target fixed at install time.
101
+ *
102
+ * The RECORDED launcher wins over a fresh PATH walk. `ocx service repair` runs from
103
+ * whatever shell the operator (or `ocx update`, or a tray helper) happened to have, and a
104
+ * context without `ocx` on `PATH` used to resolve null here — rewriting a working
105
+ * launcher-form plist into the version-pinned Bun + CLI pair and then booting the healthy
106
+ * job out to load it (#4236, defect 1g). A launcher that is still an executable file is
107
+ * the thing the installed service already runs, so repair must keep naming it; only a
108
+ * recorded launcher that has disappeared falls through to discovery.
109
+ *
110
+ * That preference is NOT macOS-only: `installSystemd` resolves this same function, so a
111
+ * Linux `ocx service repair` from a PATH-less context keeps the `ExecStart` the unit
112
+ * already has instead of rewriting it to the version-pinned pair — the #2898 shape this
113
+ * function exists to avoid. The failure mode it prevents is milder there (systemd
114
+ * `daemon-reload` + `restart` does not evict-then-maybe-nothing the way launchd did), but
115
+ * the rewrite was the same, so the behavior is deliberately shared rather than branched.
101
116
  */
102
117
  export function stableLauncherEntry(deps: {
103
118
  env?: NodeJS.ProcessEnv;
104
119
  isExecutableFile?: (path: string) => boolean;
105
120
  pathDelimiter?: string;
121
+ state?: ServiceInstallState | null;
106
122
  } = {}): string | null {
107
123
  const env = deps.env ?? process.env;
108
124
  const isExecutableFile = deps.isExecutableFile ?? ((path: string): boolean => {
@@ -114,6 +130,8 @@ export function stableLauncherEntry(deps: {
114
130
  return false;
115
131
  }
116
132
  });
133
+ const recorded = (deps.state === undefined ? readServiceInstallState() : deps.state)?.launcherPath;
134
+ if (recorded && isAbsolute(recorded) && isExecutableFile(recorded)) return recorded;
117
135
  const entries = (env.PATH ?? "").split(deps.pathDelimiter ?? delimiter);
118
136
  for (const entry of entries) {
119
137
  if (!entry || !isAbsolute(entry)) continue;
@@ -163,7 +181,44 @@ export function serviceStatePathsForOpenCodexHome(opencodexHome: string): string
163
181
  }
164
182
 
165
183
  function serviceStatePaths(): string[] {
166
- return serviceStatePathsForOpenCodexHome(currentOpenCodexHome());
184
+ const paths = serviceStatePathsForOpenCodexHome(currentOpenCodexHome());
185
+ if (!isTestHomeGuardArmed()) return paths;
186
+ /*
187
+ * Under an armed test process the legacy default-home entry IS the developer's real
188
+ * `~/.opencodex/service-state.json`. It is there so an install made before
189
+ * OPENCODEX_HOME was set can still be found, but it means a test whose OPENCODEX_HOME
190
+ * points at a sandbox still writes their live install state — observed while building
191
+ * the launchd repair coverage: one case replaced the real record's codexHome and
192
+ * opencodexHome with temp-directory paths. Drop it rather than deny the write, so the
193
+ * sandbox path keeps working and the real one is simply not in the list.
194
+ *
195
+ * The predicate is the guard's own, not a local `resolve()` compare: the guard
196
+ * canonicalizes through `realpath`, and on macOS a sandbox under `/var/folders/...`
197
+ * resolves to `/private/var/folders/...`, so two spellings of one directory must not
198
+ * decide this.
199
+ */
200
+ return paths.filter(path => !isProtectedHomeUnderTest(dirname(path)));
201
+ }
202
+
203
+ /**
204
+ * The state paths a WRITE may use. Same list, but an empty one is an error instead of a
205
+ * silent no-op.
206
+ *
207
+ * With OPENCODEX_HOME unset under an armed test process, `currentOpenCodexHome()` falls
208
+ * back to the real `~/.opencodex` (`os.homedir()` ignores `$HOME`), the filter above then
209
+ * removes every candidate, and `writeServiceInstallState` wrote NOTHING while reporting
210
+ * success — a test asserting on install state would read the previous run's record, or
211
+ * none. Fail the way `assertNotRealHomeUnderTest` does, naming the fix.
212
+ */
213
+ function serviceStateWritePaths(): string[] {
214
+ const paths = serviceStatePaths();
215
+ if (paths.length > 0) return paths;
216
+ throw new Error(
217
+ "refusing to write service install state with no writable state path: every candidate "
218
+ + "resolved to the real OpenCodex home and was filtered out. Point OPENCODEX_HOME at a "
219
+ + "temp directory for this test (the preload does it for every invocation; something "
220
+ + "deleted the variable without restoring it).",
221
+ );
167
222
  }
168
223
 
169
224
  function currentCodexHome(deps: CodexHomeDeps = {}): string {
@@ -254,7 +309,7 @@ function writeServiceInstallState(backend: ServiceBackend = "scheduler", launche
254
309
  backend,
255
310
  ...(backend === "native" ? { winswVersion: WINSW_VERSION, winswSha256: WINSW_SHA256 } : {}),
256
311
  };
257
- for (const path of serviceStatePaths()) {
312
+ for (const path of serviceStateWritePaths()) {
258
313
  const dir = dirname(path);
259
314
  recordOwnedConfigPath(getConfigDir(), path);
260
315
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -447,41 +502,93 @@ export function serviceRetryCommand(
447
502
  * installing shell. This function is the chokepoint that should refuse it rather than
448
503
  * writing a file that produces a broken service. Comparison is the same helper doctor
449
504
  * uses: minted `ocx_admin_…` prefix, or byte-equal to configuredAdminToken (env or file).
505
+ *
506
+ * `source` selects the remedy, not the rule. The token can also arrive from an EXISTING
507
+ * `service-api-token` that install/repair reuses, and there `unset` is meaningless advice —
508
+ * the fix is to delete the file so a data-plane token is generated.
450
509
  */
451
- export function assertNotAdminToken(token: string, env: NodeJS.ProcessEnv = process.env): void {
510
+ export function assertNotAdminToken(
511
+ token: string,
512
+ env: NodeJS.ProcessEnv = process.env,
513
+ source: "env" | "file" = "env",
514
+ ): void {
452
515
  if (!tokenCollidesWithAdmin(token, env)) return;
516
+ if (source === "file") {
517
+ // The file branch of `writeServiceApiTokenFile` used to skip this check entirely, so a
518
+ // hand-pasted admin token already on disk (pre-#2696, or the exact #4236 incident) was
519
+ // silently reused: `ocx status` said `present (file)` and the hub crash-looped at boot.
520
+ // The remedy is NOT `unset` -- there is nothing in the environment to unset.
521
+ throw new Error(
522
+ `${serviceApiTokenFilePath()} holds a management (admin) token, not a data-plane token. `
523
+ + "The service exports that file as the data-plane secret, which fences the whole management "
524
+ + "API closed and makes every ocx management command fail with 503, so the hub crash-loops at "
525
+ + `boot. Delete the file (rm ${serviceApiTokenFilePath()}), then rerun \`ocx service repair\` `
526
+ + "(or `ocx service install` when the service is not installed yet): a fresh owner-only "
527
+ + "data-plane token is generated and nothing needs to be exported by hand.",
528
+ );
529
+ }
453
530
  throw new Error(
454
531
  "OPENCODEX_API_AUTH_TOKEN holds a management (admin) token. The service exports it "
455
532
  + "as the data-plane secret, which fences the whole management API closed and makes "
456
- + "every ocx management command fail with 503. Unset OPENCODEX_API_AUTH_TOKEN, or set "
457
- + "it to a distinct data-plane key, then rerun the install.",
533
+ + "every ocx management command fail with 503. Run `unset OPENCODEX_API_AUTH_TOKEN` "
534
+ + "and rerun: nothing needs to be exported by hand, because the service provisions "
535
+ + `its own owner-only data-plane token at ${serviceApiTokenFilePath()}.`,
458
536
  );
459
537
  }
460
538
 
539
+ /**
540
+ * Preflight for `service install` / `service repair` on the data-plane credential.
541
+ *
542
+ * It used to DEMAND `OPENCODEX_API_AUTH_TOKEN` for a non-loopback hostname, and it threw
543
+ * even when `~/.opencodex/service-api-token` already held a perfectly good token. That is
544
+ * the defect behind the incident this unit exists to close (#4236): an operator exported the
545
+ * ADMIN token as OPENCODEX_API_AUTH_TOKEN because `install` asked for a token, the hub then
546
+ * crash-looped on `assertNotAdminToken`, and `service repair` asked for the same env var
547
+ * again — so the only remembered way to make the command proceed was the thing that broke it.
548
+ *
549
+ * Nobody should have to export a token by hand to run a hub. {@link writeServiceApiTokenFile}
550
+ * provisions one, so the only conditions left that install cannot fix are an admin-token
551
+ * collision in the environment and a token file that exists but cannot be used.
552
+ */
461
553
  export function assertServiceAuthEnvironment(): void {
462
554
  const config = loadConfig();
463
- // Check the collision before the loopback short-circuit: a loopback install writes
464
- // the token file too, so returning early here is what let the broken state through.
555
+ // Both collision checks come BEFORE the loopback short-circuit, because the launch wrapper
556
+ // exports the token file unconditionally (`buildServiceShellCommand` cats it whenever it
557
+ // exists, whatever the hostname): a management token in either source fences the whole
558
+ // management plane closed at boot, even on a loopback install that needs no admission
559
+ // secret. Returning early is what let that broken state through.
465
560
  const present = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
466
561
  if (present) assertNotAdminToken(present);
562
+ const state = readServiceApiTokenState();
563
+ // An existing FILE holding the admin token is the incident shape itself, and the first round
564
+ // only checked the env var — so install/repair reused it and the hub crash-looped at boot.
565
+ // On a machine connected to a hub this same file holds that hub's issued client key, which
566
+ // is never a management token, so the check is a no-op there.
567
+ if (state.kind === "present") assertNotAdminToken(state.token, process.env, "file");
467
568
  if (isLoopbackHostname(config.hostname)) return;
468
- if (process.env.OPENCODEX_API_AUTH_TOKEN?.trim()) return;
469
- // Reached from `service repair` as well as `install`, so name a command that can
470
- // actually succeed (see serviceRetryCommand).
569
+ if (present) return;
570
+ // Absent is fine — install/repair generates one below. `unsafe` is not: the writer refuses
571
+ // to replace a path it cannot vouch for, so say so here, where the operator can still act,
572
+ // instead of failing mid-install. Reached from `service repair` as well as `install`, so
573
+ // name a command that can actually succeed (see serviceRetryCommand).
574
+ if (state.kind !== "unsafe") return;
471
575
  const diag = diagnoseService();
472
- const retry = serviceRetryCommand(diag);
473
576
  throw new Error(
474
- `OPENCODEX_API_AUTH_TOKEN is required before ${diag.installed ? "refreshing" : "installing"} a service `
475
- + `for non-loopback hostname. Set it in the same shell, then rerun \`${retry}\`.`,
577
+ `The data-plane token file cannot be used (${state.reason}): ${serviceApiTokenFilePath()}. `
578
+ + `Move it aside, then rerun \`${serviceRetryCommand(diag)}\`; the service provisions a `
579
+ + "fresh owner-only token and needs nothing from the environment.",
476
580
  );
477
581
  }
478
582
 
479
- function writeServiceApiTokenFile(): string | null {
480
- const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
481
- if (!token) return null;
482
- // Last line of defence: every install/repair path funnels through here, so a
483
- // collision cannot reach disk regardless of which caller ran (#2696).
484
- assertNotAdminToken(token);
583
+ /** How the data-plane token the service will export was obtained. */
584
+ export type ServiceApiTokenOrigin = "env" | "file" | "generated";
585
+
586
+ export interface ProvisionedServiceApiToken {
587
+ path: string;
588
+ origin: ServiceApiTokenOrigin;
589
+ }
590
+
591
+ function persistServiceApiToken(token: string): string {
485
592
  const path = serviceApiTokenFilePath();
486
593
  const dir = getConfigDir();
487
594
  recordOwnedConfigPath(dir, path);
@@ -493,6 +600,67 @@ function writeServiceApiTokenFile(): string | null {
493
600
  return path;
494
601
  }
495
602
 
603
+ /**
604
+ * Put a usable data-plane token on disk for the service to read at launch, and say where.
605
+ *
606
+ * EVERY backend funnels through here — launchd, systemd, the Windows scheduler wrapper and
607
+ * WinSW native — because the launch wrapper's only source of the secret is this file
608
+ * (`buildServiceShellCommand` cats it into the environment; WinSW reads it through
609
+ * `OCX_API_TOKEN_FILE`). One chokepoint is also what makes the admin-token refusal
610
+ * unskippable (#2696).
611
+ *
612
+ * Precedence, in order:
613
+ * 1. `OPENCODEX_API_AUTH_TOKEN` from the installing shell — still refused outright when it is
614
+ * an admin token. An operator who deliberately exports a key keeps full control of it.
615
+ * 2. An existing owner-only `service-api-token`. Reusing it is what makes `repair`, a
616
+ * reinstall and a restart idempotent; regenerating would silently invalidate every client
617
+ * key-exchange already performed against the old value.
618
+ * 3. 32 fresh random bytes, hex. This is the branch that removes the manual step: a hub
619
+ * install on a non-loopback hostname provisions its own secret.
620
+ *
621
+ * A loopback install with no env token gets nothing: admission is not required there, so
622
+ * creating a credential would be inventing a secret nobody asked for — and on a machine
623
+ * connected to a hub the same file holds that hub's issued client key, which must not be
624
+ * overwritten by a local install.
625
+ *
626
+ * The PATH is logged; the value never is, and never reaches argv, a unit file or a plist.
627
+ */
628
+ export function writeServiceApiTokenFile(): ProvisionedServiceApiToken | null {
629
+ const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
630
+ if (token) {
631
+ // Last line of defence: every install/repair path funnels through here, so a
632
+ // collision cannot reach disk regardless of which caller ran (#2696).
633
+ assertNotAdminToken(token);
634
+ const path = persistServiceApiToken(token);
635
+ console.log(`🔐 Data-plane token taken from OPENCODEX_API_AUTH_TOKEN and stored at ${path} (owner-only).`);
636
+ return { path, origin: "env" };
637
+ }
638
+ if (isLoopbackHostname(loadConfig().hostname)) return null;
639
+ const existing = readServiceApiTokenState();
640
+ if (existing.kind === "present") {
641
+ // The collision check is NOT only for the env branch. A file that already holds the admin
642
+ // token -- hand-pasted before #2696, or written by the very incident this unit closes --
643
+ // was silently accepted here, so `ocx status` reported `present (file)` and the hub
644
+ // crash-looped at boot with no command pointing at the cause.
645
+ const path = serviceApiTokenFilePath();
646
+ assertNotAdminToken(existing.token, process.env, "file");
647
+ // `readServiceApiTokenState` accepts any bounded regular file, so a reused token may well
648
+ // be group- or world-readable. Tighten it on the way through rather than claiming
649
+ // "owner-only" about a mode nobody checked; best-effort, since a non-owner cannot chmod
650
+ // and failing the install over it would be worse than the loose mode.
651
+ try { chmodSync(path, 0o600); } catch { /* best-effort */ }
652
+ if (process.platform === "win32") hardenSecretPath(path, { required: false });
653
+ // No log line: repair/restart hit this on every run and an unconditional notice about a
654
+ // credential file trains operators to ignore the one that matters.
655
+ return { path, origin: "file" };
656
+ }
657
+ if (existing.kind === "unsafe") throw new Error(`${existing.reason}: ${serviceApiTokenFilePath()}`);
658
+ const path = persistServiceApiToken(randomBytes(32).toString("hex"));
659
+ console.log(`🔐 Provisioned an owner-only data-plane token at ${path}; nothing needs to be exported by hand.`);
660
+ console.log(" Remote machines get their own per-client key — run 'ocx hub invite' instead of copying this file.");
661
+ return { path, origin: "generated" };
662
+ }
663
+
496
664
  /**
497
665
  * Render the launchd plist. Mirrors `buildUnit`: when `deps.launcher` names a stable `ocx`
498
666
  * executable, the job execs that launcher instead of the package-local Bun + CLI pair, so a
@@ -537,9 +705,7 @@ export function buildPlist(
537
705
  ...proxyEnv.map(({ name, value }) =>
538
706
  ` <key>${name}</key><string>${plistString(value)}</string>`),
539
707
  ].filter((line): line is string => Boolean(line)).join("\n");
540
- const command = launcher
541
- ? buildServiceLauncherShellCommand(launcher)
542
- : buildServiceShellCommand(bun, cli);
708
+ const command = launchdServiceCommand(launcher, runtime);
543
709
  return `<?xml version="1.0" encoding="UTF-8"?>
544
710
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
545
711
  <plist version="1.0">
@@ -564,6 +730,46 @@ ${envLines}
564
730
  `;
565
731
  }
566
732
 
733
+ /**
734
+ * The single `EnvironmentVariables` line {@link buildPlist} fills from the env of whatever
735
+ * process happens to be repairing.
736
+ */
737
+ const PLIST_PATH_ENTRY = /^(\s*<key>PATH<\/key><string>)([^\n]*)(<\/string>)$/m;
738
+
739
+ /**
740
+ * The rendered plist with the PREVIOUS definition's `PATH` put back — or null when `PATH`
741
+ * is not the only difference.
742
+ *
743
+ * `buildPlist` bakes `process.env.PATH`, and `ocx service repair` is run by whatever has a
744
+ * shell: a tray helper, `ocx update`'s child, an ssh session, a cron job. Each of those
745
+ * carries a DIFFERENT PATH from the login shell that installed the service, so comparing
746
+ * whole-file bytes made the "nothing to repair" pre-check miss almost every time it
747
+ * mattered: a healthy hub was evicted, and its PATH rewritten to the narrower one, purely
748
+ * because of who asked (#4236, review finding 2).
749
+ *
750
+ * Reuse rather than ignore. A plist that differs only in PATH is not "equal" — dropping the
751
+ * difference silently would let a repair report a no-op while launchd keeps a PATH the
752
+ * operator has changed on purpose. Putting the previous value back makes the two files
753
+ * genuinely identical, so the caller's ordinary byte comparison decides, and the PATH the
754
+ * service already runs with is the one that survives.
755
+ *
756
+ * The caller applies this only when the live job is loaded from exactly the exec line this
757
+ * install baked: that is the evidence that the running definition is the one on disk, which
758
+ * is what makes keeping its PATH correct rather than a guess. Whenever anything ELSE about
759
+ * the definition changed the plist is rewritten in full, PATH included, so a real
760
+ * re-install still updates it.
761
+ */
762
+ export function reusePreviousPlistPathVariable(previous: string, rendered: string): string | null {
763
+ const prev = PLIST_PATH_ENTRY.exec(previous);
764
+ const next = PLIST_PATH_ENTRY.exec(rendered);
765
+ if (!prev || !next || prev[2] === next[2]) return null;
766
+ // A function replacer, not a `$1` template: a PATH entry containing `$&` or `$1` would
767
+ // otherwise be re-expanded into the file.
768
+ const adopted = rendered.replace(PLIST_PATH_ENTRY, (_match, open: string, _value: string, close: string) =>
769
+ `${open}${prev[2] ?? ""}${close}`);
770
+ return adopted === previous ? adopted : null;
771
+ }
772
+
567
773
  function shellQuote(value: string): string {
568
774
  return `'${value.replace(/'/g, "'\\''")}'`;
569
775
  }
@@ -604,6 +810,23 @@ function buildServiceLauncherShellCommand(launcher: string, port = resolveServic
604
810
  return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(launcher)} start --port ${port}`;
605
811
  }
606
812
 
813
+ /**
814
+ * The exec line {@link buildPlist} bakes, for the launcher and runtime a single install
815
+ * already resolved. Shared so `installLaunchd` can verify the live job against the exact
816
+ * string it just wrote instead of re-deriving it from install state that has not been
817
+ * written yet (a fresh install has no state, so `expectedLaunchdCommand` would hand back
818
+ * the Bun + CLI pair and call a correctly loaded launcher job stale).
819
+ */
820
+ function launchdServiceCommand(
821
+ launcher: string | null,
822
+ runtime: DurableBunRuntime = durableBunRuntime(),
823
+ port: number = resolveServiceListenPort(),
824
+ ): string {
825
+ if (launcher) return buildServiceLauncherShellCommand(launcher, port);
826
+ const { bun, cli } = cliEntry(runtime);
827
+ return buildServiceShellCommand(bun, cli, port);
828
+ }
829
+
607
830
  /**
608
831
  * The exec line the installed launchd plist is expected to carry, derived from the recorded
609
832
  * install state rather than rediscovered: a launcher install runs the launcher, a legacy or
@@ -788,7 +1011,7 @@ export async function confirmServiceServing(
788
1011
  * dead port.
789
1012
  */
790
1013
  export async function reportServiceServing(
791
- verb: "installed" | "started" | "repaired",
1014
+ verb: "installed" | "started" | "repaired" | "restarted",
792
1015
  deps: Parameters<typeof confirmServiceServing>[0] = {},
793
1016
  ): Promise<void> {
794
1017
  const healthBudgetMs = deps.timeoutMs ?? serviceInstallHealthMs();
@@ -991,6 +1214,140 @@ export function launchdJobMatchesPlist(
991
1214
  return { loaded: true, matchesPlist: printedText.includes(expectedCommand) };
992
1215
  }
993
1216
 
1217
+ /** `launchctl print`: the domain answered and holds no such service. */
1218
+ const LAUNCHCTL_NO_SUCH_SERVICE = 113;
1219
+ /** `launchctl print`: that domain does not exist at all (label-independent). */
1220
+ const LAUNCHCTL_NO_SUCH_DOMAIN = 112;
1221
+ /** `launchctl bootstrap`: something is already bootstrapped under that label. */
1222
+ const LAUNCHCTL_BOOTSTRAP_BUSY = 5;
1223
+ /** `launchctl bootout`: nothing was loaded under that label, i.e. already stopped. */
1224
+ const LAUNCHCTL_BOOTOUT_NO_SUCH_PROCESS = 3;
1225
+
1226
+ /**
1227
+ * Every domain target an eviction has to cover.
1228
+ *
1229
+ * {@link probeLaunchdLoadState} asks `gui/<uid>` AND `user/<uid>` because the two domains
1230
+ * are independent and hold separate service sets, while every MUTATING verb in this file
1231
+ * addressed `gui/<uid>` alone. So a `user/`-domain registration of our Label used to:
1232
+ * survive `ocx service stop` (`bootout gui/<uid>/<label>` exits 3, "No such process", which
1233
+ * the stop path correctly reads as "nothing was loaded" — in the wrong domain); survive the
1234
+ * install cleanup whose whole job is evicting a live manager before new assets land, which
1235
+ * then installed over a serving job; and stay registered while `installLaunchd` bootstrapped
1236
+ * a SECOND registration of the same Label into `gui/`, leaving two KeepAlive jobs fighting
1237
+ * for one port.
1238
+ *
1239
+ * Both domains unconditionally rather than the one a probe reports: `bootout` against a
1240
+ * label a domain does not hold exits 3 and changes nothing, so enumerating first would buy
1241
+ * an extra round trip to learn what the verb itself already reports.
1242
+ */
1243
+ export function launchdEvictionTargets(uid: number = process.getuid?.() ?? 0): string[] {
1244
+ return [`gui/${uid}/${LABEL}`, `user/${uid}/${LABEL}`];
1245
+ }
1246
+
1247
+ /**
1248
+ * Whether a `bootout` exit status means "nothing of ours was loaded there" rather than a
1249
+ * failure. 3 is "No such process"; 113/112 answer for the service and the domain, and a
1250
+ * domain that does not exist cannot be holding a job of ours (a headless Mac has no `gui/`).
1251
+ */
1252
+ function launchctlBootoutBenign(status: number | null): boolean {
1253
+ return status === 0
1254
+ || status === LAUNCHCTL_BOOTOUT_NO_SUCH_PROCESS
1255
+ || status === LAUNCHCTL_NO_SUCH_SERVICE
1256
+ || status === LAUNCHCTL_NO_SUCH_DOMAIN;
1257
+ }
1258
+
1259
+ /**
1260
+ * Four states, because three of them used to collapse into one bit.
1261
+ *
1262
+ * - `loaded-current` — a domain answers 0 and runs the command we expect.
1263
+ * - `loaded-stale` — a domain answers 0 but runs a different command (an older plist).
1264
+ * - `not-loaded` — every domain answered 112/113, which is proof of absence.
1265
+ * - `unknown` — launchctl could not be asked, or answered something undocumented. NOT
1266
+ * evidence of a problem, and deliberately not a reason to recommend `ocx service
1267
+ * repair`: that command evicts the job, so recommending it on a failed probe is how
1268
+ * #4236 turned a healthy hub into an outage.
1269
+ */
1270
+ export type LaunchdLoadState = "loaded-current" | "loaded-stale" | "not-loaded" | "unknown";
1271
+
1272
+ export interface LaunchdLoadProbe {
1273
+ state: LaunchdLoadState;
1274
+ /** The domain that answered, when one did. */
1275
+ domain?: string;
1276
+ /** Why the probe is `unknown`. Never carries plist contents or credentials. */
1277
+ detail?: string;
1278
+ }
1279
+
1280
+ /**
1281
+ * Whether launchd is running our job, and from which plist — asked with `launchctl print`
1282
+ * in BOTH user domains.
1283
+ *
1284
+ * Replaces `launchctl list | grep <label>`, which enumerated the CALLER's bootstrap domain
1285
+ * (so a healthy `gui/$uid` job was invisible from ssh/cron), swallowed every exit code
1286
+ * through `|| true`, and matched the label unanchored anywhere on a line (so
1287
+ * `com.opencodex.proxy.helper` read as ours). `gui/` and `user/` are independent and hold
1288
+ * separate service sets — measured on macOS 27.0: the shipped agent answers 0 under
1289
+ * `gui/<uid>` and 113 under `user/<uid>` — so asking one leaves the other free to hold a
1290
+ * job this probe would then call absent (same reasoning as `inspectLaunchd`).
1291
+ */
1292
+ export function probeLaunchdLoadState(deps: {
1293
+ launchctl?: typeof runLaunchctl;
1294
+ expectedCommand?: () => string;
1295
+ uid?: number;
1296
+ } = {}): LaunchdLoadProbe {
1297
+ const run = deps.launchctl ?? runLaunchctl;
1298
+ const uid = deps.uid ?? process.getuid?.() ?? 0;
1299
+ for (const domain of [`gui/${uid}`, `user/${uid}`]) {
1300
+ const printed = run(["print", `${domain}/${LABEL}`]);
1301
+ if (printed.status === 0) {
1302
+ const expected = (deps.expectedCommand
1303
+ ?? (() => expectedLaunchdCommand(installedServiceListenPort())))();
1304
+ const printedText = `${printed.stdout}\n${printed.stderr}`;
1305
+ return {
1306
+ state: printedText.includes(expected) ? "loaded-current" : "loaded-stale",
1307
+ domain,
1308
+ };
1309
+ }
1310
+ if (printed.status === LAUNCHCTL_NO_SUCH_SERVICE) continue;
1311
+ // 112 is an answer ABOUT THE DOMAIN and is label-independent, so an unreachable
1312
+ // domain cannot be hiding a job of ours. A headless Mac has no GUI domain and no
1313
+ // installation either; calling that `unknown` would refuse every verdict on it.
1314
+ if (printed.status === LAUNCHCTL_NO_SUCH_DOMAIN) continue;
1315
+ return {
1316
+ state: "unknown",
1317
+ detail: printed.status === null
1318
+ ? `launchctl could not be run: ${printed.stderr || "spawn failed"}`
1319
+ : `launchctl print ${domain}/${LABEL} exited ${String(printed.status)}`,
1320
+ };
1321
+ }
1322
+ return { state: "not-loaded" };
1323
+ }
1324
+
1325
+ /** Up to ~5 × 200 ms, the launchd twin of the Windows scheduler settle delays. */
1326
+ const LAUNCHD_SETTLE_ATTEMPTS = 5;
1327
+ const LAUNCHD_SETTLE_DELAY_MS = 200;
1328
+
1329
+ /**
1330
+ * Wait for a `bootout` to finish.
1331
+ *
1332
+ * `bootout` is asynchronous: it returns before the job has exited, so an immediate
1333
+ * re-registration races it and gets "Bootstrap failed: 5: Input/output error" — which is
1334
+ * exactly what made the old back-to-back retry useless (#4236, defect 1d). Bounded on
1335
+ * purpose: a genuinely wedged domain must reach the diagnosable throw rather than hang.
1336
+ *
1337
+ * Synchronous because `installLaunchd` is (`ServiceOps.install` / `repairLaunchd` are
1338
+ * `() => void`), so this uses `Bun.sleepSync` and exposes the seam for tests.
1339
+ */
1340
+ function settleLaunchdEviction(
1341
+ run: typeof runLaunchctl,
1342
+ target: string,
1343
+ sleepSync: (ms: number) => void,
1344
+ ): void {
1345
+ for (let attempt = 0; attempt < LAUNCHD_SETTLE_ATTEMPTS; attempt += 1) {
1346
+ if (!run(["print", target]).ok) return;
1347
+ sleepSync(LAUNCHD_SETTLE_DELAY_MS);
1348
+ }
1349
+ }
1350
+
994
1351
  /**
995
1352
  * Decode schtasks stdout. `/query /xml` emits UTF-16LE (often with BOM) because the
996
1353
  * registered task document is UTF-16; reading that as UTF-8 makes every health check
@@ -2338,9 +2695,32 @@ export function readWindowsSchedulerXmlState(
2338
2695
  }
2339
2696
 
2340
2697
  // ── macOS (launchd) ──
2698
+ /** Read a file as UTF-8, or null when it is absent/unreadable. */
2699
+ function readTextOrNull(path: string): string | null {
2700
+ try {
2701
+ return readFileSync(path, "utf8");
2702
+ } catch {
2703
+ return null;
2704
+ }
2705
+ }
2706
+
2707
+ /**
2708
+ * What an install or repair actually DID to launchd.
2709
+ *
2710
+ * `reloaded: false` means the no-op path was taken — the plist on disk was already the
2711
+ * rendered one, the data token was unchanged, and the probe answered `loaded-current` — so
2712
+ * launchd was never asked for anything and the job is still the same process it was. That
2713
+ * is the right answer for `repair` (a repair of a healthy service must not be an outage)
2714
+ * and the WRONG one for `restart`, which is the verb an operator reaches for precisely when
2715
+ * they want a new process. Only `restart` acts on it; see {@link restartLaunchdJob}.
2716
+ */
2717
+ export interface LaunchdInstallOutcome {
2718
+ reloaded: boolean;
2719
+ }
2720
+
2341
2721
  /**
2342
2722
  * Deps follow {@link startLaunchd}: `launchctl` replaces the LAYER, returning a
2343
- * {@link runLaunchctl} result, not a spawnSync result. It is optional so this stays
2723
+ * {@link runLaunchctl} result, not a spawnSync result. Every one is optional so this stays
2344
2724
  * assignable to `ServiceOps.install` and `RepairServiceDeps.repairLaunchd`
2345
2725
  * (`() => void`), and so `platformOps` wires the same function the tests exercise.
2346
2726
  *
@@ -2349,66 +2729,325 @@ export function readWindowsSchedulerXmlState(
2349
2729
  * its read-only list, so a test reaching the real runner would fail closed on the guard
2350
2730
  * instead of exercising the sequence.
2351
2731
  *
2352
- * No `matches` dep: unlike `startLaunchd`, this function never consults
2353
- * {@link launchdJobMatchesPlist}. It has just rewritten the plist, so a live job is stale
2354
- * by construction and there is nothing to compare against.
2732
+ * `probe` is the TRI-STATE {@link probeLaunchdLoadState}, used twice and for opposite
2733
+ * reasons: once before touching launchd, to prove a repair has nothing to do, and once
2734
+ * after, because stderr cannot prove a load took. It is deliberately not the two-state
2735
+ * `launchdJobMatchesPlist`, which reports `loaded: false` for every non-zero
2736
+ * `launchctl print` — EPERM from a non-Aqua ssh/cron context, an unspawnable launchctl, an
2737
+ * undocumented status. With that one, a healthy serving hub read as "not loaded" in the
2738
+ * pre-check (so repair evicted it) and again in the verification (so the rollback evicted
2739
+ * it a second time and the error claimed "IS NOT RUNNING" about a job that was up). An
2740
+ * `unknown` probe is not evidence, so it refuses to evict instead.
2741
+ *
2742
+ * Protocol (#4236, defect 1). `ocx service repair` on darwin IS this function, and it
2743
+ * evicts the running job — a public proxy, a management ingress and a loopback listener on
2744
+ * a hub. So:
2745
+ *
2746
+ * 1. Ask launchd what it is running BEFORE writing anything. `unknown` throws without
2747
+ * touching a file or a job; `loaded-current` plus an identical plist and an unchanged
2748
+ * token file means there is nothing to repair, and a repair of a healthy service must
2749
+ * never cause an outage.
2750
+ * 2. Keep the previous plist bytes (in memory and at `<plist>.prev`) before overwriting.
2751
+ * 3. `bootout` BOTH user domains, settle, then `bootstrap gui/$uid <plist>` — the verb
2752
+ * that PAIRS with the bootout target. Legacy `load -w` is domain-implicit: it acts on
2753
+ * the caller's own bootstrap domain, so from ssh/cron it deleted the gui-domain job and
2754
+ * registered nothing (defect 1a).
2755
+ * 4. Success is `launchctl print` agreeing, never a stderr regex: measured on macOS 27.0,
2756
+ * `load -w` over a bootstrapped job exits 0 with "Load failed: 5" and does nothing,
2757
+ * and `bootstrap` exits 5 with "Bootstrap failed: 5" for the same condition.
2758
+ * 5. On terminal failure restore the previous plist, try to bootstrap it back, and throw
2759
+ * an error that says what the probe actually found — down, or up on a different
2760
+ * command — and names the manual remedy.
2761
+ *
2762
+ * Returns {@link LaunchdInstallOutcome} so the one caller that needs a RESTART rather than a
2763
+ * repair can tell the no-op path from a reload. See {@link restartLaunchdJob}.
2355
2764
  */
2356
- export function installLaunchd(deps: { launchctl?: typeof runLaunchctl } = {}): void {
2765
+ export function installLaunchd(deps: {
2766
+ launchctl?: typeof runLaunchctl;
2767
+ probe?: typeof probeLaunchdLoadState;
2768
+ sleepSync?: (ms: number) => void;
2769
+ /**
2770
+ * Where to write the plist. Only tests pass it: `os.homedir()` reads the password
2771
+ * database rather than `$HOME`, so the suite's HOME sandbox does NOT move
2772
+ * `plistPath()`, and a case without this seam rewrites the developer's live
2773
+ * `com.opencodex.proxy.plist`. `assertNotRealLaunchAgentsUnderTest` below makes that
2774
+ * refusal mechanical rather than a convention.
2775
+ */
2776
+ plistPath?: string;
2777
+ } = {}): LaunchdInstallOutcome {
2357
2778
  const run = deps.launchctl ?? runLaunchctl;
2358
- const dir = join(homedir(), "Library", "LaunchAgents");
2359
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
2360
- recordOwnedConfigPath(getConfigDir(), serviceStatePath());
2361
- if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
2362
- writeServiceApiTokenFile();
2363
- const p = plistPath();
2779
+ const probeLoadState = deps.probe ?? probeLaunchdLoadState;
2780
+ const sleepSync = deps.sleepSync ?? ((ms: number) => { Bun.sleepSync(ms); });
2781
+ const p = deps.plistPath ?? plistPath();
2782
+ const dir = dirname(p);
2783
+ assertNotRealLaunchAgentsUnderTest(dir);
2364
2784
  // Capture this BEFORE writing: the write below makes the plist exist unconditionally,
2365
2785
  // so a post-write existsSync would call every fresh install an "installed" service.
2366
2786
  const wasInstalled = existsSync(p);
2787
+ // The previous definition, kept for rollback. An eviction whose bootstrap fails used to
2788
+ // end with the new plist on disk, nothing in launchd, and nothing listening.
2789
+ const previousPlist = wasInstalled ? readTextOrNull(p) : null;
2367
2790
  // Resolve the launcher ONCE and hand the same value to the plist and to install state,
2368
2791
  // so the staleness diagnostic judges exactly what launchd runs.
2369
2792
  const launcher = stableLauncherEntry();
2370
- writeServiceDefinitionFile(p, buildPlist(resolvedProxyEnv(), { launcher }), "utf8");
2371
- // `unload` is the legacy verb and it does not evict a job bootstrapped into the GUI
2372
- // domain — which is precisely the state that could not repair itself. Modern launchd
2373
- // answers `load -w` for an already-bootstrapped job with "Load failed: 5:
2374
- // Input/output error" AND exits 0, so `ocx update` replaced the binary, ran repair,
2375
- // and left launchd running the PREVIOUS job while the fresh plist sat unused (#4141).
2376
- //
2377
- // This EVICTS the running job. That is the repair being asked for, and it is why it
2378
- // lives here and nowhere else: `installLaunchd` has already rewritten the plist, so
2379
- // whatever is loaded is stale by construction. `ocx service start` must never do
2380
- // this, and `startLaunchd` accordingly still refuses to.
2793
+ // The command THIS install bakes, not the one install state remembers: on a fresh
2794
+ // install there is no state yet, and after a lost state file `expectedLaunchdCommand`
2795
+ // falls back to the Bun + CLI pair and would call a correct launcher job stale (#3464).
2796
+ const expectedCommand = launchdServiceCommand(launcher);
2797
+ const uid = process.getuid?.() ?? 0;
2798
+ const guiDomain = launchdGuiDomain();
2799
+ const guiTarget = `${guiDomain}/${LABEL}`;
2800
+ const evictionTargets = launchdEvictionTargets(uid);
2801
+ const probeLive = (): LaunchdLoadProbe => probeLoadState({ expectedCommand: () => expectedCommand });
2802
+
2803
+ // Nothing has been written yet, deliberately: a probe that cannot answer must leave the
2804
+ // host exactly as it found it.
2805
+ let verdict = probeLive();
2806
+ if (verdict.state === "unknown") {
2807
+ throw new Error(
2808
+ `refusing to ${wasInstalled ? "repair" : "install"} ${LABEL}: launchd state could not be verified `
2809
+ + `— ${verdict.detail ?? "launchctl could not be asked"}.\n`
2810
+ + "The job may be RUNNING, and this command evicts it, so nothing was changed.\n"
2811
+ + `Check it with:\n launchctl print ${guiTarget}\n launchctl print user/${uid}/${LABEL}\n`
2812
+ + "A non-Aqua context (ssh, cron, a launchd daemon) cannot always reach gui/<uid>; re-run "
2813
+ + `'${wasInstalled ? "ocx service repair" : "ocx service install"}' from a GUI login session.`,
2814
+ );
2815
+ }
2816
+
2817
+ let rendered = buildPlist(resolvedProxyEnv(), { launcher });
2818
+ if (previousPlist !== null && previousPlist !== rendered && verdict.state === "loaded-current") {
2819
+ // The live job runs exactly the exec line this install baked, so the definition on disk
2820
+ // IS the one launchd is running: keep the PATH it already carries instead of replacing
2821
+ // it with the repairing process's. See `reusePreviousPlistPathVariable`.
2822
+ const adopted = reusePreviousPlistPathVariable(previousPlist, rendered);
2823
+ if (adopted !== null) rendered = adopted;
2824
+ }
2825
+ // Whether launchd has to be handed NEW BYTES, which is what decides below whether a
2826
+ // `kickstart` can be trusted. `previousPlist === null` (a fresh install) counts: whatever
2827
+ // the label may hold did not come from a definition we can see.
2828
+ const renderedDiffers = previousPlist !== rendered;
2829
+
2830
+ // ── Writes start here. ──
2831
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
2832
+ recordOwnedConfigPath(getConfigDir(), serviceStatePath());
2833
+ if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
2834
+ const tokenFile = serviceApiTokenFilePath();
2835
+ const previousToken = readTextOrNull(tokenFile);
2836
+ writeServiceApiTokenFile();
2837
+ // A rotated data token only reaches the job through a restart, so it is part of "is this
2838
+ // repair a no-op?" — the plist `cat`s this file at launch.
2839
+ const tokenUnchanged = readTextOrNull(tokenFile) === previousToken;
2840
+
2841
+ if (!renderedDiffers && tokenUnchanged && verdict.state === "loaded-current") {
2842
+ // The plist is not rewritten and launchd is not touched; only the owner-only mode
2843
+ // is re-asserted, for a definition an older version may have left at 0644.
2844
+ try { chmodSync(p, 0o600); } catch { /* best-effort */ }
2845
+ // Install state is refreshed because it is what `expectedLaunchdCommand` reads, and
2846
+ // a repair that leaves it stale re-creates the false "OLDER plist" report.
2847
+ writeServiceInstallState("scheduler", launcher);
2848
+ console.log("ℹ️ service is already loaded from the current plist; nothing to do.");
2849
+ // The ONLY `reloaded: false` exit: the process launchd was running when this command
2850
+ // started is still running, same pid. `ocx service restart` turns that into a kickstart.
2851
+ return { reloaded: false };
2852
+ }
2853
+
2854
+ if (previousPlist !== null) {
2855
+ // Best-effort: a backup we could not write must not stop the repair, but it is the
2856
+ // only thing that makes the rollback below able to restore bytes rather than guesses.
2857
+ //
2858
+ // `.plist.prev`, not `.prev.plist`: launchd globs `~/Library/LaunchAgents/*.plist` at
2859
+ // login, so a backup ending in `.plist` would be a second registration of the same
2860
+ // Label fighting the real one for the port.
2861
+ try { writeServiceDefinitionFile(`${p}.prev`, previousPlist, "utf8"); } catch { /* best-effort */ }
2862
+ }
2863
+ writeServiceDefinitionFile(p, rendered, "utf8");
2864
+
2865
+ // This EVICTS the running job, and from here until the verification below nothing is
2866
+ // listening. `unload` is the legacy verb and does not evict a job bootstrapped into the
2867
+ // GUI domain — precisely the state that could not repair itself (#4141).
2381
2868
  //
2382
- // Absence is fine: booting out a job that is not there is a no-op, and a real failure
2383
- // is reported by the load verification below with a better message than a raw
2869
+ // Absence is fine: booting out a job that is not there exits 3 ("No such process"), and
2870
+ // a real failure is reported by the verification below with a better message than a raw
2384
2871
  // eviction error would carry.
2385
- const bootoutTarget = `${launchdGuiDomain()}/${LABEL}`;
2386
- run(["bootout", bootoutTarget]);
2387
- let loaded = run(["load", "-w", p]);
2388
- if (launchctlLoadFailed(loaded.stderr)) {
2389
- // Still bootstrapped after an eviction: the job re-registered between the two calls,
2390
- // or the first `bootout` raced a job that had not finished exiting. Evict and load
2391
- // once more — ONCE. A bounded retry recovers the race; a loop would turn a genuinely
2392
- // wedged domain into a hang instead of the diagnosable throw below.
2393
- run(["bootout", bootoutTarget]);
2394
- loaded = run(["load", "-w", p]);
2395
- }
2396
- if (!loaded.ok || launchctlLoadFailed(loaded.stderr)) {
2397
- // Do NOT write install state for a load that did not take: state describing an
2398
- // unused plist is what made this failure invisible.
2872
+ const evictEveryDomain = (): void => {
2873
+ for (const target of evictionTargets) {
2874
+ // Settle only where something was actually evicted. `bootout` is asynchronous, so a
2875
+ // job it DID remove needs waiting for; one it never held (exit 3) has nothing to
2876
+ // wait on, and probing it would only add a round trip per install.
2877
+ if (run(["bootout", target]).status === 0) settleLaunchdEviction(run, target, sleepSync);
2878
+ }
2879
+ };
2880
+ const evictThenBootstrap = (): { ok: boolean; stdout: string; stderr: string; status: number | null } => {
2881
+ evictEveryDomain();
2882
+ return run(["bootstrap", guiDomain, p]);
2883
+ };
2884
+
2885
+ let loaded = evictThenBootstrap();
2886
+ verdict = probeLive();
2887
+ // `unknown` is excluded on purpose: a retry means another eviction, and a probe that
2888
+ // could not answer is not a reason to take the job down again.
2889
+ if (verdict.state === "not-loaded" || verdict.state === "loaded-stale") {
2890
+ // ONE retry. A bounded retry recovers the race; a loop would turn a wedged domain
2891
+ // into a hang instead of the diagnosable throw below.
2892
+ if (loaded.status === LAUNCHCTL_BOOTSTRAP_BUSY || launchctlLoadFailed(loaded.stderr)) {
2893
+ // "Bootstrap failed: 5: Input/output error" has TWO causes, measured on macOS 27.0
2894
+ // with a throwaway label, and they need opposite remedies:
2895
+ //
2896
+ // - something is still bootstrapped under our label (the job re-registered, or it
2897
+ // had not finished exiting). `kickstart -k` restarts what the domain holds without
2898
+ // opening a second eviction window — but it restarts launchd's CACHED definition
2899
+ // and does NOT re-read the plist, so it can only settle this when the rendered
2900
+ // bytes are the ones already on disk. With new bytes it would restart the OLD
2901
+ // definition, and since the exec line is unchanged whenever only
2902
+ // `EnvironmentVariables` moved, the verification below would agree and install
2903
+ // state would be written while launchd kept the stale environment. So: new bytes
2904
+ // skip kickstart and go to the eviction, which is the only way to publish them.
2905
+ // - the label is in the domain's DISABLED list, so `bootstrap` refuses it while
2906
+ // `launchctl print` reports 113. This is the one thing the legacy `load -w` did
2907
+ // that plain `bootstrap` does not: `-w` cleared that flag. `enable` is the modern
2908
+ // spelling of it, and it is idempotent on a job that was never disabled — but it
2909
+ // runs only here, so an ordinary repair does not quietly undo a deliberate
2910
+ // `launchctl disable`.
2911
+ const kicked = renderedDiffers ? null : run(["kickstart", "-k", guiTarget]);
2912
+ if (kicked?.ok) verdict = probeLive();
2913
+ if (verdict.state !== "loaded-current") {
2914
+ run(["enable", guiTarget]);
2915
+ loaded = evictThenBootstrap();
2916
+ verdict = probeLive();
2917
+ }
2918
+ } else if (loaded.ok) {
2919
+ // Exit 0 while `print` disagrees: the load silently no-op'd. That IS worth evicting
2920
+ // again. A load that failed for any OTHER reason — a malformed plist, EPERM — is not
2921
+ // fixed by evicting a job, so it falls straight through to the throw and the
2922
+ // operator sees the real stderr instead of a delayed copy of it.
2923
+ loaded = evictThenBootstrap();
2924
+ verdict = probeLive();
2925
+ }
2926
+ }
2927
+
2928
+ if (verdict.state === "unknown") {
2929
+ // The probe stopped answering between the pre-check and here. Do NOT evict again, do
2930
+ // NOT roll back (a rollback is another eviction) and do NOT claim the job is down: the
2931
+ // bytes we asked launchd to load are the ones on disk either way.
2932
+ if (!loaded.ok) {
2933
+ throw new Error(
2934
+ `launchctl could not bootstrap ${p}: ${loaded.stderr || "bootstrap reported failure"}\n`
2935
+ + `and the state of ${LABEL} could not be verified afterwards — ${verdict.detail ?? "launchctl could not be asked"}.\n`
2936
+ + "The job was NOT evicted again and the plist was left in place, so this says nothing about "
2937
+ + "whether it is running.\n"
2938
+ + `Check it with:\n launchctl print ${guiTarget}\n launchctl print user/${uid}/${LABEL}\n`
2939
+ + `and load it if it is absent:\n launchctl bootstrap ${guiDomain} ${p}`,
2940
+ );
2941
+ }
2942
+ console.warn(
2943
+ `⚠️ launchctl accepted the bootstrap but the job state could not be verified — ${
2944
+ verdict.detail ?? "launchctl could not be asked"}. Check: launchctl print ${guiTarget}`,
2945
+ );
2946
+ writeServiceInstallState("scheduler", launcher);
2947
+ return { reloaded: true };
2948
+ }
2949
+
2950
+ if (verdict.state !== "loaded-current") {
2951
+ // Do NOT write install state for a load that did not take: state describing an unused
2952
+ // plist is what made this failure invisible.
2953
+ let rolledBack: "restored" | "on-disk-only" | "none" = "none";
2954
+ if (previousPlist !== null) {
2955
+ try {
2956
+ writeServiceDefinitionFile(p, previousPlist, "utf8");
2957
+ evictEveryDomain();
2958
+ run(["bootstrap", guiDomain, p]);
2959
+ rolledBack = run(["print", guiTarget]).ok ? "restored" : "on-disk-only";
2960
+ } catch {
2961
+ rolledBack = "on-disk-only";
2962
+ }
2963
+ }
2964
+ // What the probe actually found, rather than one sentence for both outcomes: a
2965
+ // `loaded-stale` job IS running, and telling its operator "nothing is listening" sends
2966
+ // them to fix the wrong thing.
2967
+ const state = verdict.state === "loaded-stale"
2968
+ ? `is still loaded in ${verdict.domain ?? guiDomain} from a DIFFERENT command than the plist just written`
2969
+ : rolledBack === "restored"
2970
+ ? `was evicted from ${guiDomain}; the PREVIOUS plist was restored and re-bootstrapped`
2971
+ : `was evicted from ${guiDomain} and IS NOT RUNNING — nothing is listening`;
2399
2972
  throw new Error(
2400
- `launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
2401
- // The hint used to tell the operator to run `bootout` by hand. It now runs twice
2402
- // above, so naming it as an untried remedy would send someone to repeat what just
2403
- // failed. Report what was attempted instead.
2404
- + `A previous job is still bootstrapped after two attempts to boot it out of ${launchdGuiDomain()}.\n`
2405
- + `Inspect it with:\n launchctl print ${bootoutTarget}\n`
2973
+ `launchctl could not bootstrap ${p}: ${loaded.stderr || "bootstrap reported failure"}\n`
2974
+ + `The ${LABEL} job ${state}.\n`
2975
+ + (rolledBack === "on-disk-only"
2976
+ ? "The previous plist was restored on disk but could not be bootstrapped either.\n"
2977
+ : "")
2978
+ + `Recover manually with:\n launchctl bootstrap ${guiDomain} ${p}\n`
2979
+ + `Inspect it with:\n launchctl print ${guiTarget}\n launchctl print-disabled ${guiDomain}\n`
2406
2980
  // macOS `service repair` delegates straight to installLaunchd, so this fires for
2407
2981
  // an already-installed service too; repair reloads it without re-registering.
2408
2982
  + `then re-run '${wasInstalled ? "ocx service repair" : "ocx service install"}'.`,
2409
2983
  );
2410
2984
  }
2411
2985
  writeServiceInstallState("scheduler", launcher);
2986
+ // The rollback copy has done its job: the new definition is verified loaded. Leaving it
2987
+ // behind makes the NEXT repair's backup ambiguous (which failure did it come from?) and
2988
+ // `uninstall` the only thing that ever cleaned it up.
2989
+ if (existsSync(`${p}.prev`)) { try { unlinkSync(`${p}.prev`); } catch { /* best-effort */ } }
2990
+ return { reloaded: true };
2991
+ }
2992
+ /**
2993
+ * Restart the loaded job IN PLACE — the `restart` half of `ocx service restart`.
2994
+ *
2995
+ * Only reached when {@link installLaunchd} reported `reloaded: false`, i.e. the plist is
2996
+ * already the current one and the probe proved the job is loaded from it. Nothing has to be
2997
+ * published, so this must NOT evict: `kickstart -k` restarts what the domain already holds
2998
+ * without opening an eviction window, which is the whole reason `restart` can be honest
2999
+ * about a healthy service while `repair` stays a no-op on it. `kickstart` restarts the
3000
+ * definition launchd has CACHED and does not re-read the plist — harmless here, and exactly
3001
+ * why the retry path inside `installLaunchd` may only use it for bytes already on disk.
3002
+ *
3003
+ * `launchctl print` answers about REGISTRATION, not liveness, so the verification asks the
3004
+ * same tri-state probe `installLaunchd` does: `loaded-current` is the restart confirmed,
3005
+ * `unknown` is not evidence of anything and only warns, and absence after a kick means the
3006
+ * job went away and KeepAlive did not bring it back — which throws, so the repair branch
3007
+ * reports it and still runs its serving check.
3008
+ *
3009
+ * Both deps are test seams, and the default runner is also refused by
3010
+ * `assertLiveServiceManagerAllowed`: `kickstart` is not a read-only verb, so an armed test
3011
+ * process that reached the real runner would fail closed rather than bounce the developer's
3012
+ * own hub.
3013
+ */
3014
+ export function restartLaunchdJob(deps: {
3015
+ launchctl?: typeof runLaunchctl;
3016
+ probe?: typeof probeLaunchdLoadState;
3017
+ /** The exec line the live job must carry; defaults to the one an install would bake. */
3018
+ expectedCommand?: () => string;
3019
+ } = {}): void {
3020
+ const run = deps.launchctl ?? runLaunchctl;
3021
+ const target = `${launchdGuiDomain()}/${LABEL}`;
3022
+ const expectedCommand = deps.expectedCommand
3023
+ ?? (() => launchdServiceCommand(stableLauncherEntry()));
3024
+ const kicked = run(["kickstart", "-k", target]);
3025
+ const verdict = (deps.probe ?? probeLaunchdLoadState)({ expectedCommand });
3026
+ if (!kicked.ok || verdict.state === "not-loaded" || verdict.state === "loaded-stale") {
3027
+ // Three different things to say, because they send the operator to three different
3028
+ // places: the job is gone, the job is up on an older definition, or the job is up and
3029
+ // `kickstart` refused — in which case the proxy is fine and only the restart failed.
3030
+ const state = verdict.state === "not-loaded"
3031
+ ? `is NOT loaded in ${launchdGuiDomain()} — nothing is listening`
3032
+ : verdict.state === "loaded-stale"
3033
+ ? "is loaded from a DIFFERENT command than the plist on disk"
3034
+ : "is still loaded, so it may be serving the process this restart failed to replace";
3035
+ throw new Error(
3036
+ `launchctl could not restart ${LABEL}: ${kicked.stderr || "kickstart reported failure"}\n`
3037
+ + `The ${LABEL} job ${state}.\n`
3038
+ + `Restart it manually with:\n launchctl kickstart -k ${target}\n`
3039
+ + `Inspect it with:\n launchctl print ${target}\n`
3040
+ + "and run 'ocx service repair' if it is absent.",
3041
+ );
3042
+ }
3043
+ if (verdict.state === "unknown") {
3044
+ console.warn(
3045
+ `⚠️ launchctl accepted the restart but the job state could not be verified — ${
3046
+ verdict.detail ?? "launchctl could not be asked"}. Check: launchctl print ${target}`,
3047
+ );
3048
+ return;
3049
+ }
3050
+ console.log(`ℹ️ service restarted (launchctl kickstart -k ${target}).`);
2412
3051
  }
2413
3052
  /**
2414
3053
  * Deps are named for the layer they replace, not for the process API: `launchctl`
@@ -2445,12 +3084,60 @@ export function startLaunchd(deps: {
2445
3084
  : "The job is not loaded. Run 'ocx service repair' to reload it."),
2446
3085
  );
2447
3086
  }
2448
- function stopLaunchd(): void { try { sh(`launchctl unload "${plistPath()}"`); } catch { /* not loaded */ } }
2449
- function statusLaunchd(): string { try { return sh(`launchctl list | grep ${LABEL} || true`); } catch { return ""; } }
2450
- function uninstallLaunchd(): void {
3087
+ /**
3088
+ * Evict the job with the modern, domain-explicit verb, in EVERY domain that can hold it;
3089
+ * fall back to legacy `unload` only when `bootout` could not be run at all.
3090
+ *
3091
+ * `unload` cannot evict a job bootstrapped into the GUI domain (the same reason
3092
+ * `installLaunchd` stopped using it), so a stop built on it reported success while the
3093
+ * proxy kept serving. Exit 3 ("Boot-out failed: 3: No such process") is the not-loaded
3094
+ * case and is not a failure here.
3095
+ *
3096
+ * `gui/<uid>` alone was the remaining half of that bug: `probeLaunchdLoadState` reports a
3097
+ * `user/<uid>` job too, and against one of those this function exited 3 in the wrong domain
3098
+ * and returned as if it had stopped something. See {@link launchdEvictionTargets}.
3099
+ */
3100
+ function stopLaunchd(deps: { launchctl?: typeof runLaunchctl } = {}): void {
3101
+ const run = deps.launchctl ?? runLaunchctl;
3102
+ let spawnable = true;
3103
+ try {
3104
+ for (const target of launchdEvictionTargets()) {
3105
+ // Any real exit status is final — including 3, which only means the job was not
3106
+ // loaded THERE. `status: null` is "launchctl could not be spawned at all", and only
3107
+ // then is the legacy verb worth one attempt.
3108
+ if (run(["bootout", target]).status === null) spawnable = false;
3109
+ }
3110
+ } catch {
3111
+ // The armed-test guard refuses mutating verbs; retrying through `sh` would only hit it
3112
+ // again.
3113
+ return;
3114
+ }
3115
+ if (spawnable) return;
3116
+ try { sh(`launchctl unload "${plistPath()}"`); } catch { /* not loaded */ }
3117
+ }
3118
+
3119
+ /**
3120
+ * Registration for `ocx service stop`'s "is anything installed?" guard, as a human string.
3121
+ * Empty means "no job of ours is loaded"; the tri-state lives in
3122
+ * {@link probeLaunchdLoadState}, which `diagnoseService` uses instead of this.
3123
+ */
3124
+ function statusLaunchd(deps: { probe?: typeof probeLaunchdLoadState } = {}): string {
3125
+ const probe = (deps.probe ?? probeLaunchdLoadState)();
3126
+ if (probe.state === "loaded-current") return `${LABEL} loaded in ${probe.domain ?? launchdGuiDomain()}`;
3127
+ if (probe.state === "loaded-stale") return `${LABEL} loaded in ${probe.domain ?? launchdGuiDomain()} from an OLDER plist`;
3128
+ if (probe.state === "unknown") return `${LABEL} state unknown: ${probe.detail ?? "launchctl could not be asked"}`;
3129
+ return "";
3130
+ }
3131
+
3132
+ function uninstallLaunchd(deps: { launchctl?: typeof runLaunchctl } = {}): void {
2451
3133
  const p = plistPath();
2452
- try { sh(`launchctl unload "${p}" 2>/dev/null`); } catch { /* not loaded */ }
3134
+ // Same reason as `installLaunchd`: HOME isolation does not move this path, so without
3135
+ // the guard an armed test process deletes the developer's live plist.
3136
+ assertNotRealLaunchAgentsUnderTest(dirname(p));
3137
+ stopLaunchd(deps);
2453
3138
  if (existsSync(p)) unlinkSync(p);
3139
+ // The rollback copy is part of the installation, not a user file.
3140
+ if (existsSync(`${p}.prev`)) { try { unlinkSync(`${p}.prev`); } catch { /* best-effort */ } }
2454
3141
  }
2455
3142
 
2456
3143
  /**
@@ -2928,6 +3615,13 @@ async function restoreWindowsSchedulerTaskIfAbsent(registeredXml: string): Promi
2928
3615
  }
2929
3616
  }
2930
3617
 
3618
+ /**
3619
+ * The two CLI verbs `repairService` serves. They differ on ONE platform and ONE case: a
3620
+ * macOS job that is already loaded from the current plist, which `repair` must not touch and
3621
+ * `restart` must restart.
3622
+ */
3623
+ export type ServiceRepairVerb = "repair" | "restart";
3624
+
2931
3625
  export interface RepairServiceDeps {
2932
3626
  diagnose?: () => ServiceDiagnostic;
2933
3627
  assertEnv?: () => void;
@@ -2938,8 +3632,17 @@ export interface RepairServiceDeps {
2938
3632
  writeSchedulerState?: () => void;
2939
3633
  writeNativeState?: () => void;
2940
3634
  repairNative?: () => void | Promise<void>;
2941
- repairLaunchd?: () => void;
3635
+ repairLaunchd?: () => LaunchdInstallOutcome | void;
2942
3636
  repairSystemd?: () => void;
3637
+ /** Restarts a launchd job the install path deliberately left alone. `restart` only. */
3638
+ restartLaunchd?: () => void;
3639
+ /**
3640
+ * Which CLI verb is being served. `repair` must leave a healthy service alone — that no-op
3641
+ * IS the #4236 fix — while `restart` promises a new process, so on darwin it kicks the job
3642
+ * the no-op path did not touch. Windows (stop + start) and Linux
3643
+ * (`systemctl --user restart`) already restart unconditionally, so neither reads this.
3644
+ */
3645
+ verb?: ServiceRepairVerb;
2943
3646
  /** Reads live registered task XML; may be called again after failure, empty when unreadable. */
2944
3647
  readSchedulerXml?: () => string;
2945
3648
  /** Bounded wait before retrying an unreadable live registration snapshot. */
@@ -3212,10 +3915,19 @@ export async function repairService(deps: RepairServiceDeps = {}): Promise<void>
3212
3915
  return;
3213
3916
  }
3214
3917
  if (platform === "darwin") {
3215
- (deps.repairLaunchd ?? installLaunchd)();
3918
+ const outcome = (deps.repairLaunchd ?? installLaunchd)();
3919
+ // `installLaunchd` is the only function that knows whether it published anything, and its
3920
+ // no-op path leaves the live process running on purpose. `repair` wants exactly that;
3921
+ // `restart` would otherwise restart NOTHING on a healthy hub and send the operator to run
3922
+ // `launchctl kickstart -k` by hand, which is the opposite of what these verbs are for.
3923
+ if ((deps.verb ?? "repair") === "restart" && outcome?.reloaded === false) {
3924
+ (deps.restartLaunchd ?? restartLaunchdJob)();
3925
+ }
3216
3926
  return;
3217
3927
  }
3218
3928
  if (platform === "linux") {
3929
+ // `installSystemd` ends in `systemctl --user restart`, unconditionally, so the unit is
3930
+ // restarted whichever verb asked — there is no no-op path here to compensate for.
3219
3931
  (deps.repairSystemd ?? installSystemd)();
3220
3932
  return;
3221
3933
  }
@@ -3596,7 +4308,10 @@ export function systemdServiceInstallCleanupOps(deps: {
3596
4308
 
3597
4309
  function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
3598
4310
  if (process.platform === "darwin")
3599
- return { install: installLaunchd, start: startLaunchd, stop: stopLaunchd, status: statusLaunchd, uninstall: uninstallLaunchd };
4311
+ // Wrapped, not passed: `installLaunchd` reports whether it reloaded launchd, and only
4312
+ // `repairService` (for the `restart` verb) has any use for that. `ServiceOps.install` is
4313
+ // the generic install seam and deliberately promises nothing about a return value.
4314
+ return { install: () => { installLaunchd(); }, start: startLaunchd, stop: stopLaunchd, status: statusLaunchd, uninstall: uninstallLaunchd };
3600
4315
  if (process.platform === "win32") {
3601
4316
  if (backend === "native")
3602
4317
  return { install: installWindowsNative, start: startWinswService, stop: stopWinswService, status: winswStatusSummary, uninstall: uninstallWinswService };
@@ -3628,11 +4343,29 @@ function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
3628
4343
  function platformServiceInstallCleanupOps(backend: ServiceBackend): ServiceInstallCleanupOps | null {
3629
4344
  if (process.platform === "darwin") {
3630
4345
  return {
4346
+ // Same probe as `diagnoseService`, so the two answers to one question can no longer
4347
+ // have opposite failure semantics: this one used to throw on a launchctl error while
4348
+ // `statusLaunchd` swallowed it into "absent". Installing over an unverifiable
4349
+ // manager is the unsafe case, so `unknown` fails closed here.
3631
4350
  status: () => {
3632
- const listing = sh("launchctl list");
3633
- return listing.split("\n").some(line => line.includes(LABEL)) ? listing : null;
4351
+ const probe = probeLaunchdLoadState();
4352
+ if (probe.state === "unknown") {
4353
+ throw new Error(`launchd job status could not be verified: ${probe.detail ?? "launchctl could not be asked"}`);
4354
+ }
4355
+ return probe.state === "not-loaded" ? null : `${LABEL} loaded in ${probe.domain ?? launchdGuiDomain()}`;
4356
+ },
4357
+ // `unload` is legacy and CANNOT evict a gui-domain job, which is what this cleanup
4358
+ // exists to do before new assets are installed. Both user domains, because the probe
4359
+ // above reports `user/<uid>` too: a gui-only bootout exits 3 against one of those,
4360
+ // which this function would have read as "already stopped" and installed over a live
4361
+ // job (see `launchdEvictionTargets`).
4362
+ stop: () => {
4363
+ for (const target of launchdEvictionTargets()) {
4364
+ const booted = runLaunchctl(["bootout", target]);
4365
+ if (launchctlBootoutBenign(booted.status)) continue;
4366
+ throw new Error(`launchctl bootout ${target} failed: ${booted.stderr || `exit ${String(booted.status)}`}`);
4367
+ }
3634
4368
  },
3635
- stop: () => { sh(`launchctl unload "${plistPath()}"`); },
3636
4369
  };
3637
4370
  }
3638
4371
  if (process.platform === "win32") {
@@ -4310,6 +5043,61 @@ export function deriveWindowsServiceDiagnosticForCurrentUser(
4310
5043
  });
4311
5044
  }
4312
5045
 
5046
+ export interface LaunchdServiceDiagnosticInputs {
5047
+ installed: boolean;
5048
+ stale: boolean;
5049
+ load: LaunchdLoadProbe;
5050
+ diagnostics: string;
5051
+ }
5052
+
5053
+ /**
5054
+ * Turn the launchd tri-state into a {@link ServiceDiagnostic}. Pure, so the four states
5055
+ * are testable without a live launchd.
5056
+ *
5057
+ * `unknown` is the one that used to do damage. The old probe collapsed "launchctl could
5058
+ * not be asked" into "not loaded", which printed `installed, not loaded` for a serving hub
5059
+ * and recommended `ocx service repair` — the command that evicts the job (#4236). So:
5060
+ *
5061
+ * - the summary says the state could not be verified and names NO repair command, and
5062
+ * - `viable` stays true, because `isServiceViable() === false` is what makes
5063
+ * `src/update/index.ts` and `src/update/job.ts` treat a successful repair as a dead
5064
+ * supervisor and start a competing proxy on the service's own port. A failed probe is
5065
+ * not evidence against the service; `startable` is likewise left alone so the tray can
5066
+ * still hand a start to `ocx service start`, which no-ops on an already-loaded job.
5067
+ *
5068
+ * `loaded-stale` keeps the viability the `launchctl list` era gave it (loaded ⇒ viable, so
5069
+ * the update fallback behaves as before), and only the summary is upgraded — the operator
5070
+ * is told the live job came from an older plist, which is the one case where `repair` is
5071
+ * exactly right.
5072
+ */
5073
+ export function deriveLaunchdServiceDiagnostic(inputs: LaunchdServiceDiagnosticInputs): ServiceDiagnostic {
5074
+ const { installed, stale, load, diagnostics } = inputs;
5075
+ const loaded = load.state === "loaded-current" || load.state === "loaded-stale";
5076
+ const running = installed && loaded;
5077
+ const verified = load.state !== "unknown";
5078
+ const viable = installed && !stale && (loaded || !verified);
5079
+ const summary = !installed ? `not installed (${diagnostics})`
5080
+ : stale ? `installed, but stale (launchd; ${diagnostics})`
5081
+ : load.state === "loaded-current" ? `installed and loaded (launchd; ${diagnostics})`
5082
+ : load.state === "loaded-stale"
5083
+ ? `installed and loaded from an OLDER plist (launchd; ${diagnostics})`
5084
+ : load.state === "unknown"
5085
+ ? `installed; launchd state could not be verified — ${load.detail ?? "launchctl could not be asked"} (launchd; ${diagnostics})`
5086
+ : `installed, not loaded (launchd; ${diagnostics})`;
5087
+ return {
5088
+ supported: true,
5089
+ installed,
5090
+ enabled: running,
5091
+ running,
5092
+ viable,
5093
+ startable: installed && !stale,
5094
+ stale,
5095
+ conflict: false,
5096
+ backend: "launchd",
5097
+ summary,
5098
+ };
5099
+ }
5100
+
4313
5101
  /**
4314
5102
  * Fail-closed restart diagnostic. Presence alone is never enough: conflicting
4315
5103
  * managers, stale baked paths, disabled registrations, and unknown/stopped
@@ -4319,14 +5107,13 @@ export function diagnoseService(): ServiceDiagnostic {
4319
5107
  const diagnostics = serviceDiagnosticsSummary();
4320
5108
  if (process.platform === "darwin") {
4321
5109
  const installed = existsSync(plistPath());
4322
- const running = installed && Boolean(statusLaunchd());
4323
5110
  const stale = installed && bakedServicePathsDiagnostic() !== null;
4324
- const viable = installed && running && !stale;
4325
- const summary = !installed ? `not installed (${diagnostics})`
4326
- : stale ? `installed, but stale (launchd; ${diagnostics})`
4327
- : running ? `installed and loaded (launchd; ${diagnostics})`
4328
- : `installed, not loaded (launchd; ${diagnostics})`;
4329
- return { supported: true, installed, enabled: running, running, viable, startable: installed && !stale, stale, conflict: false, backend: "launchd", summary };
5111
+ return deriveLaunchdServiceDiagnostic({
5112
+ installed,
5113
+ stale,
5114
+ load: installed ? probeLaunchdLoadState() : { state: "not-loaded" },
5115
+ diagnostics,
5116
+ });
4330
5117
  }
4331
5118
  if (process.platform === "win32") {
4332
5119
  const schedulerXml = statusWindowsXml();
@@ -4419,8 +5206,18 @@ export async function serviceStatusReport(
4419
5206
  + " Meanwhile: ocx start (serves in the foreground)";
4420
5207
  }
4421
5208
 
5209
+ /**
5210
+ * `restart` is NO LONGER folded into `repair`.
5211
+ *
5212
+ * It used to be, and on macOS that made it a lie: `repair` now returns early when the plist
5213
+ * is already current and the job is loaded from it (#4236), so `ocx service restart` of a
5214
+ * healthy service restarted nothing and the operator had to run `launchctl kickstart -k` by
5215
+ * hand. The two verbs share the whole repair path and diverge only in `repairService`, which
5216
+ * kicks the launchd job the no-op left running. A BARE `ocx service` still maps to `repair`
5217
+ * (see {@link selectServiceSubcommand}): it is an idempotent "make it current", not a
5218
+ * request to bounce a healthy hub.
5219
+ */
4422
5220
  export function normalizeServiceSubcommand(sub?: string): string {
4423
- if (sub === "restart") return "repair";
4424
5221
  return sub ?? "install";
4425
5222
  }
4426
5223
 
@@ -4579,15 +5376,30 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
4579
5376
  process.exit(1);
4580
5377
  }
4581
5378
  const { parsed, command } = plan;
4582
- if (command === "repair") {
5379
+ if (command === "repair" || command === "restart") {
5380
+ const verb: ServiceRepairVerb = command === "restart" ? "restart" : "repair";
4583
5381
  assertServiceEnvironmentMatchesInstall();
4584
5382
  assertServiceAuthEnvironment();
4585
- await repairService();
5383
+ // A throw used to escape straight to the top level, so the one command that can
5384
+ // leave a macOS hub evicted never reached its own serving check (#4236, defect 1f).
5385
+ // Report the failure, then still ask whether anything is listening: on darwin the
5386
+ // rollback inside installLaunchd may have brought the previous job back, and on
5387
+ // Windows the preserve/restart protocol may have done the same. The operator needs
5388
+ // both halves of that answer, and the exit code stays non-zero either way.
5389
+ let repairError: unknown;
5390
+ try {
5391
+ await repairService({ verb });
5392
+ } catch (error) {
5393
+ repairError = error;
5394
+ console.error(`❌ Service ${verb} failed: ${error instanceof Error ? error.message : String(error)}`);
5395
+ process.exitCode = 1;
5396
+ }
4586
5397
  // All three platforms: a repair that reports success while nothing serves is the
4587
5398
  // defect class this unit exists to close. Windows bakes its port into the
4588
5399
  // scheduler wrapper or the WinSW XML, both of which installedServiceListenPort()
4589
5400
  // now reads.
4590
- await reportServiceServing("repaired");
5401
+ await reportServiceServing(verb === "restart" ? "restarted" : "repaired");
5402
+ if (repairError !== undefined) process.exitCode = 1;
4591
5403
  return;
4592
5404
  }
4593
5405
  // Non-install subcommands follow the backend recorded at install time (state v2).
@@ -4727,8 +5539,8 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
4727
5539
  default:
4728
5540
  console.error("Usage: ocx service [install|repair|restart|start|stop|status|uninstall|remove] [--native|--scheduler]");
4729
5541
  console.error(" With no subcommand, installs when absent or repairs/restarts an existing service.");
4730
- console.error(" repair: refresh and restart the installed backend; stale Windows tasks may request admin approval.");
4731
- console.error(" restart: alias of repair.");
5542
+ console.error(" repair: refresh the installed backend, reloading it only when the definition changed; stale Windows tasks may request admin approval.");
5543
+ console.error(" restart: the same refresh, but always restarts the service — on macOS a healthy job is kickstarted in place.");
4732
5544
  console.error(" --native (Windows only): register a real SCM service via WinSW instead of Task Scheduler.");
4733
5545
  process.exit(1);
4734
5546
  }