@bitkyc08/opencodex 2.52.0-preview.20260912 → 2.53.0-preview.20260913

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 (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-D7ynYo2K.js +128 -0
  3. package/gui/dist/index.html +2 -2
  4. package/native/remote-workspace-helper/Cargo.lock +130 -0
  5. package/native/remote-workspace-helper/Cargo.toml +24 -0
  6. package/native/remote-workspace-helper/src/main.rs +49 -0
  7. package/native/remote-workspace-helper/src/protocol.rs +246 -0
  8. package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
  9. package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
  10. package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
  11. package/package.json +6 -1
  12. package/src/adapters/anthropic-image-normalize.ts +30 -2
  13. package/src/adapters/anthropic.ts +1 -1
  14. package/src/adapters/base.ts +8 -2
  15. package/src/adapters/cursor/cursor-errors.ts +12 -0
  16. package/src/adapters/cursor/thread-continuity.ts +93 -0
  17. package/src/adapters/cursor.ts +104 -73
  18. package/src/adapters/devin/cloud-direct/chat.ts +312 -23
  19. package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
  20. package/src/adapters/devin/live-models.ts +70 -3
  21. package/src/adapters/devin.ts +281 -21
  22. package/src/adapters/google-wire-compiler.ts +14 -6
  23. package/src/adapters/google.ts +22 -8
  24. package/src/adapters/kiro/adapter.ts +316 -0
  25. package/src/adapters/kiro/conversation.ts +136 -0
  26. package/src/adapters/kiro/payload.ts +432 -0
  27. package/src/adapters/kiro/reasoning.ts +56 -0
  28. package/src/adapters/kiro/stream.ts +1153 -0
  29. package/src/adapters/kiro/usage.ts +223 -0
  30. package/src/adapters/kiro/wire.ts +76 -0
  31. package/src/adapters/kiro.ts +8 -2319
  32. package/src/adapters/mimo-free.ts +1 -1
  33. package/src/adapters/openai-chat-images.ts +101 -0
  34. package/src/adapters/openai-chat.ts +201 -181
  35. package/src/adapters/openai-responses.ts +92 -224
  36. package/src/adapters/registry.ts +0 -7
  37. package/src/adapters/run-turn-queue.ts +13 -6
  38. package/src/bridge.ts +14 -15
  39. package/src/chat/inbound.ts +29 -4
  40. package/src/chat/outbound.ts +145 -107
  41. package/src/claude/desktop-profile.ts +4 -6
  42. package/src/cli/account-api.ts +14 -0
  43. package/src/cli/account-extended.ts +1 -1
  44. package/src/cli/account-history.ts +60 -0
  45. package/src/cli/account-main.ts +80 -0
  46. package/src/cli/account.ts +11 -3
  47. package/src/cli/capabilities.ts +113 -0
  48. package/src/cli/catalog.ts +109 -0
  49. package/src/cli/dispatch.ts +9 -0
  50. package/src/cli/help.ts +2 -0
  51. package/src/cli/index.ts +2 -2
  52. package/src/cli/observe.ts +28 -1
  53. package/src/cli/opencode.ts +42 -8
  54. package/src/cli/provider-runtime.ts +11 -1
  55. package/src/cli/provider.ts +22 -2
  56. package/src/cli/registry.ts +21 -0
  57. package/src/cli/remote-workspace.ts +154 -0
  58. package/src/cli/status.ts +39 -7
  59. package/src/cli/usage-report.ts +14 -2
  60. package/src/client/hub-client.ts +34 -0
  61. package/src/client/hub-state.ts +9 -1
  62. package/src/codex/account-store.ts +78 -0
  63. package/src/codex/auth-api.ts +81 -54
  64. package/src/codex/auth-context.ts +45 -16
  65. package/src/codex/catalog/effort.ts +1 -1
  66. package/src/codex/catalog/metadata.ts +3 -6
  67. package/src/codex/catalog/native-models.ts +4 -4
  68. package/src/codex/catalog/parsing.ts +2 -20
  69. package/src/codex/catalog/provider-fetch.ts +10 -1
  70. package/src/codex/catalog/remote.ts +233 -0
  71. package/src/codex/catalog/sync.ts +403 -35
  72. package/src/codex/convergence.ts +1 -1
  73. package/src/codex/history-manifest.ts +36 -0
  74. package/src/codex/history-provider.ts +32 -5
  75. package/src/codex/inject.ts +9 -0
  76. package/src/codex/main-account.ts +113 -0
  77. package/src/codex/main-device-reauth-api.ts +89 -0
  78. package/src/codex/main-device-reauth.ts +217 -0
  79. package/src/codex/native-residue.ts +9 -2
  80. package/src/codex/quota-auto-refresh.ts +3 -2
  81. package/src/codex/quota-capacity.ts +98 -0
  82. package/src/codex/quota-history.ts +160 -0
  83. package/src/codex/quota-types.ts +8 -0
  84. package/src/codex/quota.ts +118 -91
  85. package/src/codex/refresh.ts +2 -1
  86. package/src/codex/routing.ts +90 -17
  87. package/src/codex/sync.ts +33 -4
  88. package/src/combos/request.ts +19 -1
  89. package/src/config/multi-agent-surface.ts +61 -0
  90. package/src/config/provider-validation.ts +176 -0
  91. package/src/config.ts +213 -11
  92. package/src/generated/compatibility-version.json +436 -168
  93. package/src/images/loop.ts +119 -36
  94. package/src/lib/admission.ts +12 -6
  95. package/src/lib/redact.ts +7 -0
  96. package/src/lib/translator-budget.ts +4 -3
  97. package/src/lib/windows-atomic-replace.ts +1 -0
  98. package/src/lib/windows-elevation.ts +1 -1
  99. package/src/oauth/chatgpt-device.ts +62 -5
  100. package/src/oauth/devin/cli-import.ts +130 -0
  101. package/src/oauth/devin.ts +63 -8
  102. package/src/oauth/index.ts +29 -14
  103. package/src/oauth/kiro.ts +18 -6
  104. package/src/oauth/login-cli.ts +9 -1
  105. package/src/oauth/meta-muse-device.ts +464 -0
  106. package/src/oauth/meta-muse.ts +123 -32
  107. package/src/oauth/pool-kernel.ts +9 -0
  108. package/src/oauth/pool-settings-capability.ts +2 -2
  109. package/src/oauth/store.ts +57 -0
  110. package/src/oauth/types.ts +31 -0
  111. package/src/providers/derive.ts +13 -3
  112. package/src/providers/devin-cli-authmode-migration.ts +57 -35
  113. package/src/providers/devin-provider-merge-migration.ts +240 -0
  114. package/src/providers/muse-key-quota.ts +117 -0
  115. package/src/providers/muse-subscription-usage.ts +14 -2
  116. package/src/providers/openai-sidecar.ts +25 -3
  117. package/src/providers/opencode-zen-rate-limit.ts +58 -0
  118. package/src/providers/provider-id-rewrite.ts +20 -5
  119. package/src/providers/quota-types.ts +12 -0
  120. package/src/providers/quota.ts +143 -102
  121. package/src/providers/reasoning-metadata.ts +543 -0
  122. package/src/providers/registry.ts +80 -49
  123. package/src/reasoning-effort.ts +26 -2
  124. package/src/remote/hub-usage.ts +32 -0
  125. package/src/remote-control/index.ts +192 -41
  126. package/src/remote-control/workspace-activation.ts +9 -0
  127. package/src/remote-control/workspace-agent-connection.ts +366 -0
  128. package/src/remote-control/workspace-claude-runtime.ts +243 -0
  129. package/src/remote-control/workspace-codex-runtime.ts +531 -0
  130. package/src/remote-control/workspace-codex-sandbox.ts +115 -0
  131. package/src/remote-control/workspace-command-runner.ts +748 -0
  132. package/src/remote-control/workspace-coordinator.ts +231 -0
  133. package/src/remote-control/workspace-device.ts +585 -0
  134. package/src/remote-control/workspace-executable.ts +43 -0
  135. package/src/remote-control/workspace-executor.ts +397 -0
  136. package/src/remote-control/workspace-hub.ts +519 -0
  137. package/src/remote-control/workspace-pi-runtime.ts +382 -0
  138. package/src/remote-control/workspace-process.ts +129 -0
  139. package/src/remote-control/workspace-rpc.ts +304 -0
  140. package/src/remote-control/workspace-runtime.ts +60 -0
  141. package/src/remote-control/workspace-secret-store.ts +39 -0
  142. package/src/remote-control/workspace-sessions.ts +799 -0
  143. package/src/remote-control/workspace-tool-bridge.ts +192 -0
  144. package/src/responses/code-mode-helper-compat.ts +22 -3
  145. package/src/responses/hosted-tool-policy.ts +0 -1
  146. package/src/responses/muse-tool-name-alias.ts +379 -0
  147. package/src/responses/plaintext-v2-agent-messages.ts +902 -0
  148. package/src/router.ts +7 -0
  149. package/src/routing/compatibility/behavior.ts +0 -1
  150. package/src/server/audio-client.ts +64 -0
  151. package/src/server/audio-dictation.ts +91 -0
  152. package/src/server/audio-live.ts +185 -0
  153. package/src/server/audio-transcriptions.ts +183 -0
  154. package/src/server/audio-upstream.ts +153 -0
  155. package/src/server/auth-cors.ts +61 -2
  156. package/src/server/chat-completions.ts +1 -1
  157. package/src/server/chat-native-sse.ts +92 -48
  158. package/src/server/chat-native.ts +37 -15
  159. package/src/server/hub-usage.ts +57 -0
  160. package/src/server/images.ts +4 -0
  161. package/src/server/index.ts +722 -57
  162. package/src/server/lifecycle.ts +5 -6
  163. package/src/server/live-call-bindings.ts +60 -0
  164. package/src/server/live.ts +12 -1
  165. package/src/server/management/agent-settings-routes.ts +25 -4
  166. package/src/server/management/api-access.ts +37 -0
  167. package/src/server/management/api-key-usage.ts +7 -2
  168. package/src/server/management/config-routes.ts +1 -18
  169. package/src/server/management/context.ts +15 -0
  170. package/src/server/management/logs-usage-routes.ts +2 -0
  171. package/src/server/management/oauth-account-routes.ts +39 -12
  172. package/src/server/management/provider-routes.ts +125 -2
  173. package/src/server/management/remote-workspace-routes.ts +140 -0
  174. package/src/server/management/route-registry.ts +15 -0
  175. package/src/server/management/usage-aggregate-cache.ts +14 -15
  176. package/src/server/management/usage-summary-cache.ts +2 -0
  177. package/src/server/management-api.ts +23 -0
  178. package/src/server/ports.ts +17 -0
  179. package/src/server/relay-eager.ts +4 -1
  180. package/src/server/relay.ts +70 -10
  181. package/src/server/request-decompress.ts +6 -3
  182. package/src/server/responses/agent-task-recovery.ts +25 -32
  183. package/src/server/responses/codex-auth-error.ts +11 -0
  184. package/src/server/responses/codex-ws-exchange.ts +52 -3
  185. package/src/server/responses/codex-ws-wire.ts +55 -0
  186. package/src/server/responses/compact.ts +9 -1
  187. package/src/server/responses/core.ts +337 -73
  188. package/src/server/responses/encrypted-payload.ts +45 -2
  189. package/src/server/responses/ws-upstream.ts +4 -1
  190. package/src/server/responses-self-named-namespace-scrub.ts +1 -3
  191. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  192. package/src/server/search.ts +3 -0
  193. package/src/server/sse-payload-rewrite.ts +136 -51
  194. package/src/server/ws-bridge.ts +35 -1
  195. package/src/service/cli.ts +372 -0
  196. package/src/service/diagnostics.ts +340 -0
  197. package/src/service/guards.ts +303 -0
  198. package/src/service/health.ts +222 -0
  199. package/src/service/launchd.ts +853 -0
  200. package/src/service/orchestration.ts +617 -0
  201. package/src/service/repair.ts +334 -0
  202. package/src/service/state.ts +363 -0
  203. package/src/service/systemd.ts +229 -0
  204. package/src/service/windows-ops.ts +690 -0
  205. package/src/service/windows-scheduler.ts +769 -0
  206. package/src/service/windows-taskxml.ts +613 -0
  207. package/src/service.ts +22 -5550
  208. package/src/storage/cleanup/db.ts +258 -0
  209. package/src/storage/cleanup/execute.ts +358 -0
  210. package/src/storage/cleanup/paths.ts +189 -0
  211. package/src/storage/cleanup/pending.ts +140 -0
  212. package/src/storage/cleanup/preview.ts +292 -0
  213. package/src/storage/cleanup/reconcile.ts +347 -0
  214. package/src/storage/cleanup/restore.ts +932 -0
  215. package/src/storage/cleanup/satellite.ts +474 -0
  216. package/src/storage/cleanup/staging.ts +129 -0
  217. package/src/storage/cleanup/types.ts +98 -0
  218. package/src/storage/cleanup.ts +49 -3127
  219. package/src/types/accounts.ts +2 -0
  220. package/src/types/config.ts +13 -12
  221. package/src/types/provider.ts +37 -0
  222. package/src/types/request.ts +2 -0
  223. package/src/types/tools.ts +17 -5
  224. package/src/types.ts +1 -0
  225. package/src/usage/expected-prices.ts +127 -0
  226. package/src/usage/log.ts +58 -1
  227. package/src/vision/eligibility.ts +13 -2
  228. package/src/web-search/loop.ts +56 -3
  229. package/gui/dist/assets/index-D_t6sCWs.js +0 -115
  230. package/gui/dist/assets/index-EdoPnm9_.css +0 -1
  231. package/src/adapters/devin-cli/acp.ts +0 -204
  232. package/src/adapters/devin-cli/adapter.ts +0 -345
  233. package/src/adapters/devin-cli/binary.ts +0 -69
  234. package/src/adapters/devin-cli/models.ts +0 -57
  235. package/src/oauth/devin-cli.ts +0 -149
  236. package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
@@ -0,0 +1,853 @@
1
+ import { existsSync, unlinkSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { assertNotRealLaunchAgentsUnderTest } from "../lib/test-home-guard";
4
+ import { sh } from "./guards";
5
+ import { plistPath } from "./state";
6
+ import { spawnSync } from "node:child_process";
7
+ import { chmodSync, mkdirSync, readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { getConfigDir } from "../config";
10
+ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime, type DurableBunRuntime } from "../lib/bun-runtime";
11
+ import { serviceApiTokenFilePath } from "../lib/service-secrets";
12
+ import { recordOwnedConfigPath } from "../lib/config-ownership";
13
+ import { writeServiceApiTokenFile, assertLiveServiceManagerAllowed } from "./guards";
14
+ import { resolveServiceListenPort, buildServiceShellCommand, buildServiceLauncherShellCommand, installedServiceListenPort, resolvedProxyEnv } from "./health";
15
+ import { SERVICE_MANAGED_ENV, LABEL, cliEntry, stableLauncherEntry, logPath, serviceStatePath, currentCodexSqliteHomeAbsolute, type ServiceInstallState, writeServiceInstallState, readServiceInstallState } from "./state";
16
+ import { writeServiceDefinitionFile } from "./windows-ops";
17
+ import { readTextOrNull } from "./windows-taskxml";
18
+
19
+ function plistString(value: string): string {
20
+ return value
21
+ .replace(/&/g, "&")
22
+ .replace(/</g, "&lt;")
23
+ .replace(/>/g, "&gt;")
24
+ .replace(/"/g, "&quot;")
25
+ .replace(/'/g, "&apos;");
26
+ }
27
+
28
+ /**
29
+ * Render the launchd plist. Mirrors `buildUnit`: when `deps.launcher` names a stable `ocx`
30
+ * executable, the job execs that launcher instead of the package-local Bun + CLI pair, so a
31
+ * version-manager upgrade (mise, asdf, nvm) that replaces the package directory is picked up
32
+ * on the next launchd start instead of leaving the old build serving (#3464 — the macOS
33
+ * counterpart of #2898). Discovery belongs to `installLaunchd()`; the default here is the
34
+ * legacy pair so callers and tests stay hermetic.
35
+ */
36
+ export function buildPlist(
37
+ proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(),
38
+ deps: { launcher?: string | null; runtime?: DurableBunRuntime } = {},
39
+ ): string {
40
+ const runtime = deps.runtime ?? durableBunRuntime();
41
+ const { bun, bunRuntimeSource, cli } = cliEntry(runtime);
42
+ const launcher = deps.launcher ?? null;
43
+ const log = logPath();
44
+ const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
45
+ const codexHome = process.env.CODEX_HOME?.trim();
46
+ const codexSqliteHome = currentCodexSqliteHomeAbsolute();
47
+ const opencodexHome = process.env.OPENCODEX_HOME?.trim();
48
+ const envLines = [
49
+ ` <key>OCX_SERVICE</key><string>1</string>`,
50
+ // OCX_SERVICE alone cannot identify the managed job: `ocx claude` and `ocx opencode`
51
+ // also set it on the proxies they spawn, to borrow its routing-preservation meaning
52
+ // (src/cli/index.ts preserveRouting). Only the wrapper writes this second marker, so
53
+ // the dashboard-stop refusal below can tell a real launchd job from an ordinary child.
54
+ ` <key>${SERVICE_MANAGED_ENV}</key><string>1</string>`,
55
+ ...(launcher ? [] : [
56
+ ` <key>${BUN_RUNTIME_SOURCE_ENV}</key><string>${bunRuntimeSource}</string>`,
57
+ ` <key>${BUN_RUNTIME_PATH_ENV}</key><string>${plistString(bun)}</string>`,
58
+ ]),
59
+ // A launcher resolves the current package's bundled Bun after every upgrade. Preserve
60
+ // only a proof-bound shell override; baking a package-local path here would recreate
61
+ // the version-manager pin that launcher mode exists to remove (same rule as buildUnit).
62
+ launcher && runtime.source === "override"
63
+ ? ` <key>${runtime.overrideEnv}</key><string>${plistString(runtime.path)}</string>`
64
+ : null,
65
+ ` <key>PATH</key><string>${plistString(path)}</string>`,
66
+ codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : null,
67
+ codexSqliteHome ? ` <key>CODEX_SQLITE_HOME</key><string>${plistString(codexSqliteHome)}</string>` : null,
68
+ opencodexHome ? ` <key>OPENCODEX_HOME</key><string>${plistString(opencodexHome)}</string>` : null,
69
+ ...proxyEnv.map(({ name, value }) =>
70
+ ` <key>${name}</key><string>${plistString(value)}</string>`),
71
+ ].filter((line): line is string => Boolean(line)).join("\n");
72
+ const command = launchdServiceCommand(launcher, runtime);
73
+ return `<?xml version="1.0" encoding="UTF-8"?>
74
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
75
+ <plist version="1.0">
76
+ <dict>
77
+ <key>Label</key><string>${LABEL}</string>
78
+ <key>ProgramArguments</key>
79
+ <array>
80
+ <string>/bin/sh</string>
81
+ <string>-lc</string>
82
+ <string>${plistString(command)}</string>
83
+ </array>
84
+ <key>RunAtLoad</key><true/>
85
+ <key>KeepAlive</key><true/>
86
+ <key>EnvironmentVariables</key>
87
+ <dict>
88
+ ${envLines}
89
+ </dict>
90
+ <key>StandardOutPath</key><string>${plistString(log)}</string>
91
+ <key>StandardErrorPath</key><string>${plistString(log)}</string>
92
+ </dict>
93
+ </plist>
94
+ `;
95
+ }
96
+
97
+ /**
98
+ * The single `EnvironmentVariables` line {@link buildPlist} fills from the env of whatever
99
+ * process happens to be repairing.
100
+ */
101
+ const PLIST_PATH_ENTRY = /^(\s*<key>PATH<\/key><string>)([^\n]*)(<\/string>)$/m;
102
+
103
+ /**
104
+ * The rendered plist with the PREVIOUS definition's `PATH` put back — or null when `PATH`
105
+ * is not the only difference.
106
+ *
107
+ * `buildPlist` bakes `process.env.PATH`, and `ocx service repair` is run by whatever has a
108
+ * shell: a tray helper, `ocx update`'s child, an ssh session, a cron job. Each of those
109
+ * carries a DIFFERENT PATH from the login shell that installed the service, so comparing
110
+ * whole-file bytes made the "nothing to repair" pre-check miss almost every time it
111
+ * mattered: a healthy hub was evicted, and its PATH rewritten to the narrower one, purely
112
+ * because of who asked (#4236, review finding 2).
113
+ *
114
+ * Reuse rather than ignore. A plist that differs only in PATH is not "equal" — dropping the
115
+ * difference silently would let a repair report a no-op while launchd keeps a PATH the
116
+ * operator has changed on purpose. Putting the previous value back makes the two files
117
+ * genuinely identical, so the caller's ordinary byte comparison decides, and the PATH the
118
+ * service already runs with is the one that survives.
119
+ *
120
+ * The caller applies this only when the live job is loaded from exactly the exec line this
121
+ * install baked: that is the evidence that the running definition is the one on disk, which
122
+ * is what makes keeping its PATH correct rather than a guess. Whenever anything ELSE about
123
+ * the definition changed the plist is rewritten in full, PATH included, so a real
124
+ * re-install still updates it.
125
+ */
126
+ export function reusePreviousPlistPathVariable(previous: string, rendered: string): string | null {
127
+ const prev = PLIST_PATH_ENTRY.exec(previous);
128
+ const next = PLIST_PATH_ENTRY.exec(rendered);
129
+ if (!prev || !next || prev[2] === next[2]) return null;
130
+ // A function replacer, not a `$1` template: a PATH entry containing `$&` or `$1` would
131
+ // otherwise be re-expanded into the file.
132
+ const adopted = rendered.replace(PLIST_PATH_ENTRY, (_match, open: string, _value: string, close: string) =>
133
+ `${open}${prev[2] ?? ""}${close}`);
134
+ return adopted === previous ? adopted : null;
135
+ }
136
+
137
+ /**
138
+ * The exec line {@link buildPlist} bakes, for the launcher and runtime a single install
139
+ * already resolved. Shared so `installLaunchd` can verify the live job against the exact
140
+ * string it just wrote instead of re-deriving it from install state that has not been
141
+ * written yet (a fresh install has no state, so `expectedLaunchdCommand` would hand back
142
+ * the Bun + CLI pair and call a correctly loaded launcher job stale).
143
+ */
144
+ function launchdServiceCommand(
145
+ launcher: string | null,
146
+ runtime: DurableBunRuntime = durableBunRuntime(),
147
+ port: number = resolveServiceListenPort(),
148
+ ): string {
149
+ if (launcher) return buildServiceLauncherShellCommand(launcher, port);
150
+ const { bun, cli } = cliEntry(runtime);
151
+ return buildServiceShellCommand(bun, cli, port);
152
+ }
153
+
154
+ /**
155
+ * The exec line the installed launchd plist is expected to carry, derived from the recorded
156
+ * install state rather than rediscovered: a launcher install runs the launcher, a legacy or
157
+ * stateless install runs the Bun + CLI pair. `start` and `status` compare the live job
158
+ * against this, so both must follow the launcher or a healthy launcher-backed job reads as
159
+ * "an OLDER plist" (#3464). PATH is deliberately NOT re-walked here.
160
+ */
161
+ export function expectedLaunchdCommand(
162
+ port: number,
163
+ deps: { state?: ServiceInstallState | null; entry?: { bun: string; cli: string } } = {},
164
+ ): string {
165
+ const state = deps.state === undefined ? readServiceInstallState() : deps.state;
166
+ if (state?.launcherPath) return buildServiceLauncherShellCommand(state.launcherPath, port);
167
+ const entry = deps.entry ?? cliEntry();
168
+ return buildServiceShellCommand(entry.bun, entry.cli, port);
169
+ }
170
+
171
+ /**
172
+ * The `--port <n>` actually baked into the installed launchd plist, or null when it
173
+ * cannot be read. macOS only — named for launchd rather than "service" so no caller
174
+ * assumes it covers systemd or the Windows wrapper.
175
+ *
176
+ * `start` needs this because it does NOT rewrite the plist: an install made under
177
+ * OCX_BAKE_PORT, or any later config.port edit, would otherwise leave launchd serving
178
+ * one port while the confirmation probes another, failing a healthy service.
179
+ *
180
+ * Anchored on the closing tag and matched LAST: the command also carries the Bun and
181
+ * CLI paths, and a path containing the literal `start --port 9999` must not shadow
182
+ * the real argument. buildPlist emits the command as the final ProgramArguments
183
+ * string, and buildServiceShellCommand puts the port at the very end of it.
184
+ */
185
+ export function launchdListenPort(deps: { readPlist?: () => string } = {}): number | null {
186
+ try {
187
+ const text = (deps.readPlist ?? (() => readFileSync(plistPath(), "utf8")))();
188
+ const last = [...text.matchAll(/start --port (\d{1,5})\s*<\/string>/g)].at(-1);
189
+ if (!last) return null;
190
+ const n = Number(last[1]);
191
+ return n > 0 && n <= 65535 ? n : null;
192
+ } catch {
193
+ return null;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Run `launchctl` and report BOTH streams regardless of exit status.
199
+ *
200
+ * `launchctl load` writes "Load failed: <n>: <reason>" to stderr and exits 0 for
201
+ * every already-bootstrapped job. `sh()` above is execSync, which throws only on a
202
+ * non-zero exit, so install and start both reported success for a load that did
203
+ * nothing — leaving launchd running the PREVIOUS plist while a freshly written one
204
+ * sat unused on disk. That is the 2026-08-02 report: `ocx service` prints a
205
+ * checkmark, `launchctl list` shows the job, and the port answers nothing.
206
+ *
207
+ * spawnSync, NOT execFileSync: execFileSync discards stderr when the child exits 0,
208
+ * which is precisely this case — a runner built on it returns an empty stderr and
209
+ * the guard below can never fire. Measured on macOS 27.0.
210
+ */
211
+ export function runLaunchctl(
212
+ args: string[],
213
+ deps: { run?: typeof spawnSync } = {},
214
+ ): { ok: boolean; stdout: string; stderr: string; status: number | null } {
215
+ const run = deps.run ?? spawnSync;
216
+ // Only the real runner is guarded. Tests that inject a spawnSync stand-in are
217
+ // exercising the parsing, not reaching launchd, and must keep working.
218
+ if (run === spawnSync) assertLiveServiceManagerAllowed(`launchctl ${args.join(" ")}`);
219
+ const result = run("/bin/launchctl", args, { encoding: "utf8", windowsHide: true });
220
+ // `error` is set when the spawn itself failed (ENOENT off macOS) and `status` is
221
+ // null for a signalled child; neither may be reported as success.
222
+ if (result.error) {
223
+ return { ok: false, stdout: "", stderr: String(result.error.message ?? ""), status: null };
224
+ }
225
+ return {
226
+ ok: result.status === 0,
227
+ stdout: String(result.stdout ?? "").trim(),
228
+ stderr: String(result.stderr ?? "").trim(),
229
+ /*
230
+ * The NUMBER, not just its zero-ness.
231
+ *
232
+ * `launchctl print` distinguishes "that domain does not exist" (112) from
233
+ * "the domain answered and has no such service" (113), and an ownership
234
+ * probe needs that difference: the second proves absence, the first only
235
+ * proves we could not look. Collapsing both into `ok: false` forced callers
236
+ * to parse stderr, which Apple does not treat as a stable interface.
237
+ */
238
+ status: result.status ?? null,
239
+ };
240
+ }
241
+
242
+ /**
243
+ * Whether launchctl output indicates the operation did not take. Needed because
244
+ * `ok` alone is insufficient for the legacy `load`/`unload` subcommands, which
245
+ * report failure on stderr while exiting 0. `bootstrap` exits 5, so for that path
246
+ * this is belt-and-braces rather than the only signal.
247
+ */
248
+ export function launchctlLoadFailed(stderr: string): boolean {
249
+ return /\b(?:Load|Bootstrap) failed\b/i.test(stderr);
250
+ }
251
+
252
+ /** launchd domain target for the current user's GUI session. */
253
+ export function launchdGuiDomain(): string {
254
+ return `gui/${process.getuid?.() ?? 0}`;
255
+ }
256
+
257
+ /**
258
+ * Whether launchd is running the job from the CURRENT plist. `launchctl list` only
259
+ * proves domain membership — a job bootstrapped from an older plist stays listed
260
+ * forever. `launchctl print` exposes the live `arguments`, which is the only way to
261
+ * catch a load that silently no-op'd.
262
+ */
263
+ export function launchdJobMatchesPlist(
264
+ expectedCommand: string,
265
+ deps: { run?: typeof runLaunchctl } = {},
266
+ ): { loaded: boolean; matchesPlist: boolean } {
267
+ const run = deps.run ?? runLaunchctl;
268
+ const printed = run(["print", `${launchdGuiDomain()}/${LABEL}`]);
269
+ if (!printed.ok) return { loaded: false, matchesPlist: false };
270
+ // `print` writes the arguments block to stdout for a live job. Search both streams
271
+ // anyway so a future launchctl that moves diagnostics between them cannot turn this
272
+ // into a false negative — a false "stale" verdict would send users to `bootout` for
273
+ // nothing.
274
+ const printedText = `${printed.stdout}\n${printed.stderr}`;
275
+ return { loaded: true, matchesPlist: printedText.includes(expectedCommand) };
276
+ }
277
+
278
+ /** `launchctl print`: the domain answered and holds no such service. */
279
+ const LAUNCHCTL_NO_SUCH_SERVICE = 113;
280
+
281
+ /** `launchctl print`: that domain does not exist at all (label-independent). */
282
+ const LAUNCHCTL_NO_SUCH_DOMAIN = 112;
283
+
284
+ /** `launchctl bootstrap`: something is already bootstrapped under that label. */
285
+ const LAUNCHCTL_BOOTSTRAP_BUSY = 5;
286
+
287
+ /** `launchctl bootout`: nothing was loaded under that label, i.e. already stopped. */
288
+ const LAUNCHCTL_BOOTOUT_NO_SUCH_PROCESS = 3;
289
+
290
+ /**
291
+ * Every domain target an eviction has to cover.
292
+ *
293
+ * {@link probeLaunchdLoadState} asks `gui/<uid>` AND `user/<uid>` because the two domains
294
+ * are independent and hold separate service sets, while every MUTATING verb in this file
295
+ * addressed `gui/<uid>` alone. So a `user/`-domain registration of our Label used to:
296
+ * survive `ocx service stop` (`bootout gui/<uid>/<label>` exits 3, "No such process", which
297
+ * the stop path correctly reads as "nothing was loaded" — in the wrong domain); survive the
298
+ * install cleanup whose whole job is evicting a live manager before new assets land, which
299
+ * then installed over a serving job; and stay registered while `installLaunchd` bootstrapped
300
+ * a SECOND registration of the same Label into `gui/`, leaving two KeepAlive jobs fighting
301
+ * for one port.
302
+ *
303
+ * Both domains unconditionally rather than the one a probe reports: `bootout` against a
304
+ * label a domain does not hold exits 3 and changes nothing, so enumerating first would buy
305
+ * an extra round trip to learn what the verb itself already reports.
306
+ */
307
+ export function launchdEvictionTargets(uid: number = process.getuid?.() ?? 0): string[] {
308
+ return [`gui/${uid}/${LABEL}`, `user/${uid}/${LABEL}`];
309
+ }
310
+
311
+ /**
312
+ * Whether a `bootout` exit status means "nothing of ours was loaded there" rather than a
313
+ * failure. 3 is "No such process"; 113/112 answer for the service and the domain, and a
314
+ * domain that does not exist cannot be holding a job of ours (a headless Mac has no `gui/`).
315
+ */
316
+ export function launchctlBootoutBenign(status: number | null): boolean {
317
+ return status === 0
318
+ || status === LAUNCHCTL_BOOTOUT_NO_SUCH_PROCESS
319
+ || status === LAUNCHCTL_NO_SUCH_SERVICE
320
+ || status === LAUNCHCTL_NO_SUCH_DOMAIN;
321
+ }
322
+
323
+ /**
324
+ * Four states, because three of them used to collapse into one bit.
325
+ *
326
+ * - `loaded-current` — a domain answers 0 and runs the command we expect.
327
+ * - `loaded-stale` — a domain answers 0 but runs a different command (an older plist).
328
+ * - `not-loaded` — every domain answered 112/113, which is proof of absence.
329
+ * - `unknown` — launchctl could not be asked, or answered something undocumented. NOT
330
+ * evidence of a problem, and deliberately not a reason to recommend `ocx service
331
+ * repair`: that command evicts the job, so recommending it on a failed probe is how
332
+ * #4236 turned a healthy hub into an outage.
333
+ */
334
+ export type LaunchdLoadState = "loaded-current" | "loaded-stale" | "not-loaded" | "unknown";
335
+
336
+ export interface LaunchdLoadProbe {
337
+ state: LaunchdLoadState;
338
+ /** The domain that answered, when one did. */
339
+ domain?: string;
340
+ /** Why the probe is `unknown`. Never carries plist contents or credentials. */
341
+ detail?: string;
342
+ }
343
+
344
+ /**
345
+ * Whether launchd is running our job, and from which plist — asked with `launchctl print`
346
+ * in BOTH user domains.
347
+ *
348
+ * Replaces `launchctl list | grep <label>`, which enumerated the CALLER's bootstrap domain
349
+ * (so a healthy `gui/$uid` job was invisible from ssh/cron), swallowed every exit code
350
+ * through `|| true`, and matched the label unanchored anywhere on a line (so
351
+ * `com.opencodex.proxy.helper` read as ours). `gui/` and `user/` are independent and hold
352
+ * separate service sets — measured on macOS 27.0: the shipped agent answers 0 under
353
+ * `gui/<uid>` and 113 under `user/<uid>` — so asking one leaves the other free to hold a
354
+ * job this probe would then call absent (same reasoning as `inspectLaunchd`).
355
+ */
356
+ export function probeLaunchdLoadState(deps: {
357
+ launchctl?: typeof runLaunchctl;
358
+ expectedCommand?: () => string;
359
+ uid?: number;
360
+ } = {}): LaunchdLoadProbe {
361
+ const run = deps.launchctl ?? runLaunchctl;
362
+ const uid = deps.uid ?? process.getuid?.() ?? 0;
363
+ for (const domain of [`gui/${uid}`, `user/${uid}`]) {
364
+ const printed = run(["print", `${domain}/${LABEL}`]);
365
+ if (printed.status === 0) {
366
+ const expected = (deps.expectedCommand
367
+ ?? (() => expectedLaunchdCommand(installedServiceListenPort())))();
368
+ const printedText = `${printed.stdout}\n${printed.stderr}`;
369
+ return {
370
+ state: printedText.includes(expected) ? "loaded-current" : "loaded-stale",
371
+ domain,
372
+ };
373
+ }
374
+ if (printed.status === LAUNCHCTL_NO_SUCH_SERVICE) continue;
375
+ // 112 is an answer ABOUT THE DOMAIN and is label-independent, so an unreachable
376
+ // domain cannot be hiding a job of ours. A headless Mac has no GUI domain and no
377
+ // installation either; calling that `unknown` would refuse every verdict on it.
378
+ if (printed.status === LAUNCHCTL_NO_SUCH_DOMAIN) continue;
379
+ return {
380
+ state: "unknown",
381
+ detail: printed.status === null
382
+ ? `launchctl could not be run: ${printed.stderr || "spawn failed"}`
383
+ : `launchctl print ${domain}/${LABEL} exited ${String(printed.status)}`,
384
+ };
385
+ }
386
+ return { state: "not-loaded" };
387
+ }
388
+
389
+ /** Up to ~5 × 200 ms, the launchd twin of the Windows scheduler settle delays. */
390
+ const LAUNCHD_SETTLE_ATTEMPTS = 5;
391
+
392
+ const LAUNCHD_SETTLE_DELAY_MS = 200;
393
+
394
+ /**
395
+ * Wait for a `bootout` to finish.
396
+ *
397
+ * `bootout` is asynchronous: it returns before the job has exited, so an immediate
398
+ * re-registration races it and gets "Bootstrap failed: 5: Input/output error" — which is
399
+ * exactly what made the old back-to-back retry useless (#4236, defect 1d). Bounded on
400
+ * purpose: a genuinely wedged domain must reach the diagnosable throw rather than hang.
401
+ *
402
+ * Synchronous because `installLaunchd` is (`ServiceOps.install` / `repairLaunchd` are
403
+ * `() => void`), so this uses `Bun.sleepSync` and exposes the seam for tests.
404
+ */
405
+ function settleLaunchdEviction(
406
+ run: typeof runLaunchctl,
407
+ target: string,
408
+ sleepSync: (ms: number) => void,
409
+ ): void {
410
+ for (let attempt = 0; attempt < LAUNCHD_SETTLE_ATTEMPTS; attempt += 1) {
411
+ if (!run(["print", target]).ok) return;
412
+ sleepSync(LAUNCHD_SETTLE_DELAY_MS);
413
+ }
414
+ }
415
+
416
+ /**
417
+ * What an install or repair actually DID to launchd.
418
+ *
419
+ * `reloaded: false` means the no-op path was taken — the plist on disk was already the
420
+ * rendered one, the data token was unchanged, and the probe answered `loaded-current` — so
421
+ * launchd was never asked for anything and the job is still the same process it was. That
422
+ * is the right answer for `repair` (a repair of a healthy service must not be an outage)
423
+ * and the WRONG one for `restart`, which is the verb an operator reaches for precisely when
424
+ * they want a new process. Only `restart` acts on it; see {@link restartLaunchdJob}.
425
+ */
426
+ export interface LaunchdInstallOutcome {
427
+ reloaded: boolean;
428
+ }
429
+
430
+ /**
431
+ * Deps follow {@link startLaunchd}: `launchctl` replaces the LAYER, returning a
432
+ * {@link runLaunchctl} result, not a spawnSync result. Every one is optional so this stays
433
+ * assignable to `ServiceOps.install` and `RepairServiceDeps.repairLaunchd`
434
+ * (`() => void`), and so `platformOps` wires the same function the tests exercise.
435
+ *
436
+ * The seam is what makes the eviction below testable at all. The live-service-manager
437
+ * guard refuses every mutating verb from an armed test process and `bootout` is not on
438
+ * its read-only list, so a test reaching the real runner would fail closed on the guard
439
+ * instead of exercising the sequence.
440
+ *
441
+ * `probe` is the TRI-STATE {@link probeLaunchdLoadState}, used twice and for opposite
442
+ * reasons: once before touching launchd, to prove a repair has nothing to do, and once
443
+ * after, because stderr cannot prove a load took. It is deliberately not the two-state
444
+ * `launchdJobMatchesPlist`, which reports `loaded: false` for every non-zero
445
+ * `launchctl print` — EPERM from a non-Aqua ssh/cron context, an unspawnable launchctl, an
446
+ * undocumented status. With that one, a healthy serving hub read as "not loaded" in the
447
+ * pre-check (so repair evicted it) and again in the verification (so the rollback evicted
448
+ * it a second time and the error claimed "IS NOT RUNNING" about a job that was up). An
449
+ * `unknown` probe is not evidence, so it refuses to evict instead.
450
+ *
451
+ * Protocol (#4236, defect 1). `ocx service repair` on darwin IS this function, and it
452
+ * evicts the running job — a public proxy, a management ingress and a loopback listener on
453
+ * a hub. So:
454
+ *
455
+ * 1. Ask launchd what it is running BEFORE writing anything. `unknown` throws without
456
+ * touching a file or a job; `loaded-current` plus an identical plist and an unchanged
457
+ * token file means there is nothing to repair, and a repair of a healthy service must
458
+ * never cause an outage.
459
+ * 2. Keep the previous plist bytes (in memory and at `<plist>.prev`) before overwriting.
460
+ * 3. `bootout` BOTH user domains, settle, then `bootstrap gui/$uid <plist>` — the verb
461
+ * that PAIRS with the bootout target. Legacy `load -w` is domain-implicit: it acts on
462
+ * the caller's own bootstrap domain, so from ssh/cron it deleted the gui-domain job and
463
+ * registered nothing (defect 1a).
464
+ * 4. Success is `launchctl print` agreeing, never a stderr regex: measured on macOS 27.0,
465
+ * `load -w` over a bootstrapped job exits 0 with "Load failed: 5" and does nothing,
466
+ * and `bootstrap` exits 5 with "Bootstrap failed: 5" for the same condition.
467
+ * 5. On terminal failure restore the previous plist, try to bootstrap it back, and throw
468
+ * an error that says what the probe actually found — down, or up on a different
469
+ * command — and names the manual remedy.
470
+ *
471
+ * Returns {@link LaunchdInstallOutcome} so the one caller that needs a RESTART rather than a
472
+ * repair can tell the no-op path from a reload. See {@link restartLaunchdJob}.
473
+ */
474
+ export function installLaunchd(deps: {
475
+ launchctl?: typeof runLaunchctl;
476
+ probe?: typeof probeLaunchdLoadState;
477
+ sleepSync?: (ms: number) => void;
478
+ /**
479
+ * Where to write the plist. Only tests pass it: `os.homedir()` reads the password
480
+ * database rather than `$HOME`, so the suite's HOME sandbox does NOT move
481
+ * `plistPath()`, and a case without this seam rewrites the developer's live
482
+ * `com.opencodex.proxy.plist`. `assertNotRealLaunchAgentsUnderTest` below makes that
483
+ * refusal mechanical rather than a convention.
484
+ */
485
+ plistPath?: string;
486
+ } = {}): LaunchdInstallOutcome {
487
+ const run = deps.launchctl ?? runLaunchctl;
488
+ const probeLoadState = deps.probe ?? probeLaunchdLoadState;
489
+ const sleepSync = deps.sleepSync ?? ((ms: number) => { Bun.sleepSync(ms); });
490
+ const p = deps.plistPath ?? plistPath();
491
+ const dir = dirname(p);
492
+ assertNotRealLaunchAgentsUnderTest(dir);
493
+ // Capture this BEFORE writing: the write below makes the plist exist unconditionally,
494
+ // so a post-write existsSync would call every fresh install an "installed" service.
495
+ const wasInstalled = existsSync(p);
496
+ // The previous definition, kept for rollback. An eviction whose bootstrap fails used to
497
+ // end with the new plist on disk, nothing in launchd, and nothing listening.
498
+ const previousPlist = wasInstalled ? readTextOrNull(p) : null;
499
+ // Resolve the launcher ONCE and hand the same value to the plist and to install state,
500
+ // so the staleness diagnostic judges exactly what launchd runs.
501
+ const launcher = stableLauncherEntry();
502
+ // The command THIS install bakes, not the one install state remembers: on a fresh
503
+ // install there is no state yet, and after a lost state file `expectedLaunchdCommand`
504
+ // falls back to the Bun + CLI pair and would call a correct launcher job stale (#3464).
505
+ const expectedCommand = launchdServiceCommand(launcher);
506
+ const uid = process.getuid?.() ?? 0;
507
+ const guiDomain = launchdGuiDomain();
508
+ const guiTarget = `${guiDomain}/${LABEL}`;
509
+ const evictionTargets = launchdEvictionTargets(uid);
510
+ const probeLive = (): LaunchdLoadProbe => probeLoadState({ expectedCommand: () => expectedCommand });
511
+
512
+ // Nothing has been written yet, deliberately: a probe that cannot answer must leave the
513
+ // host exactly as it found it.
514
+ let verdict = probeLive();
515
+ if (verdict.state === "unknown") {
516
+ throw new Error(
517
+ `refusing to ${wasInstalled ? "repair" : "install"} ${LABEL}: launchd state could not be verified `
518
+ + `— ${verdict.detail ?? "launchctl could not be asked"}.\n`
519
+ + "The job may be RUNNING, and this command evicts it, so nothing was changed.\n"
520
+ + `Check it with:\n launchctl print ${guiTarget}\n launchctl print user/${uid}/${LABEL}\n`
521
+ + "A non-Aqua context (ssh, cron, a launchd daemon) cannot always reach gui/<uid>; re-run "
522
+ + `'${wasInstalled ? "ocx service repair" : "ocx service install"}' from a GUI login session.`,
523
+ );
524
+ }
525
+
526
+ let rendered = buildPlist(resolvedProxyEnv(), { launcher });
527
+ if (previousPlist !== null && previousPlist !== rendered && verdict.state === "loaded-current") {
528
+ // The live job runs exactly the exec line this install baked, so the definition on disk
529
+ // IS the one launchd is running: keep the PATH it already carries instead of replacing
530
+ // it with the repairing process's. See `reusePreviousPlistPathVariable`.
531
+ const adopted = reusePreviousPlistPathVariable(previousPlist, rendered);
532
+ if (adopted !== null) rendered = adopted;
533
+ }
534
+ // Whether launchd has to be handed NEW BYTES, which is what decides below whether a
535
+ // `kickstart` can be trusted. `previousPlist === null` (a fresh install) counts: whatever
536
+ // the label may hold did not come from a definition we can see.
537
+ const renderedDiffers = previousPlist !== rendered;
538
+
539
+ // ── Writes start here. ──
540
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
541
+ recordOwnedConfigPath(getConfigDir(), serviceStatePath());
542
+ if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
543
+ const tokenFile = serviceApiTokenFilePath();
544
+ const previousToken = readTextOrNull(tokenFile);
545
+ writeServiceApiTokenFile();
546
+ // A rotated data token only reaches the job through a restart, so it is part of "is this
547
+ // repair a no-op?" — the plist `cat`s this file at launch.
548
+ const tokenUnchanged = readTextOrNull(tokenFile) === previousToken;
549
+
550
+ if (!renderedDiffers && tokenUnchanged && verdict.state === "loaded-current") {
551
+ // The plist is not rewritten and launchd is not touched; only the owner-only mode
552
+ // is re-asserted, for a definition an older version may have left at 0644.
553
+ try { chmodSync(p, 0o600); } catch { /* best-effort */ }
554
+ // Install state is refreshed because it is what `expectedLaunchdCommand` reads, and
555
+ // a repair that leaves it stale re-creates the false "OLDER plist" report.
556
+ writeServiceInstallState("scheduler", launcher);
557
+ console.log("ℹ️ service is already loaded from the current plist; nothing to do.");
558
+ // The ONLY `reloaded: false` exit: the process launchd was running when this command
559
+ // started is still running, same pid. `ocx service restart` turns that into a kickstart.
560
+ return { reloaded: false };
561
+ }
562
+
563
+ if (previousPlist !== null) {
564
+ // Best-effort: a backup we could not write must not stop the repair, but it is the
565
+ // only thing that makes the rollback below able to restore bytes rather than guesses.
566
+ //
567
+ // `.plist.prev`, not `.prev.plist`: launchd globs `~/Library/LaunchAgents/*.plist` at
568
+ // login, so a backup ending in `.plist` would be a second registration of the same
569
+ // Label fighting the real one for the port.
570
+ try { writeServiceDefinitionFile(`${p}.prev`, previousPlist, "utf8"); } catch { /* best-effort */ }
571
+ }
572
+ writeServiceDefinitionFile(p, rendered, "utf8");
573
+
574
+ // This EVICTS the running job, and from here until the verification below nothing is
575
+ // listening. `unload` is the legacy verb and does not evict a job bootstrapped into the
576
+ // GUI domain — precisely the state that could not repair itself (#4141).
577
+ //
578
+ // Absence is fine: booting out a job that is not there exits 3 ("No such process"), and
579
+ // a real failure is reported by the verification below with a better message than a raw
580
+ // eviction error would carry.
581
+ const evictEveryDomain = (): void => {
582
+ for (const target of evictionTargets) {
583
+ // Settle only where something was actually evicted. `bootout` is asynchronous, so a
584
+ // job it DID remove needs waiting for; one it never held (exit 3) has nothing to
585
+ // wait on, and probing it would only add a round trip per install.
586
+ if (run(["bootout", target]).status === 0) settleLaunchdEviction(run, target, sleepSync);
587
+ }
588
+ };
589
+ const evictThenBootstrap = (): { ok: boolean; stdout: string; stderr: string; status: number | null } => {
590
+ evictEveryDomain();
591
+ return run(["bootstrap", guiDomain, p]);
592
+ };
593
+
594
+ let loaded = evictThenBootstrap();
595
+ verdict = probeLive();
596
+ // `unknown` is excluded on purpose: a retry means another eviction, and a probe that
597
+ // could not answer is not a reason to take the job down again.
598
+ if (verdict.state === "not-loaded" || verdict.state === "loaded-stale") {
599
+ // ONE retry. A bounded retry recovers the race; a loop would turn a wedged domain
600
+ // into a hang instead of the diagnosable throw below.
601
+ if (loaded.status === LAUNCHCTL_BOOTSTRAP_BUSY || launchctlLoadFailed(loaded.stderr)) {
602
+ // "Bootstrap failed: 5: Input/output error" has TWO causes, measured on macOS 27.0
603
+ // with a throwaway label, and they need opposite remedies:
604
+ //
605
+ // - something is still bootstrapped under our label (the job re-registered, or it
606
+ // had not finished exiting). `kickstart -k` restarts what the domain holds without
607
+ // opening a second eviction window — but it restarts launchd's CACHED definition
608
+ // and does NOT re-read the plist, so it can only settle this when the rendered
609
+ // bytes are the ones already on disk. With new bytes it would restart the OLD
610
+ // definition, and since the exec line is unchanged whenever only
611
+ // `EnvironmentVariables` moved, the verification below would agree and install
612
+ // state would be written while launchd kept the stale environment. So: new bytes
613
+ // skip kickstart and go to the eviction, which is the only way to publish them.
614
+ // - the label is in the domain's DISABLED list, so `bootstrap` refuses it while
615
+ // `launchctl print` reports 113. This is the one thing the legacy `load -w` did
616
+ // that plain `bootstrap` does not: `-w` cleared that flag. `enable` is the modern
617
+ // spelling of it, and it is idempotent on a job that was never disabled — but it
618
+ // runs only here, so an ordinary repair does not quietly undo a deliberate
619
+ // `launchctl disable`.
620
+ const kicked = renderedDiffers ? null : run(["kickstart", "-k", guiTarget]);
621
+ if (kicked?.ok) verdict = probeLive();
622
+ if (verdict.state !== "loaded-current") {
623
+ run(["enable", guiTarget]);
624
+ loaded = evictThenBootstrap();
625
+ verdict = probeLive();
626
+ }
627
+ } else if (loaded.ok) {
628
+ // Exit 0 while `print` disagrees: the load silently no-op'd. That IS worth evicting
629
+ // again. A load that failed for any OTHER reason — a malformed plist, EPERM — is not
630
+ // fixed by evicting a job, so it falls straight through to the throw and the
631
+ // operator sees the real stderr instead of a delayed copy of it.
632
+ loaded = evictThenBootstrap();
633
+ verdict = probeLive();
634
+ }
635
+ }
636
+
637
+ if (verdict.state === "unknown") {
638
+ // The probe stopped answering between the pre-check and here. Do NOT evict again, do
639
+ // NOT roll back (a rollback is another eviction) and do NOT claim the job is down: the
640
+ // bytes we asked launchd to load are the ones on disk either way.
641
+ if (!loaded.ok) {
642
+ throw new Error(
643
+ `launchctl could not bootstrap ${p}: ${loaded.stderr || "bootstrap reported failure"}\n`
644
+ + `and the state of ${LABEL} could not be verified afterwards — ${verdict.detail ?? "launchctl could not be asked"}.\n`
645
+ + "The job was NOT evicted again and the plist was left in place, so this says nothing about "
646
+ + "whether it is running.\n"
647
+ + `Check it with:\n launchctl print ${guiTarget}\n launchctl print user/${uid}/${LABEL}\n`
648
+ + `and load it if it is absent:\n launchctl bootstrap ${guiDomain} ${p}`,
649
+ );
650
+ }
651
+ console.warn(
652
+ `⚠️ launchctl accepted the bootstrap but the job state could not be verified — ${
653
+ verdict.detail ?? "launchctl could not be asked"}. Check: launchctl print ${guiTarget}`,
654
+ );
655
+ writeServiceInstallState("scheduler", launcher);
656
+ return { reloaded: true };
657
+ }
658
+
659
+ if (verdict.state !== "loaded-current") {
660
+ // Do NOT write install state for a load that did not take: state describing an unused
661
+ // plist is what made this failure invisible.
662
+ let rolledBack: "restored" | "on-disk-only" | "none" = "none";
663
+ if (previousPlist !== null) {
664
+ try {
665
+ writeServiceDefinitionFile(p, previousPlist, "utf8");
666
+ evictEveryDomain();
667
+ run(["bootstrap", guiDomain, p]);
668
+ rolledBack = run(["print", guiTarget]).ok ? "restored" : "on-disk-only";
669
+ } catch {
670
+ rolledBack = "on-disk-only";
671
+ }
672
+ }
673
+ // What the probe actually found, rather than one sentence for both outcomes: a
674
+ // `loaded-stale` job IS running, and telling its operator "nothing is listening" sends
675
+ // them to fix the wrong thing.
676
+ const state = verdict.state === "loaded-stale"
677
+ ? `is still loaded in ${verdict.domain ?? guiDomain} from a DIFFERENT command than the plist just written`
678
+ : rolledBack === "restored"
679
+ ? `was evicted from ${guiDomain}; the PREVIOUS plist was restored and re-bootstrapped`
680
+ : `was evicted from ${guiDomain} and IS NOT RUNNING — nothing is listening`;
681
+ throw new Error(
682
+ `launchctl could not bootstrap ${p}: ${loaded.stderr || "bootstrap reported failure"}\n`
683
+ + `The ${LABEL} job ${state}.\n`
684
+ + (rolledBack === "on-disk-only"
685
+ ? "The previous plist was restored on disk but could not be bootstrapped either.\n"
686
+ : "")
687
+ + `Recover manually with:\n launchctl bootstrap ${guiDomain} ${p}\n`
688
+ + `Inspect it with:\n launchctl print ${guiTarget}\n launchctl print-disabled ${guiDomain}\n`
689
+ // macOS `service repair` delegates straight to installLaunchd, so this fires for
690
+ // an already-installed service too; repair reloads it without re-registering.
691
+ + `then re-run '${wasInstalled ? "ocx service repair" : "ocx service install"}'.`,
692
+ );
693
+ }
694
+ writeServiceInstallState("scheduler", launcher);
695
+ // The rollback copy has done its job: the new definition is verified loaded. Leaving it
696
+ // behind makes the NEXT repair's backup ambiguous (which failure did it come from?) and
697
+ // `uninstall` the only thing that ever cleaned it up.
698
+ if (existsSync(`${p}.prev`)) { try { unlinkSync(`${p}.prev`); } catch { /* best-effort */ } }
699
+ return { reloaded: true };
700
+ }
701
+
702
+ /**
703
+ * Restart the loaded job IN PLACE — the `restart` half of `ocx service restart`.
704
+ *
705
+ * Only reached when {@link installLaunchd} reported `reloaded: false`, i.e. the plist is
706
+ * already the current one and the probe proved the job is loaded from it. Nothing has to be
707
+ * published, so this must NOT evict: `kickstart -k` restarts what the domain already holds
708
+ * without opening an eviction window, which is the whole reason `restart` can be honest
709
+ * about a healthy service while `repair` stays a no-op on it. `kickstart` restarts the
710
+ * definition launchd has CACHED and does not re-read the plist — harmless here, and exactly
711
+ * why the retry path inside `installLaunchd` may only use it for bytes already on disk.
712
+ *
713
+ * `launchctl print` answers about REGISTRATION, not liveness, so the verification asks the
714
+ * same tri-state probe `installLaunchd` does: `loaded-current` is the restart confirmed,
715
+ * `unknown` is not evidence of anything and only warns, and absence after a kick means the
716
+ * job went away and KeepAlive did not bring it back — which throws, so the repair branch
717
+ * reports it and still runs its serving check.
718
+ *
719
+ * Both deps are test seams, and the default runner is also refused by
720
+ * `assertLiveServiceManagerAllowed`: `kickstart` is not a read-only verb, so an armed test
721
+ * process that reached the real runner would fail closed rather than bounce the developer's
722
+ * own hub.
723
+ */
724
+ export function restartLaunchdJob(deps: {
725
+ launchctl?: typeof runLaunchctl;
726
+ probe?: typeof probeLaunchdLoadState;
727
+ /** The exec line the live job must carry; defaults to the one an install would bake. */
728
+ expectedCommand?: () => string;
729
+ } = {}): void {
730
+ const run = deps.launchctl ?? runLaunchctl;
731
+ const target = `${launchdGuiDomain()}/${LABEL}`;
732
+ const expectedCommand = deps.expectedCommand
733
+ ?? (() => launchdServiceCommand(stableLauncherEntry()));
734
+ const kicked = run(["kickstart", "-k", target]);
735
+ const verdict = (deps.probe ?? probeLaunchdLoadState)({ expectedCommand });
736
+ if (!kicked.ok || verdict.state === "not-loaded" || verdict.state === "loaded-stale") {
737
+ // Three different things to say, because they send the operator to three different
738
+ // places: the job is gone, the job is up on an older definition, or the job is up and
739
+ // `kickstart` refused — in which case the proxy is fine and only the restart failed.
740
+ const state = verdict.state === "not-loaded"
741
+ ? `is NOT loaded in ${launchdGuiDomain()} — nothing is listening`
742
+ : verdict.state === "loaded-stale"
743
+ ? "is loaded from a DIFFERENT command than the plist on disk"
744
+ : "is still loaded, so it may be serving the process this restart failed to replace";
745
+ throw new Error(
746
+ `launchctl could not restart ${LABEL}: ${kicked.stderr || "kickstart reported failure"}\n`
747
+ + `The ${LABEL} job ${state}.\n`
748
+ + `Restart it manually with:\n launchctl kickstart -k ${target}\n`
749
+ + `Inspect it with:\n launchctl print ${target}\n`
750
+ + "and run 'ocx service repair' if it is absent.",
751
+ );
752
+ }
753
+ if (verdict.state === "unknown") {
754
+ console.warn(
755
+ `⚠️ launchctl accepted the restart but the job state could not be verified — ${
756
+ verdict.detail ?? "launchctl could not be asked"}. Check: launchctl print ${target}`,
757
+ );
758
+ return;
759
+ }
760
+ console.log(`ℹ️ service restarted (launchctl kickstart -k ${target}).`);
761
+ }
762
+
763
+ /**
764
+ * Deps are named for the layer they replace, not for the process API: `launchctl`
765
+ * returns a {@link runLaunchctl} result and `matches` a {@link launchdJobMatchesPlist}
766
+ * result. Only `runLaunchctl` itself takes a spawnSync mock.
767
+ *
768
+ * Exported for the branch tests. Every parameter is optional, so this stays
769
+ * assignable to `ServiceOps.start` (`() => void`) and `platformOps` wires the same
770
+ * function the tests exercise.
771
+ */
772
+ export function startLaunchd(deps: {
773
+ launchctl?: typeof runLaunchctl;
774
+ matches?: typeof launchdJobMatchesPlist;
775
+ } = {}): void {
776
+ const run = deps.launchctl ?? runLaunchctl;
777
+ const p = plistPath();
778
+ const loaded = run(["load", "-w", p]);
779
+ if (loaded.ok && !launchctlLoadFailed(loaded.stderr)) return;
780
+ // `Load failed` on start is AMBIGUOUS in a way it is not on install: the job may
781
+ // already be bootstrapped from THIS plist, which is a no-op rather than an error.
782
+ // `install` can assume a stale job (it just rewrote the plist); `start` cannot, and
783
+ // throwing here would break `ocx service start` on every healthy service.
784
+ const live = (deps.matches ?? launchdJobMatchesPlist)(
785
+ expectedLaunchdCommand(installedServiceListenPort()),
786
+ );
787
+ if (live.loaded && live.matchesPlist) {
788
+ console.log("ℹ️ service was already loaded from the current plist; nothing to do.");
789
+ return;
790
+ }
791
+ throw new Error(
792
+ `launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
793
+ + (live.loaded
794
+ ? `launchd is running an OLDER plist. Fix:\n launchctl bootout ${launchdGuiDomain()}/${LABEL}\n ocx service repair`
795
+ : "The job is not loaded. Run 'ocx service repair' to reload it."),
796
+ );
797
+ }
798
+
799
+ /**
800
+ * Evict the job with the modern, domain-explicit verb, in EVERY domain that can hold it;
801
+ * fall back to legacy `unload` only when `bootout` could not be run at all.
802
+ *
803
+ * `unload` cannot evict a job bootstrapped into the GUI domain (the same reason
804
+ * `installLaunchd` stopped using it), so a stop built on it reported success while the
805
+ * proxy kept serving. Exit 3 ("Boot-out failed: 3: No such process") is the not-loaded
806
+ * case and is not a failure here.
807
+ *
808
+ * `gui/<uid>` alone was the remaining half of that bug: `probeLaunchdLoadState` reports a
809
+ * `user/<uid>` job too, and against one of those this function exited 3 in the wrong domain
810
+ * and returned as if it had stopped something. See {@link launchdEvictionTargets}.
811
+ */
812
+ export function stopLaunchd(deps: { launchctl?: typeof runLaunchctl } = {}): void {
813
+ const run = deps.launchctl ?? runLaunchctl;
814
+ let spawnable = true;
815
+ try {
816
+ for (const target of launchdEvictionTargets()) {
817
+ // Any real exit status is final — including 3, which only means the job was not
818
+ // loaded THERE. `status: null` is "launchctl could not be spawned at all", and only
819
+ // then is the legacy verb worth one attempt.
820
+ if (run(["bootout", target]).status === null) spawnable = false;
821
+ }
822
+ } catch {
823
+ // The armed-test guard refuses mutating verbs; retrying through `sh` would only hit it
824
+ // again.
825
+ return;
826
+ }
827
+ if (spawnable) return;
828
+ try { sh(`launchctl unload "${plistPath()}"`); } catch { /* not loaded */ }
829
+ }
830
+
831
+ /**
832
+ * Registration for `ocx service stop`'s "is anything installed?" guard, as a human string.
833
+ * Empty means "no job of ours is loaded"; the tri-state lives in
834
+ * {@link probeLaunchdLoadState}, which `diagnoseService` uses instead of this.
835
+ */
836
+ export function statusLaunchd(deps: { probe?: typeof probeLaunchdLoadState } = {}): string {
837
+ const probe = (deps.probe ?? probeLaunchdLoadState)();
838
+ if (probe.state === "loaded-current") return `${LABEL} loaded in ${probe.domain ?? launchdGuiDomain()}`;
839
+ if (probe.state === "loaded-stale") return `${LABEL} loaded in ${probe.domain ?? launchdGuiDomain()} from an OLDER plist`;
840
+ if (probe.state === "unknown") return `${LABEL} state unknown: ${probe.detail ?? "launchctl could not be asked"}`;
841
+ return "";
842
+ }
843
+
844
+ export function uninstallLaunchd(deps: { launchctl?: typeof runLaunchctl } = {}): void {
845
+ const p = plistPath();
846
+ // Same reason as `installLaunchd`: HOME isolation does not move this path, so without
847
+ // the guard an armed test process deletes the developer's live plist.
848
+ assertNotRealLaunchAgentsUnderTest(dirname(p));
849
+ stopLaunchd(deps);
850
+ if (existsSync(p)) unlinkSync(p);
851
+ // The rollback copy is part of the installation, not a user file.
852
+ if (existsSync(`${p}.prev`)) { try { unlinkSync(`${p}.prev`); } catch { /* best-effort */ } }
853
+ }