@bitkyc08/opencodex 2.52.0 → 2.53.0

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-BlO4Yl6q.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-CWXut3rG.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,340 @@
1
+ import { existsSync } from "node:fs";
2
+ import { statusWinswRaw, winswStatusSummary } from "../lib/winsw";
3
+ import { cachedCurrentWindowsIdentity, resolveCurrentWindowsPrincipal, WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS } from "../lib/windows-user-principal";
4
+ import { sh } from "./guards";
5
+ import { installedServiceListenPort, confirmServiceServing } from "./health";
6
+ import { expectedLaunchdCommand, launchdJobMatchesPlist, probeLaunchdLoadState } from "./launchd";
7
+ import type { LaunchdLoadProbe } from "./launchd";
8
+ import { plistPath, windowsServiceScriptPath, windowsLauncherVbsPath, windowsTaskXmlPath, readServiceInstallState } from "./state";
9
+ import type { ServiceBackend } from "./state";
10
+ import { unitPath, isSystemd } from "./systemd";
11
+ import { statusWindowsXml } from "./windows-ops";
12
+ import { cachedWindowsTaskUserIds, taskXmlSection, taskXmlWithoutCommentsAndCdata, taskXmlElementCount, readWindowsSchedulerXmlState } from "./windows-taskxml";
13
+ import type { ExpectedWindowsTaskUserId } from "./windows-taskxml";
14
+ import { join, win32 } from "node:path";
15
+ import { WINSW_VERSION } from "../lib/winsw";
16
+ import { serviceRepairCommand } from "./health";
17
+ import { LABEL, TASK, serviceLogPath } from "./state";
18
+
19
+ /**
20
+ * Warn when the paths baked into installed service assets no longer exist (npm prefix
21
+ * moved, nvm switch, reinstall) — the service manager would restart-loop on a dead path
22
+ * while `schtasks`/`launchctl` still report "installed".
23
+ */
24
+ export function bakedServicePathsDiagnostic(): string | null {
25
+ const state = readServiceInstallState();
26
+ // A launcher install runs the launcher, not the baked pair, so the pair's existence says
27
+ // nothing about whether the service can start. Judging the recorded launcher is both
28
+ // necessary (a deleted launcher IS stale) and sufficient (a replaced version directory
29
+ // is not, which is exactly what #2898 made routine).
30
+ if (state?.launcherPath) {
31
+ if (existsSync(state.launcherPath)) return null;
32
+ return `STALE baked paths (missing: ${state.launcherPath}) — run 'ocx service repair' to re-bake`;
33
+ }
34
+ if (!state?.bunPath || !state?.cliPath) return null;
35
+ const missing = [state.bunPath, state.cliPath].filter(path => !existsSync(path));
36
+ if (missing.length === 0) return null;
37
+ return `STALE baked paths (missing: ${missing.join(", ")}) — run 'ocx service repair' to re-bake`;
38
+ }
39
+
40
+ export function serviceDiagnosticsSummary(): string {
41
+ const stale = bakedServicePathsDiagnostic();
42
+ return stale ? `${stale}; logs: ${serviceLogPath()}` : `logs: ${serviceLogPath()}`;
43
+ }
44
+
45
+ export interface ServiceDiagnostic {
46
+ supported: boolean;
47
+ installed: boolean;
48
+ enabled: boolean;
49
+ running: boolean;
50
+ viable: boolean;
51
+ startable: boolean;
52
+ stale: boolean;
53
+ conflict: boolean;
54
+ backend: ServiceBackend | "launchd" | "systemd" | null;
55
+ summary: string;
56
+ }
57
+
58
+ /** Windows tray may restart a healthy-but-stopped native service; stale/conflicting installs remain blocked. */
59
+ export function serviceStartableFromTray(service: ServiceDiagnostic): boolean {
60
+ return service.startable && !service.stale && !service.conflict;
61
+ }
62
+
63
+ export interface WindowsTaskDiagnosticIdentityDeps {
64
+ currentIdentity?: () => Readonly<{ sid: string; name: string }> | null;
65
+ resolvePrincipal?: (timeoutMs: number) => string;
66
+ }
67
+
68
+ /**
69
+ * Resolve the effective account only when the registered task carries an explicit unprefixed
70
+ * trigger scope. Empty/unscoped tasks do not need identity and must not pay a repeated sync
71
+ * lookup timeout; prefixed scopes remain unreadable and fail closed in the XML validator.
72
+ */
73
+ export function resolveWindowsTaskDiagnosticUserId(
74
+ schedulerXml: string,
75
+ deps: WindowsTaskDiagnosticIdentityDeps = {},
76
+ ): readonly string[] | null {
77
+ const currentIdentity = deps.currentIdentity ?? cachedCurrentWindowsIdentity;
78
+ const cached = currentIdentity();
79
+ if (cached) return [cached.sid, cached.name];
80
+
81
+ const scrubbed = taskXmlWithoutCommentsAndCdata(schedulerXml);
82
+ const triggers = taskXmlSection(scrubbed, "Triggers");
83
+ if (taskXmlElementCount(triggers, "UserId") === 0) return null;
84
+
85
+ try {
86
+ (deps.resolvePrincipal ?? resolveCurrentWindowsPrincipal)(WINDOWS_PRINCIPAL_LOOKUP_TIMEOUT_MS);
87
+ } catch {
88
+ return null;
89
+ }
90
+ const resolved = currentIdentity();
91
+ return resolved ? [resolved.sid, resolved.name] : null;
92
+ }
93
+
94
+ export interface WindowsServiceDiagnosticInputs {
95
+ /**
96
+ * Raw `schtasks /query /xml` output; empty when no task is registered. Passed as
97
+ * XML rather than pre-computed booleans so every caller reads the document through
98
+ * readWindowsSchedulerXmlState() — a second, stricter reading elsewhere would
99
+ * silently reintroduce the stale-status false positive (#432).
100
+ */
101
+ schedulerXml: string;
102
+ /** Resolved effective account for explicit scheduler trigger scopes; null means unknown. */
103
+ schedulerExpectedUserId?: ExpectedWindowsTaskUserId | null;
104
+ /** Whether the on-disk service assets exist. A filesystem concern, not an XML one. */
105
+ schedulerAssetsPresent: boolean;
106
+ nativeStatus: "started" | "stopped" | "nonexistent" | "unknown";
107
+ recordedBackend: ServiceBackend | null;
108
+ staleBakedPaths: boolean;
109
+ nativeRepairAssetsOnly: boolean;
110
+ diagnostics: string;
111
+ }
112
+
113
+ export function deriveWindowsServiceDiagnostic(inputs: WindowsServiceDiagnosticInputs): ServiceDiagnostic {
114
+ const expectedUserId = inputs.schedulerExpectedUserId === undefined
115
+ ? cachedWindowsTaskUserIds()
116
+ : inputs.schedulerExpectedUserId;
117
+ const schedulerState = readWindowsSchedulerXmlState(
118
+ inputs.schedulerXml,
119
+ undefined,
120
+ undefined,
121
+ expectedUserId,
122
+ );
123
+ const schedulerInstalled = schedulerState.installed;
124
+ const schedulerEnabled = schedulerState.enabled;
125
+ const schedulerAssetsHealthy = inputs.schedulerAssetsPresent && schedulerState.registrationHealthy;
126
+ const nativeInstalled = inputs.nativeStatus !== "nonexistent";
127
+ const conflict = schedulerInstalled && nativeInstalled;
128
+ const backendStateMismatch = schedulerInstalled
129
+ ? inputs.recordedBackend !== "scheduler"
130
+ : nativeInstalled && inputs.recordedBackend !== "native";
131
+ const stale = inputs.staleBakedPaths
132
+ || (schedulerInstalled && !schedulerAssetsHealthy)
133
+ || backendStateMismatch
134
+ || (inputs.nativeStatus === "nonexistent" && inputs.nativeRepairAssetsOnly);
135
+ const backend = schedulerInstalled ? "scheduler" : nativeInstalled ? "native" : null;
136
+ const enabled = schedulerInstalled ? schedulerEnabled : inputs.nativeStatus === "started";
137
+ const running = nativeInstalled ? inputs.nativeStatus === "started" : schedulerInstalled && schedulerEnabled;
138
+ const viable = !conflict && !stale
139
+ && (schedulerInstalled ? schedulerEnabled && schedulerAssetsHealthy : inputs.nativeStatus === "started");
140
+ const startable = !conflict && !stale
141
+ && (schedulerInstalled
142
+ ? schedulerEnabled && schedulerAssetsHealthy
143
+ : inputs.nativeStatus === "started" || inputs.nativeStatus === "stopped");
144
+ const detail = conflict
145
+ ? "CONFLICT: Task Scheduler and native WinSW are both present — run 'ocx service uninstall' then reinstall one"
146
+ : stale
147
+ ? "stale or missing service assets — run 'ocx service repair'"
148
+ : schedulerInstalled
149
+ ? schedulerEnabled ? "Task Scheduler enabled" : "Task Scheduler disabled"
150
+ : nativeInstalled
151
+ ? `native (WinSW ${WINSW_VERSION}): ${inputs.nativeStatus}`
152
+ : "not installed";
153
+ const summary = backend ? `installed, ${detail} (${inputs.diagnostics})` : `not installed (${inputs.diagnostics})`;
154
+ return {
155
+ supported: true,
156
+ installed: schedulerInstalled || nativeInstalled,
157
+ enabled,
158
+ running,
159
+ viable,
160
+ startable,
161
+ stale,
162
+ conflict,
163
+ backend,
164
+ summary,
165
+ };
166
+ }
167
+
168
+ /** Bind the live Windows identity to a scheduler snapshot before deriving service health. */
169
+ export function deriveWindowsServiceDiagnosticForCurrentUser(
170
+ inputs: Omit<WindowsServiceDiagnosticInputs, "schedulerExpectedUserId">,
171
+ identityDeps: WindowsTaskDiagnosticIdentityDeps = {},
172
+ ): ServiceDiagnostic {
173
+ return deriveWindowsServiceDiagnostic({
174
+ ...inputs,
175
+ schedulerExpectedUserId: resolveWindowsTaskDiagnosticUserId(inputs.schedulerXml, identityDeps),
176
+ });
177
+ }
178
+
179
+ export interface LaunchdServiceDiagnosticInputs {
180
+ installed: boolean;
181
+ stale: boolean;
182
+ load: LaunchdLoadProbe;
183
+ diagnostics: string;
184
+ }
185
+
186
+ /**
187
+ * Turn the launchd tri-state into a {@link ServiceDiagnostic}. Pure, so the four states
188
+ * are testable without a live launchd.
189
+ *
190
+ * `unknown` is the one that used to do damage. The old probe collapsed "launchctl could
191
+ * not be asked" into "not loaded", which printed `installed, not loaded` for a serving hub
192
+ * and recommended `ocx service repair` — the command that evicts the job (#4236). So:
193
+ *
194
+ * - the summary says the state could not be verified and names NO repair command, and
195
+ * - `viable` stays true, because `isServiceViable() === false` is what makes
196
+ * `src/update/index.ts` and `src/update/job.ts` treat a successful repair as a dead
197
+ * supervisor and start a competing proxy on the service's own port. A failed probe is
198
+ * not evidence against the service; `startable` is likewise left alone so the tray can
199
+ * still hand a start to `ocx service start`, which no-ops on an already-loaded job.
200
+ *
201
+ * `loaded-stale` keeps the viability the `launchctl list` era gave it (loaded ⇒ viable, so
202
+ * the update fallback behaves as before), and only the summary is upgraded — the operator
203
+ * is told the live job came from an older plist, which is the one case where `repair` is
204
+ * exactly right.
205
+ */
206
+ export function deriveLaunchdServiceDiagnostic(inputs: LaunchdServiceDiagnosticInputs): ServiceDiagnostic {
207
+ const { installed, stale, load, diagnostics } = inputs;
208
+ const loaded = load.state === "loaded-current" || load.state === "loaded-stale";
209
+ const running = installed && loaded;
210
+ const verified = load.state !== "unknown";
211
+ const viable = installed && !stale && (loaded || !verified);
212
+ const summary = !installed ? `not installed (${diagnostics})`
213
+ : stale ? `installed, but stale (launchd; ${diagnostics})`
214
+ : load.state === "loaded-current" ? `installed and loaded (launchd; ${diagnostics})`
215
+ : load.state === "loaded-stale"
216
+ ? `installed and loaded from an OLDER plist (launchd; ${diagnostics})`
217
+ : load.state === "unknown"
218
+ ? `installed; launchd state could not be verified — ${load.detail ?? "launchctl could not be asked"} (launchd; ${diagnostics})`
219
+ : `installed, not loaded (launchd; ${diagnostics})`;
220
+ return {
221
+ supported: true,
222
+ installed,
223
+ enabled: running,
224
+ running,
225
+ viable,
226
+ startable: installed && !stale,
227
+ stale,
228
+ conflict: false,
229
+ backend: "launchd",
230
+ summary,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Fail-closed restart diagnostic. Presence alone is never enough: conflicting
236
+ * managers, stale baked paths, disabled registrations, and unknown/stopped
237
+ * native managers cannot claim that Codex will reconnect after a reboot.
238
+ */
239
+ export function diagnoseService(): ServiceDiagnostic {
240
+ const diagnostics = serviceDiagnosticsSummary();
241
+ if (process.platform === "darwin") {
242
+ const installed = existsSync(plistPath());
243
+ const stale = installed && bakedServicePathsDiagnostic() !== null;
244
+ return deriveLaunchdServiceDiagnostic({
245
+ installed,
246
+ stale,
247
+ load: installed ? probeLaunchdLoadState() : { state: "not-loaded" },
248
+ diagnostics,
249
+ });
250
+ }
251
+ if (process.platform === "win32") {
252
+ const schedulerXml = statusWindowsXml();
253
+ const schedulerAssetsPresent = [windowsServiceScriptPath(), windowsLauncherVbsPath(), windowsTaskXmlPath()]
254
+ .every(existsSync);
255
+ const nativeStatus = statusWinswRaw();
256
+ const installState = readServiceInstallState();
257
+ const recordedBackend: ServiceBackend | null = !installState
258
+ ? null
259
+ : installState.backend === "native" ? "native" : "scheduler";
260
+ return deriveWindowsServiceDiagnosticForCurrentUser({
261
+ schedulerXml,
262
+ schedulerAssetsPresent,
263
+ nativeStatus,
264
+ recordedBackend,
265
+ staleBakedPaths: bakedServicePathsDiagnostic() !== null,
266
+ nativeRepairAssetsOnly: Boolean(winswStatusSummary()),
267
+ diagnostics,
268
+ });
269
+ }
270
+ if (process.platform === "linux") {
271
+ if (existsSync("/.dockerenv")) return { supported: false, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: "unsupported in Docker" };
272
+ if (!isSystemd()) return { supported: false, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: "unsupported: systemd not found" };
273
+ const installed = existsSync(unitPath());
274
+ const enabled = installed && (() => { try { return sh(`systemctl --user is-enabled ${TASK}`) === "enabled"; } catch { return false; } })();
275
+ const running = installed && (() => { try { return sh(`systemctl --user is-active ${TASK}`) === "active"; } catch { return false; } })();
276
+ const stale = installed && bakedServicePathsDiagnostic() !== null;
277
+ const viable = installed && enabled && running && !stale;
278
+ const summary = !installed ? `not installed (${diagnostics})`
279
+ : stale ? `installed, but stale (systemd user; ${diagnostics})`
280
+ : viable ? `installed, enabled and running (systemd user; ${diagnostics})`
281
+ : `installed, but ${!enabled ? "disabled" : "not running"} (systemd user; ${diagnostics})`;
282
+ return { supported: true, installed, enabled, running, viable, startable: installed && !stale, stale, conflict: false, backend: "systemd", summary };
283
+ }
284
+ return { supported: false, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: `unsupported on ${process.platform}` };
285
+ }
286
+
287
+ export function serviceStatusSummary(): string {
288
+ return diagnoseService().summary;
289
+ }
290
+
291
+ /**
292
+ * Status a human can act on: registration state, whether a proxy actually answers,
293
+ * and — when it does not — whether launchd is running the plist we have on disk.
294
+ *
295
+ * `launchctl list` membership cannot distinguish "serving", "bootstrapped from an
296
+ * older plist", and "loaded but never bound"; the reported failure was the middle
297
+ * one presented as the first.
298
+ *
299
+ * Resolves the port through `confirmServiceServing`, i.e. the same
300
+ * `installedServiceListenPort()` path install/start/repair use, so those surfaces can
301
+ * never disagree about one service. The budget is short (2 probes) because this is a
302
+ * status read, not a post-install wait.
303
+ */
304
+ export async function serviceStatusReport(
305
+ deps: {
306
+ diagnose?: () => ServiceDiagnostic;
307
+ serving?: () => Promise<{ ok: boolean; port: number }>;
308
+ matchesPlist?: () => { loaded: boolean; matchesPlist: boolean };
309
+ } = {},
310
+ ): Promise<string> {
311
+ const diag = (deps.diagnose ?? diagnoseService)();
312
+ if (!diag.installed) return `❌ ${diag.summary}`;
313
+
314
+ const serving = await (deps.serving ?? (() => confirmServiceServing({ timeoutMs: 1_500 })))();
315
+ if (serving.ok) return `✅ ${diag.summary}\n Serving on port ${serving.port}.`;
316
+
317
+ // The dep is consulted FIRST; the platform check only guards the default. Wrapping
318
+ // the whole expression in a darwin check would discard an injected seam on
319
+ // Linux/Windows and make the stale-plist case untestable there.
320
+ const stalePlist = deps.matchesPlist?.() ?? (process.platform === "darwin"
321
+ ? (() => {
322
+ // Pass the INSTALLED port explicitly: the default third argument is
323
+ // resolveServiceListenPort(), which reads OCX_BAKE_PORT/config.port, so after
324
+ // a config edit the expected string would never match and every run would
325
+ // print a false "OLDER plist".
326
+ return launchdJobMatchesPlist(expectedLaunchdCommand(installedServiceListenPort()));
327
+ })()
328
+ : null);
329
+ const staleLine = stalePlist && stalePlist.loaded && !stalePlist.matchesPlist
330
+ ? " launchd is running an OLDER plist than the one on disk.\n"
331
+ + ` Fix: launchctl bootout gui/$(id -u)/${LABEL} && ocx service repair\n`
332
+ : "";
333
+
334
+ return `⚠️ ${diag.summary}\n`
335
+ + ` Registered, but no proxy is answering on port ${serving.port}.\n`
336
+ + staleLine
337
+ + ` Log: ${serviceLogPath()}\n`
338
+ + ` Repair: ${serviceRepairCommand()}\n`
339
+ + " Meanwhile: ocx start (serves in the foreground)";
340
+ }
@@ -0,0 +1,303 @@
1
+ import { execSync } from "node:child_process";
2
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { getConfigDir, loadConfig } from "../config";
4
+ import { readServiceApiTokenState, serviceApiTokenFilePath } from "../lib/service-secrets";
5
+ import { tokenCollidesWithAdmin } from "../lib/admin-secrets";
6
+ import { randomBytes } from "node:crypto";
7
+ import { hardenSecretDir, hardenSecretPath } from "../lib/windows-secret-acl";
8
+ import { recordOwnedConfigPath } from "../lib/config-ownership";
9
+ import { isTestHomeGuardArmed } from "../lib/test-home-guard";
10
+ import { diagnoseService } from "./diagnostics";
11
+ import type { ServiceDiagnostic } from "./diagnostics";
12
+ import { currentCodexHome, currentOpenCodexHome, normalizePathForCompare, readServiceInstallState } from "./state";
13
+ import { win32 } from "node:path";
14
+
15
+ /**
16
+ * The service was installed under a different CODEX_HOME/OPENCODEX_HOME, so this process may not
17
+ * touch it. Distinct from "stop failed": the manager was never even contacted, which means the
18
+ * installed service is still live and shared state (native Codex config, the Grok fence) must be
19
+ * left alone — tearing it down would strip config out from under a running service.
20
+ */
21
+ export class ServiceOwnershipError extends Error {
22
+ readonly code = "service-ownership-mismatch" as const;
23
+ }
24
+
25
+ export function isServiceOwnershipError(err: unknown): err is ServiceOwnershipError {
26
+ return err instanceof ServiceOwnershipError;
27
+ }
28
+
29
+ /**
30
+ * True when no installed service exists, or the installed one belongs to THIS
31
+ * CODEX_HOME/OPENCODEX_HOME. Callers use it to decide whether they may tear down shared state
32
+ * (native Codex config, the Grok fence) that a foreign service would still be relying on.
33
+ */
34
+ export function serviceEnvironmentOwnedHere(): boolean {
35
+ try {
36
+ assertServiceEnvironmentMatchesInstall();
37
+ return true;
38
+ } catch (err) {
39
+ if (isServiceOwnershipError(err)) return false;
40
+ return true; // unrelated failure: fall back to the previous behavior rather than wedging
41
+ }
42
+ }
43
+
44
+ export function assertServiceEnvironmentMatchesInstall(): void {
45
+ const state = readServiceInstallState();
46
+ if (!state) return;
47
+ const actualCodexHome = currentCodexHome();
48
+ const expected = normalizePathForCompare(state.codexHome);
49
+ const actual = normalizePathForCompare(actualCodexHome);
50
+ if (expected !== actual) {
51
+ throw new ServiceOwnershipError(
52
+ `Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${actualCodexHome}. ` +
53
+ "Run the service command from the same Codex home so native Codex restore updates the correct config.",
54
+ );
55
+ }
56
+ const expectedOpenCodexHome = normalizePathForCompare(state.opencodexHome);
57
+ const actualOpenCodexHome = normalizePathForCompare(currentOpenCodexHome());
58
+ if (expectedOpenCodexHome !== actualOpenCodexHome) {
59
+ throw new ServiceOwnershipError(
60
+ `Service was installed with OPENCODEX_HOME=${state.opencodexHome}, but current OPENCODEX_HOME=${currentOpenCodexHome()}. ` +
61
+ "Run the service command from the same OpenCodex home so service state and secrets match.",
62
+ );
63
+ }
64
+ }
65
+
66
+ function isLoopbackHostname(hostname: string | undefined): boolean {
67
+ const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase();
68
+ return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
69
+ }
70
+
71
+ /**
72
+ * The `ocx` command a user should rerun for the service state they actually have.
73
+ *
74
+ * `installed` alone is not enough: `repairService()` refuses a Task-Scheduler-plus-WinSW
75
+ * conflict outright, so recommending repair there names a command guaranteed to fail.
76
+ * Install IS the valid conflict recovery, because `installWindows` removes the native
77
+ * backend first. Exported so the guard tests the real selector rather than a copy of it.
78
+ */
79
+ export function serviceRetryCommand(
80
+ diag: Pick<ServiceDiagnostic, "installed" | "conflict"> = diagnoseService(),
81
+ ): string {
82
+ return diag.installed && !diag.conflict ? "ocx service repair" : "ocx service install";
83
+ }
84
+
85
+ /**
86
+ * Refuse a management (admin) token as the data-plane secret.
87
+ *
88
+ * The service exports the contents of the service token file as
89
+ * `OPENCODEX_API_AUTH_TOKEN` before starting the proxy. When that value is the admin
90
+ * token, the server treats the management credential as a data-plane admission secret
91
+ * and fails the ENTIRE management plane closed at boot, so every `/api/*` request
92
+ * returns 503 — even on a loopback install that never needed a data-plane secret.
93
+ * Exporting the admin token in the CLI cannot recover it, because the fence is decided
94
+ * server-side at startup (#2696).
95
+ *
96
+ * Nothing in this codebase puts an admin token in that env var; it arrives from the
97
+ * installing shell. This function is the chokepoint that should refuse it rather than
98
+ * writing a file that produces a broken service. Comparison is the same helper doctor
99
+ * uses: minted `ocx_admin_…` prefix, or byte-equal to configuredAdminToken (env or file).
100
+ *
101
+ * `source` selects the remedy, not the rule. The token can also arrive from an EXISTING
102
+ * `service-api-token` that install/repair reuses, and there `unset` is meaningless advice —
103
+ * the fix is to delete the file so a data-plane token is generated.
104
+ */
105
+ export function assertNotAdminToken(
106
+ token: string,
107
+ env: NodeJS.ProcessEnv = process.env,
108
+ source: "env" | "file" = "env",
109
+ ): void {
110
+ if (!tokenCollidesWithAdmin(token, env)) return;
111
+ if (source === "file") {
112
+ // The file branch of `writeServiceApiTokenFile` used to skip this check entirely, so a
113
+ // hand-pasted admin token already on disk (pre-#2696, or the exact #4236 incident) was
114
+ // silently reused: `ocx status` said `present (file)` and the hub crash-looped at boot.
115
+ // The remedy is NOT `unset` -- there is nothing in the environment to unset.
116
+ throw new Error(
117
+ `${serviceApiTokenFilePath()} holds a management (admin) token, not a data-plane token. `
118
+ + "The service exports that file as the data-plane secret, which fences the whole management "
119
+ + "API closed and makes every ocx management command fail with 503, so the hub crash-loops at "
120
+ + `boot. Delete the file (rm ${serviceApiTokenFilePath()}), then rerun \`ocx service repair\` `
121
+ + "(or `ocx service install` when the service is not installed yet): a fresh owner-only "
122
+ + "data-plane token is generated and nothing needs to be exported by hand.",
123
+ );
124
+ }
125
+ throw new Error(
126
+ "OPENCODEX_API_AUTH_TOKEN holds a management (admin) token. The service exports it "
127
+ + "as the data-plane secret, which fences the whole management API closed and makes "
128
+ + "every ocx management command fail with 503. Run `unset OPENCODEX_API_AUTH_TOKEN` "
129
+ + "and rerun: nothing needs to be exported by hand, because the service provisions "
130
+ + `its own owner-only data-plane token at ${serviceApiTokenFilePath()}.`,
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Preflight for `service install` / `service repair` on the data-plane credential.
136
+ *
137
+ * It used to DEMAND `OPENCODEX_API_AUTH_TOKEN` for a non-loopback hostname, and it threw
138
+ * even when `~/.opencodex/service-api-token` already held a perfectly good token. That is
139
+ * the defect behind the incident this unit exists to close (#4236): an operator exported the
140
+ * ADMIN token as OPENCODEX_API_AUTH_TOKEN because `install` asked for a token, the hub then
141
+ * crash-looped on `assertNotAdminToken`, and `service repair` asked for the same env var
142
+ * again — so the only remembered way to make the command proceed was the thing that broke it.
143
+ *
144
+ * Nobody should have to export a token by hand to run a hub. {@link writeServiceApiTokenFile}
145
+ * provisions one, so the only conditions left that install cannot fix are an admin-token
146
+ * collision in the environment and a token file that exists but cannot be used.
147
+ */
148
+ export function assertServiceAuthEnvironment(): void {
149
+ const config = loadConfig();
150
+ // Both collision checks come BEFORE the loopback short-circuit, because the launch wrapper
151
+ // exports the token file unconditionally (`buildServiceShellCommand` cats it whenever it
152
+ // exists, whatever the hostname): a management token in either source fences the whole
153
+ // management plane closed at boot, even on a loopback install that needs no admission
154
+ // secret. Returning early is what let that broken state through.
155
+ const present = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
156
+ if (present) assertNotAdminToken(present);
157
+ const state = readServiceApiTokenState();
158
+ // An existing FILE holding the admin token is the incident shape itself, and the first round
159
+ // only checked the env var — so install/repair reused it and the hub crash-looped at boot.
160
+ // On a machine connected to a hub this same file holds that hub's issued client key, which
161
+ // is never a management token, so the check is a no-op there.
162
+ if (state.kind === "present") assertNotAdminToken(state.token, process.env, "file");
163
+ if (isLoopbackHostname(config.hostname)) return;
164
+ if (present) return;
165
+ // Absent is fine — install/repair generates one below. `unsafe` is not: the writer refuses
166
+ // to replace a path it cannot vouch for, so say so here, where the operator can still act,
167
+ // instead of failing mid-install. Reached from `service repair` as well as `install`, so
168
+ // name a command that can actually succeed (see serviceRetryCommand).
169
+ if (state.kind !== "unsafe") return;
170
+ const diag = diagnoseService();
171
+ throw new Error(
172
+ `The data-plane token file cannot be used (${state.reason}): ${serviceApiTokenFilePath()}. `
173
+ + `Move it aside, then rerun \`${serviceRetryCommand(diag)}\`; the service provisions a `
174
+ + "fresh owner-only token and needs nothing from the environment.",
175
+ );
176
+ }
177
+
178
+ /** How the data-plane token the service will export was obtained. */
179
+ export type ServiceApiTokenOrigin = "env" | "file" | "generated";
180
+
181
+ export interface ProvisionedServiceApiToken {
182
+ path: string;
183
+ origin: ServiceApiTokenOrigin;
184
+ }
185
+
186
+ function persistServiceApiToken(token: string): string {
187
+ const path = serviceApiTokenFilePath();
188
+ const dir = getConfigDir();
189
+ recordOwnedConfigPath(dir, path);
190
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
191
+ if (process.platform === "win32") hardenSecretDir(dir, { required: true });
192
+ writeFileSync(path, `${token}\n`, { encoding: "utf8", mode: 0o600 });
193
+ try { chmodSync(path, 0o600); } catch { /* best-effort */ }
194
+ if (process.platform === "win32") hardenSecretPath(path, { required: true });
195
+ return path;
196
+ }
197
+
198
+ /**
199
+ * Put a usable data-plane token on disk for the service to read at launch, and say where.
200
+ *
201
+ * EVERY backend funnels through here — launchd, systemd, the Windows scheduler wrapper and
202
+ * WinSW native — because the launch wrapper's only source of the secret is this file
203
+ * (`buildServiceShellCommand` cats it into the environment; WinSW reads it through
204
+ * `OCX_API_TOKEN_FILE`). One chokepoint is also what makes the admin-token refusal
205
+ * unskippable (#2696).
206
+ *
207
+ * Precedence, in order:
208
+ * 1. `OPENCODEX_API_AUTH_TOKEN` from the installing shell — still refused outright when it is
209
+ * an admin token. An operator who deliberately exports a key keeps full control of it.
210
+ * 2. An existing owner-only `service-api-token`. Reusing it is what makes `repair`, a
211
+ * reinstall and a restart idempotent; regenerating would silently invalidate every client
212
+ * key-exchange already performed against the old value.
213
+ * 3. 32 fresh random bytes, hex. This is the branch that removes the manual step: a hub
214
+ * install on a non-loopback hostname provisions its own secret.
215
+ *
216
+ * A loopback install with no env token gets nothing: admission is not required there, so
217
+ * creating a credential would be inventing a secret nobody asked for — and on a machine
218
+ * connected to a hub the same file holds that hub's issued client key, which must not be
219
+ * overwritten by a local install.
220
+ *
221
+ * The PATH is logged; the value never is, and never reaches argv, a unit file or a plist.
222
+ */
223
+ export function writeServiceApiTokenFile(): ProvisionedServiceApiToken | null {
224
+ const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
225
+ if (token) {
226
+ // Last line of defence: every install/repair path funnels through here, so a
227
+ // collision cannot reach disk regardless of which caller ran (#2696).
228
+ assertNotAdminToken(token);
229
+ const path = persistServiceApiToken(token);
230
+ console.log(`🔐 Data-plane token taken from OPENCODEX_API_AUTH_TOKEN and stored at ${path} (owner-only).`);
231
+ return { path, origin: "env" };
232
+ }
233
+ if (isLoopbackHostname(loadConfig().hostname)) return null;
234
+ const existing = readServiceApiTokenState();
235
+ if (existing.kind === "present") {
236
+ // The collision check is NOT only for the env branch. A file that already holds the admin
237
+ // token -- hand-pasted before #2696, or written by the very incident this unit closes --
238
+ // was silently accepted here, so `ocx status` reported `present (file)` and the hub
239
+ // crash-looped at boot with no command pointing at the cause.
240
+ const path = serviceApiTokenFilePath();
241
+ assertNotAdminToken(existing.token, process.env, "file");
242
+ // `readServiceApiTokenState` accepts any bounded regular file, so a reused token may well
243
+ // be group- or world-readable. Tighten it on the way through rather than claiming
244
+ // "owner-only" about a mode nobody checked; best-effort, since a non-owner cannot chmod
245
+ // and failing the install over it would be worse than the loose mode.
246
+ try { chmodSync(path, 0o600); } catch { /* best-effort */ }
247
+ if (process.platform === "win32") hardenSecretPath(path, { required: false });
248
+ // No log line: repair/restart hit this on every run and an unconditional notice about a
249
+ // credential file trains operators to ignore the one that matters.
250
+ return { path, origin: "file" };
251
+ }
252
+ if (existing.kind === "unsafe") throw new Error(`${existing.reason}: ${serviceApiTokenFilePath()}`);
253
+ const path = persistServiceApiToken(randomBytes(32).toString("hex"));
254
+ console.log(`🔐 Provisioned an owner-only data-plane token at ${path}; nothing needs to be exported by hand.`);
255
+ console.log(" Remote machines get their own per-client key — run 'ocx hub invite' instead of copying this file.");
256
+ return { path, origin: "generated" };
257
+ }
258
+
259
+ export function sh(cmd: string): string {
260
+ assertLiveServiceManagerAllowed(cmd);
261
+ return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
262
+ }
263
+
264
+ /**
265
+ * Service-manager invocations that only observe. Everything else changes a job that
266
+ * launchd or the systemd user manager is running right now.
267
+ */
268
+ const READ_ONLY_SERVICE_MANAGER = new RegExp(
269
+ "^(?:launchctl\\s+(?:list|print|print-disabled|blame|managerpid|manageruid)\\b"
270
+ + "|systemctl\\s+(?:--user\\s+)?(?:show|show-environment|status|is-active|is-enabled|is-failed|cat|list-units|list-unit-files|--version)\\b)",
271
+ );
272
+
273
+ const SERVICE_MANAGER_COMMAND = /^(?:launchctl|systemctl)\b/;
274
+
275
+ /**
276
+ * Refuse to mutate a live service manager from an armed test process.
277
+ *
278
+ * The test preload isolates HOME, OPENCODEX_HOME and CODEX_HOME, and that is enough for
279
+ * anything addressed by path. It is not enough here. `systemctl --user stop
280
+ * opencodex-proxy.service` is addressed by job NAME and talks to the user manager that is
281
+ * already running, so it stops the proxy the developer is actually using no matter what
282
+ * HOME says. `launchctl bootout gui/<uid>/com.opencodex.proxy` has the same shape.
283
+ *
284
+ * Windows already had this guard: `querySchtasks` refuses every non-query call while the
285
+ * test-home guard is armed, after a partially-faked test replaced a real scheduled task
286
+ * with a launcher inside a temporary test home. macOS and Linux were left without the
287
+ * equivalent, which means the person most likely to run this suite - someone running
288
+ * opencodex on the machine they are developing it on - is the person it can disrupt.
289
+ *
290
+ * Read-only verbs stay allowed: probing what the manager reports is the whole point of
291
+ * the diagnostics, and observation cannot take a service down.
292
+ */
293
+ export function assertLiveServiceManagerAllowed(command: string): void {
294
+ if (!isTestHomeGuardArmed()) return;
295
+ const trimmed = command.trim();
296
+ if (!SERVICE_MANAGER_COMMAND.test(trimmed)) return;
297
+ if (READ_ONLY_SERVICE_MANAGER.test(trimmed)) return;
298
+ throw new Error(
299
+ `refusing to run \`${trimmed}\` from an armed test process: launchd and the systemd user `
300
+ + "manager address a job by name, not by HOME, so this reaches the service the developer is "
301
+ + "actually running. Inject the service operation instead of calling the live manager.",
302
+ );
303
+ }