@bitkyc08/opencodex 2.60.0 → 2.61.0-preview.20260922

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 (249) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +5 -1
  13. package/src/adapters/anthropic.ts +16 -0
  14. package/src/adapters/coding-agent/protocol.ts +36 -6
  15. package/src/adapters/coding-agent/turn.ts +10 -2
  16. package/src/adapters/command-code.ts +2 -1
  17. package/src/adapters/cursor/catalog.ts +51 -7
  18. package/src/adapters/cursor/protobuf-request.ts +6 -3
  19. package/src/adapters/cursor/request-builder.ts +13 -3
  20. package/src/adapters/cursor.ts +11 -2
  21. package/src/adapters/declaration-carrier.ts +45 -0
  22. package/src/adapters/devin.ts +75 -23
  23. package/src/adapters/google-antigravity-wire.ts +5 -2
  24. package/src/adapters/google-errors.ts +7 -1
  25. package/src/adapters/google.ts +29 -5
  26. package/src/adapters/image.ts +4 -1
  27. package/src/adapters/input-media-guard.ts +21 -9
  28. package/src/adapters/kiro/usage.ts +3 -2
  29. package/src/adapters/kiro-tool-fallback.ts +1 -1
  30. package/src/adapters/ollama-native.ts +6 -0
  31. package/src/adapters/openai-chat/developer-role.ts +61 -0
  32. package/src/adapters/openai-chat/messages.ts +46 -27
  33. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  34. package/src/adapters/openai-chat/passthrough.ts +33 -9
  35. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  36. package/src/adapters/openai-chat.ts +18 -57
  37. package/src/adapters/openai-responses/passthrough.ts +2 -0
  38. package/src/adapters/registry.ts +3 -2
  39. package/src/adapters/run-turn-queue.ts +178 -29
  40. package/src/adapters/xai-web-search.ts +16 -1
  41. package/src/bridge/errors.ts +8 -2
  42. package/src/bridge/response-json.ts +9 -1
  43. package/src/bridge/sse.ts +10 -0
  44. package/src/chat/inbound.ts +141 -5
  45. package/src/claude/desktop-3p.ts +7 -1
  46. package/src/claude/desktop-first-party.ts +183 -0
  47. package/src/claude/desktop-gateway-state.ts +41 -0
  48. package/src/claude/inbound-content-options.ts +6 -0
  49. package/src/claude/inbound.ts +32 -6
  50. package/src/claude/intercept/connect-proxy.ts +179 -0
  51. package/src/claude/intercept/listener.ts +122 -0
  52. package/src/claude/intercept/local-ca.ts +298 -0
  53. package/src/claude/intercept/runtime.ts +98 -0
  54. package/src/claude/intercept/settings.ts +189 -0
  55. package/src/cli/access.ts +87 -0
  56. package/src/cli/account-auth.ts +19 -0
  57. package/src/cli/capabilities.ts +31 -0
  58. package/src/cli/claude-desktop.ts +206 -16
  59. package/src/cli/codex-shim-autorestore.ts +3 -0
  60. package/src/cli/companion.ts +56 -0
  61. package/src/cli/dispatch.ts +43 -4
  62. package/src/cli/ensure-desired-integrations.ts +43 -5
  63. package/src/cli/help.ts +7 -9
  64. package/src/cli/index.ts +200 -61
  65. package/src/cli/init.ts +8 -0
  66. package/src/cli/integrations.ts +7 -1
  67. package/src/cli/registry.ts +41 -2
  68. package/src/cli/resolve.ts +230 -0
  69. package/src/cli/root.ts +24 -1
  70. package/src/cli/start-ownership-publication.ts +56 -0
  71. package/src/cli/status-probes.ts +2 -18
  72. package/src/cli/status.ts +62 -0
  73. package/src/cli/stop-report.ts +143 -0
  74. package/src/cli/uninstall-plan.ts +9 -0
  75. package/src/client/machine-listener.ts +2 -5
  76. package/src/clients/aside-profiles.ts +4 -0
  77. package/src/clients/config-export/zcode-store.ts +157 -0
  78. package/src/clients/config-export.ts +36 -0
  79. package/src/codex/app-server-processes.ts +72 -40
  80. package/src/codex/auth-api/login-flow.ts +6 -1
  81. package/src/codex/autostart-health.ts +28 -0
  82. package/src/codex/catalog/build-entries.ts +2 -2
  83. package/src/codex/catalog/effort.ts +3 -3
  84. package/src/codex/catalog/provider-models.ts +24 -15
  85. package/src/codex/catalog/retained-sync.ts +2 -2
  86. package/src/codex/convergence.ts +2 -2
  87. package/src/codex/history-provider.ts +12 -1
  88. package/src/codex/inject/config-toml.ts +41 -6
  89. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  90. package/src/codex/inject.ts +18 -15
  91. package/src/codex/injected-marker.ts +18 -0
  92. package/src/codex/main-account.ts +6 -0
  93. package/src/codex/model-cache.ts +52 -6
  94. package/src/codex/model-entitlement-admission.ts +59 -0
  95. package/src/codex/model-entitlements.ts +87 -44
  96. package/src/codex/native-main-admission.ts +83 -0
  97. package/src/codex/routing/health-store.ts +39 -0
  98. package/src/codex/routing/selection.ts +37 -1
  99. package/src/codex/routing.ts +5 -41
  100. package/src/codex/shim-templates.ts +29 -3
  101. package/src/companion/settings.ts +132 -0
  102. package/src/config/atomic-write.ts +117 -5
  103. package/src/config/load-degrade.ts +34 -7
  104. package/src/config/process-state.ts +1 -1
  105. package/src/config/schema/config-schema.ts +27 -1
  106. package/src/config/schema/leaf-validators.ts +47 -0
  107. package/src/config.ts +1 -1
  108. package/src/generated/compatibility-version.json +418 -174
  109. package/src/integrations/config-io.ts +44 -10
  110. package/src/integrations/merge.ts +120 -13
  111. package/src/integrations/mutation-plan.ts +124 -18
  112. package/src/integrations/registry.ts +38 -0
  113. package/src/integrations/state.ts +78 -45
  114. package/src/integrations/target.ts +208 -0
  115. package/src/integrations/writer.ts +49 -11
  116. package/src/lab/conformance/fixture-provider.ts +5 -0
  117. package/src/lib/browser-launch-notice.ts +59 -0
  118. package/src/lib/bun-runtime.ts +6 -2
  119. package/src/lib/debug.ts +40 -0
  120. package/src/lib/open-url.ts +51 -7
  121. package/src/lib/package-tree-integrity.ts +2 -1
  122. package/src/lib/package-version.ts +8 -0
  123. package/src/lib/provider-egress.ts +310 -0
  124. package/src/lib/provider-outbound.ts +59 -14
  125. package/src/lib/proxy-env.ts +82 -7
  126. package/src/lib/request-execution-budget.ts +72 -0
  127. package/src/lib/request-failure-attribution.ts +183 -0
  128. package/src/lib/request-failure-model.ts +236 -0
  129. package/src/lib/request-resend-gate.ts +138 -0
  130. package/src/lib/standalone.ts +16 -0
  131. package/src/lib/upstream-retry.ts +167 -16
  132. package/src/lib/winsw.ts +2 -2
  133. package/src/oauth/index.ts +24 -1
  134. package/src/oauth/login-cli.ts +80 -29
  135. package/src/providers/api-key-resolve.ts +133 -0
  136. package/src/providers/api-key-selection.ts +5 -1
  137. package/src/providers/key-failover.ts +31 -1
  138. package/src/providers/key-store.ts +34 -110
  139. package/src/providers/model-rename-fields.ts +147 -0
  140. package/src/providers/model-rename-migration.ts +124 -37
  141. package/src/providers/quota/vendor-probes-key.ts +37 -22
  142. package/src/providers/reasoning-metadata.ts +43 -18
  143. package/src/providers/registry/entries-core.ts +9 -4
  144. package/src/providers/registry/entries-extended.ts +29 -4
  145. package/src/providers/registry/model-seeds.ts +47 -10
  146. package/src/providers/xai-transport.ts +12 -1
  147. package/src/reasoning-effort.ts +8 -0
  148. package/src/responses/function-call-compat.ts +38 -1
  149. package/src/responses/inline-document.ts +65 -0
  150. package/src/responses/input-media.ts +42 -8
  151. package/src/responses/muse-tool-name-alias.ts +19 -0
  152. package/src/responses/parser-content.ts +8 -2
  153. package/src/responses/parser-tools.ts +3 -0
  154. package/src/responses/parser.ts +3 -1
  155. package/src/responses/schema.ts +3 -0
  156. package/src/router.ts +17 -2
  157. package/src/server/admission-model-scope.ts +219 -0
  158. package/src/server/audio-live.ts +9 -3
  159. package/src/server/audio-upstream.ts +18 -0
  160. package/src/server/auth-cors.ts +26 -0
  161. package/src/server/chat-completions.ts +55 -2
  162. package/src/server/chat-native.ts +19 -4
  163. package/src/server/claude-messages.ts +55 -17
  164. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  165. package/src/server/gui-freshness.ts +103 -0
  166. package/src/server/gui-static.ts +7 -9
  167. package/src/server/images.ts +59 -6
  168. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  169. package/src/server/index/serve-options.ts +56 -10
  170. package/src/server/index/spend-ledger-lifecycle.ts +34 -8
  171. package/src/server/index/startup-warnings.ts +24 -0
  172. package/src/server/index.ts +21 -28
  173. package/src/server/lifecycle.ts +4 -4
  174. package/src/server/live-call-bindings.ts +6 -0
  175. package/src/server/live.ts +88 -3
  176. package/src/server/management/agent-settings-routes.ts +121 -36
  177. package/src/server/management/companion-routes.ts +77 -0
  178. package/src/server/management/logs-usage-routes.ts +19 -0
  179. package/src/server/management/native-integration-routes.ts +103 -6
  180. package/src/server/management/oauth-account-routes.ts +45 -7
  181. package/src/server/management/route-registry.ts +6 -0
  182. package/src/server/management/shared.ts +18 -1
  183. package/src/server/management/usage-timeline-routes.ts +44 -0
  184. package/src/server/management-api.ts +8 -9
  185. package/src/server/proxy-liveness.ts +75 -0
  186. package/src/server/relay.ts +19 -2
  187. package/src/server/request-log-failure-attribution.ts +99 -0
  188. package/src/server/request-log.ts +114 -0
  189. package/src/server/request-metrics.ts +92 -30
  190. package/src/server/responses/codex-ws-wire.ts +34 -8
  191. package/src/server/responses/combo-stream-preflight.ts +168 -6
  192. package/src/server/responses/compact.ts +11 -0
  193. package/src/server/responses/core-opaque-recovery.ts +90 -0
  194. package/src/server/responses/fetch-helpers.ts +124 -8
  195. package/src/server/responses/input-admission.ts +10 -0
  196. package/src/server/responses/passthrough-delivery.ts +14 -1
  197. package/src/server/responses/passthrough-dispatch.ts +179 -35
  198. package/src/server/responses/passthrough-error.ts +27 -8
  199. package/src/server/responses/request-prepare.ts +42 -1
  200. package/src/server/responses/request-send-budget.ts +12 -0
  201. package/src/server/responses/request-transport.ts +24 -4
  202. package/src/server/responses/reset-replay.ts +108 -0
  203. package/src/server/responses-request-tool-scope.ts +214 -0
  204. package/src/server/responses-undeclared-tool-guard.ts +4 -1
  205. package/src/server/search.ts +25 -1
  206. package/src/server/usage-ledger-retention.ts +73 -0
  207. package/src/service/cli.ts +48 -2
  208. package/src/service/health.ts +3 -2
  209. package/src/service/install-state-contract.d.mts +27 -0
  210. package/src/service/install-state-contract.mjs +34 -0
  211. package/src/service/launchd.ts +1 -1
  212. package/src/service/orchestration.ts +2 -4
  213. package/src/service/ownership-compatibility.ts +164 -0
  214. package/src/service/ownership-mutation-lease.d.mts +32 -0
  215. package/src/service/ownership-mutation-lease.mjs +211 -0
  216. package/src/service/repair.ts +45 -1
  217. package/src/service/state-lock.ts +269 -0
  218. package/src/service/state-record.d.mts +36 -0
  219. package/src/service/state-record.mjs +138 -0
  220. package/src/service/state.ts +582 -68
  221. package/src/service/windows-taskxml.ts +11 -10
  222. package/src/service.ts +7 -3
  223. package/src/tray/windows-tray.ps1 +1 -1
  224. package/src/types/config.ts +37 -0
  225. package/src/types/provider.ts +73 -0
  226. package/src/types/request.ts +28 -2
  227. package/src/types/tools.ts +19 -0
  228. package/src/types.ts +3 -0
  229. package/src/update/index.ts +207 -63
  230. package/src/update/job.ts +9 -5
  231. package/src/update/ownership-transaction.ts +47 -0
  232. package/src/update/restart-ownership.ts +54 -0
  233. package/src/update/runtime-ownership.d.mts +40 -0
  234. package/src/update/runtime-ownership.mjs +122 -0
  235. package/src/usage/attempt-delivery.ts +198 -0
  236. package/src/usage/cache-diagnostic.ts +305 -0
  237. package/src/usage/failure-fingerprint.ts +118 -0
  238. package/src/usage/failure-projection-cache.ts +174 -0
  239. package/src/usage/failure-projection.ts +174 -0
  240. package/src/usage/ledger-retention.ts +165 -0
  241. package/src/usage/log.ts +126 -79
  242. package/src/usage/request-outcome.ts +150 -0
  243. package/src/usage/retention-contract.ts +28 -0
  244. package/src/usage/summary.ts +2 -2
  245. package/src/usage/telemetry-contract.ts +237 -0
  246. package/src/usage/timeline.ts +236 -0
  247. package/src/web-search/alpha-search.ts +21 -1
  248. package/gui/dist/assets/index-BTuCbqQd.css +0 -1
  249. package/gui/dist/assets/index-DoBVdPHP.js +0 -134
@@ -1,14 +1,33 @@
1
- import { accessSync, chmodSync, constants as fsConstants, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
1
+ import { accessSync, constants as fsConstants, existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { delimiter, dirname, isAbsolute, join, posix, resolve, win32 } from "node:path";
4
4
  import { expandUserPath, getConfigDir } from "../config";
5
+ import { atomicWriteFileStreamed } from "../config/atomic-write";
5
6
  import { resolveCodexHomeDir, type CodexHomeDeps } from "../codex/home";
6
7
  import { resolveCodexSqliteHome } from "../codex/paths";
7
8
  import { durableBunRuntime, type BunRuntimeSource, type DurableBunRuntime } from "../lib/bun-runtime";
8
9
  import { WINSW_SHA256, WINSW_VERSION } from "../lib/winsw";
9
- import { hardenSecretPath } from "../lib/windows-secret-acl";
10
- import { recordOwnedConfigPath } from "../lib/config-ownership";
11
10
  import { isProtectedHomeUnderTest, isTestHomeGuardArmed } from "../lib/test-home-guard";
11
+ import { isStandaloneBinary } from "../lib/standalone";
12
+ import {
13
+ inspectInstallStateBytes,
14
+ parseInstallStateRecord,
15
+ parseOwnershipClaim,
16
+ SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
17
+ SERVICE_OWNERSHIP_PROTOCOL_VERSION,
18
+ selectAuthoritativeServiceState,
19
+ serviceStateFingerprint,
20
+ serviceStateFilesFor,
21
+ } from "./install-state-contract.mjs";
22
+ import type { ServiceStateRecordEvidence } from "./state-record.mjs";
23
+ import { assertServiceStateLocksOwned, withServiceStateLocks, type ServiceStateLockHooks } from "./state-lock";
24
+ import { withOwnershipMutationLease, type OwnershipMutationLeaseOptions } from "./ownership-mutation-lease.mjs";
25
+ import {
26
+ assessServiceTakeoverCompatibility,
27
+ sameServiceTakeoverCompatibility,
28
+ type ManagingCliObservation,
29
+ type ServiceTakeoverCompatibility,
30
+ } from "./ownership-compatibility";
12
31
 
13
32
  /**
14
33
  * Written only by the launchd plist and the systemd unit. `OCX_SERVICE=1` cannot stand in
@@ -19,6 +38,7 @@ export const SERVICE_MANAGED_ENV = "OCX_SERVICE_MANAGED";
19
38
 
20
39
  export const LABEL = "com.opencodex.proxy";
21
40
  export const TASK = "opencodex-proxy";
41
+ export { SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION, SERVICE_OWNERSHIP_PROTOCOL_VERSION };
22
42
 
23
43
  // This module lives one level below the original src/service.ts, so path-relative
24
44
  // lookups anchored at that file's directory go through this constant instead.
@@ -26,14 +46,18 @@ export const serviceSourceDir = dirname(import.meta.dir);
26
46
 
27
47
  export type ServiceBackend = "scheduler" | "native";
28
48
 
29
- export function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } {
49
+ export function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string | null } {
30
50
  // Bake the bundled Bun (manager-owned global package directory, survives `ocx update`) rather than
31
51
  // a transient system Bun, so launchd/systemd/schtasks keep resolving even if a
32
52
  // standalone Bun is later removed. The CLI entry lives at src/cli/index.ts.
33
53
  //
34
54
  // Path and provenance come from ONE resolution so the marker can never describe a
35
55
  // different binary than the one actually baked.
36
- return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(serviceSourceDir, "cli", "index.ts") };
56
+ return {
57
+ bun: runtime.path,
58
+ bunRuntimeSource: runtime.source,
59
+ cli: runtime.source === "standalone" || isStandaloneBinary() ? null : join(serviceSourceDir, "cli", "index.ts"),
60
+ };
37
61
  }
38
62
 
39
63
  /**
@@ -130,10 +154,7 @@ function defaultOpenCodexHome(): string {
130
154
  }
131
155
 
132
156
  export function serviceStatePathsForOpenCodexHome(opencodexHome: string): string[] {
133
- const paths = [join(opencodexHome, "service-state.json")];
134
- const defaultPath = join(defaultOpenCodexHome(), "service-state.json");
135
- if (normalizePathForCompare(defaultPath) !== normalizePathForCompare(paths[0])) paths.push(defaultPath);
136
- return paths;
157
+ return serviceStateFilesFor(opencodexHome, defaultOpenCodexHome());
137
158
  }
138
159
 
139
160
  export function serviceStatePaths(): string[] {
@@ -223,7 +244,7 @@ export interface ServiceInstallState {
223
244
  codexSqliteHome?: string;
224
245
  /** Baked at install; lets status flag paths gone stale after npm prefix/nvm moves. */
225
246
  bunPath?: string;
226
- cliPath?: string;
247
+ cliPath?: string | null;
227
248
  /**
228
249
  * launchd and systemd. The stable `ocx` launcher the service definition actually invokes,
229
250
  * when one was found. Present means `bunPath`/`cliPath` are provenance for the install,
@@ -236,59 +257,573 @@ export interface ServiceInstallState {
236
257
  backend?: ServiceBackend;
237
258
  winswVersion?: string;
238
259
  winswSha256?: string;
260
+ /**
261
+ * Bumped by every write through {@link swapServiceInstallState}; the compare-and-swap
262
+ * token. Absent means a record written before this field existed, which compares equal
263
+ * to 0 so the first swap over it still lands.
264
+ */
265
+ revision?: number;
266
+ /** Who owns the running proxy. Absent means the CLI install that registered the service. */
267
+ ownership?: ServiceOwnership;
268
+ /**
269
+ * The highest consent generation this record has ever carried, kept across a release.
270
+ *
271
+ * Without it the counter is an ABA token: granting, releasing and granting again produces
272
+ * generation 1 twice, and an app-local record holding the first 1 would read the second
273
+ * one as its own prior consent.
274
+ */
275
+ consentGenerationCeiling?: number;
276
+ /** Written only by CLIs whose start/repair/update paths honor a desktop claim. */
277
+ ownershipProtocolVersion?: number;
278
+ }
279
+
280
+ /**
281
+ * The two kinds of installation that can own the proxy.
282
+ *
283
+ * `cli` is the npm (or standalone) `ocx` install that registered the background service.
284
+ * `desktop` is the packaged app, which brings its own bundled runtime.
285
+ */
286
+ export type ServiceOwner = "cli" | "desktop";
287
+
288
+ /**
289
+ * Durable ownership, recorded in the shared service install state.
290
+ *
291
+ * Ownership used to be a boolean the desktop shell recomputed at every launch from whether
292
+ * it happened to spawn a child, so a restart silently demoted the app back to guest and
293
+ * "ask once, then own permanently" could not be expressed at all. This record is the thing
294
+ * that survives the restart.
295
+ *
296
+ * ABSENT IS NOT UNOWNED. Every installation that predates this field has no record, and the
297
+ * npm service registration is what owns the runtime there, so absence has to keep meaning
298
+ * exactly that.
299
+ */
300
+ export interface ServiceOwnership {
301
+ readonly owner: ServiceOwner;
302
+ /**
303
+ * Opaque identity of the owning INSTALLATION — not of the user, the machine or the
304
+ * account. The desktop app keeps the same value in its own app-local store, and comparing
305
+ * the two through {@link ownershipGrantedTo} is how a reinstalled app tells its own prior
306
+ * consent from another installation's.
307
+ */
308
+ readonly installId: string;
309
+ /**
310
+ * Increments once per ownership grant. Re-recording the same owner and install id leaves
311
+ * it alone, so a relaunch cannot inflate it and "exactly one increment per takeover" is
312
+ * an assertion a test can make.
313
+ */
314
+ readonly consentGeneration: number;
315
+ }
316
+
317
+ /**
318
+ * Validate an ownership claim read off disk.
319
+ *
320
+ * Returns the ORIGINAL object rather than a rebuilt one: a newer writer may carry fields
321
+ * this version does not know about, and rebuilding would drop them on the next preserve —
322
+ * which is the same lost-field failure this whole record exists to stop.
323
+ */
324
+ export function parseServiceOwnership(value: unknown): ServiceOwnership | null {
325
+ return parseOwnershipClaim(value) as ServiceOwnership | null;
239
326
  }
240
327
 
328
+ /**
329
+ * The record contract lives in `install-state-contract.mjs` so the Node launcher validates
330
+ * exactly what this reader validates. It used to keep a weaker copy, and a record that fails
331
+ * this contract while merely lacking an `ownership` field read there as "nobody owns the
332
+ * runtime" — which is permission to stop a foreign runtime and reactivate the npm service.
333
+ */
241
334
  export function parseServiceInstallState(value: unknown): ServiceInstallState | null {
242
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
243
- const state = value as Record<string, unknown>;
244
- if (state.version !== 1 && state.version !== 2) return null;
245
- if (typeof state.codexHome !== "string" || state.codexHome.length === 0) return null;
246
- if (typeof state.opencodexHome !== "string" || state.opencodexHome.length === 0) return null;
247
- for (const key of ["codexSqliteHome", "bunPath", "cliPath", "launcherPath", "winswVersion", "winswSha256"] as const) {
248
- if (state[key] !== undefined && (typeof state[key] !== "string" || state[key].length === 0)) return null;
249
- }
250
- if (state.version === 1) {
251
- if (state.backend !== undefined) return null;
252
- } else if (state.backend !== "scheduler" && state.backend !== "native") {
253
- return null;
254
- }
255
- return state as unknown as ServiceInstallState;
335
+ return parseInstallStateRecord(value) as ServiceInstallState | null;
256
336
  }
257
337
 
258
- export function writeServiceInstallState(backend: ServiceBackend = "scheduler", launcherPath?: string | null): void {
338
+ /**
339
+ * What an install bakes into the record: the homes, the provenance paths and the backend.
340
+ *
341
+ * Everything here is rebuilt from the CURRENT process on every write, which is the point —
342
+ * it describes the install that just ran. {@link ServiceInstallState.ownership} deliberately
343
+ * is not part of it.
344
+ */
345
+ function installProvenanceRecord(backend: ServiceBackend, launcherPath?: string | null): ServiceInstallState {
259
346
  const { bun, cli } = cliEntry();
260
347
  const codexHome = currentCodexHome();
261
- const state: ServiceInstallState = {
348
+ return {
262
349
  version: 2,
263
350
  codexHome,
264
351
  opencodexHome: currentOpenCodexHome(),
265
352
  codexSqliteHome: resolveCodexSqliteHome({ codexHome }),
266
353
  bunPath: bun,
267
354
  cliPath: cli,
355
+ ownershipProtocolVersion: SERVICE_OWNERSHIP_PROTOCOL_VERSION,
268
356
  ...(launcherPath ? { launcherPath } : {}),
269
357
  backend,
270
358
  ...(backend === "native" ? { winswVersion: WINSW_VERSION, winswSha256: WINSW_SHA256 } : {}),
271
359
  };
272
- for (const path of serviceStateWritePaths()) {
273
- const dir = dirname(path);
274
- recordOwnedConfigPath(getConfigDir(), path);
275
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
276
- writeFileSync(path, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
277
- try { chmodSync(path, 0o600); } catch { /* best-effort */ }
278
- if (process.platform === "win32") hardenSecretPath(path, { required: true });
279
- }
360
+ }
361
+
362
+ /**
363
+ * Record an install, PRESERVING whatever owns the runtime.
364
+ *
365
+ * Every install, repair, update and stop path ends here, and each one used to hand this
366
+ * function a freshly rebuilt record that simply replaced the file. That is why ownership
367
+ * cannot be an ordinary field written by whoever ran last: a repair kicked off by a tray
368
+ * helper, or by `ocx update`, would erase a takeover the user had consented to and hand the
369
+ * runtime back to the npm launcher without saying anything. Preserving it here is what makes
370
+ * the consent durable.
371
+ */
372
+ export function writeServiceInstallState(
373
+ backend: ServiceBackend = "scheduler",
374
+ launcherPath?: string | null,
375
+ deps: ServiceStateSwapDeps = {},
376
+ ): void {
377
+ swapServiceInstallState(current => ({
378
+ ...installProvenanceRecord(backend, launcherPath),
379
+ ...preservedConsent(current),
380
+ }), deps);
381
+ }
382
+
383
+ /** The ownership half of a record: the claim itself plus the generation high-water mark. */
384
+ function preservedConsent(
385
+ current: ServiceInstallState | null,
386
+ ): Pick<ServiceInstallState, "ownership" | "consentGenerationCeiling"> {
387
+ const ownership = current?.ownership;
388
+ const ceiling = Math.max(current?.consentGenerationCeiling ?? 0, ownership?.consentGeneration ?? 0);
389
+ return {
390
+ ...(ownership ? { ownership } : {}),
391
+ ...(ceiling > 0 ? { consentGenerationCeiling: ceiling } : {}),
392
+ };
280
393
  }
281
394
 
282
395
  export function readServiceInstallState(): ServiceInstallState | null {
283
- for (const path of serviceStatePaths()) {
284
- try {
285
- const parsed = parseServiceInstallState(JSON.parse(readFileSync(path, "utf8")));
286
- if (parsed) return parsed;
287
- } catch {
288
- /* try the next known state path */
396
+ const resolved = resolveServiceState();
397
+ return resolved.kind === "state" ? resolved.state : null;
398
+ }
399
+
400
+ /** Raised when a non-cooperating writer prevents a stable authoritative commit. */
401
+ export class ServiceStateConflictError extends Error {
402
+ constructor(readonly path: string, readonly attempts: number) {
403
+ super(
404
+ `service install state at ${path} was rewritten by another process during all ${attempts} `
405
+ + "compare-and-swap attempts; a stable commit could not be verified. Re-run the command.",
406
+ );
407
+ this.name = "ServiceStateConflictError";
408
+ }
409
+ }
410
+
411
+ export interface ServiceStateSwapDeps {
412
+ /** Test seam: which state paths to write. Defaults to every writable state path. */
413
+ paths?: readonly string[];
414
+ /** How many times to re-read and recompute before giving up. */
415
+ attempts?: number;
416
+ /**
417
+ * Test seam: runs immediately before each commit. It is the only place a competing writer
418
+ * can be interleaved deterministically, which is what makes the revision check testable
419
+ * rather than a claim in a comment.
420
+ */
421
+ beforeCommit?: (attempt: number) => void;
422
+ /** How long to wait for another process to release the anchor lock. */
423
+ lockWaitMs?: number;
424
+ /** Deterministic lock seams for failure-order tests. */
425
+ lockHooks?: ServiceStateLockHooks;
426
+ /** Atomic publisher seam. The callback must run immediately before its commit point. */
427
+ commitStateFile?: (path: string, serialized: string, validate: () => void) => void;
428
+ /** A mirror failure occurs after the authority committed and is therefore diagnostic. */
429
+ onMirrorError?: (path: string, error: unknown) => void;
430
+ /** Allows consented mutations to preserve a machine-readable unknown-subject error. */
431
+ unknownStateError?: (reason: string) => Error;
432
+ /** Shared with update/install/start so replacement and ownership mutation cannot overlap. */
433
+ mutationLease?: OwnershipMutationLeaseOptions;
434
+ }
435
+
436
+ export interface ServiceStateMutationContext {
437
+ readonly revision: number;
438
+ }
439
+
440
+ const SERVICE_STATE_SWAP_ATTEMPTS = 5;
441
+
442
+ function authoritativeState(
443
+ paths: readonly string[],
444
+ unknownStateError?: (reason: string) => Error,
445
+ ): { current: ServiceInstallState | null; revision: number; fingerprint: string } {
446
+ const selected = selectAuthoritativeServiceState(
447
+ inspectServiceStateEvidence(paths) as readonly ServiceStateRecordEvidence[],
448
+ );
449
+ if (selected.kind === "unknown") {
450
+ throw unknownStateError?.(selected.reason) ?? new Error(`${selected.reason}; nothing was written`);
451
+ }
452
+ if (selected.kind === "none") return { current: null, revision: 0, fingerprint: "none" };
453
+ const current = selected.state as ServiceInstallState;
454
+ return { current, revision: selected.revision, fingerprint: serviceStateFingerprint(current) };
455
+ }
456
+
457
+ function commitServiceStateFile(path: string, serialized: string, validate: () => void): void {
458
+ atomicWriteFileStreamed(path, descriptor => {
459
+ writeFileSync(descriptor, serialized, { encoding: "utf8" });
460
+ }, { validateBeforeRename: validate });
461
+ }
462
+
463
+ /**
464
+ * Read the recorded state, compute the next one from it, and commit it only if nothing else
465
+ * moved the record in between.
466
+ *
467
+ * `mutate` may return null to mean "nothing to change", which writes nothing and leaves the
468
+ * file — including its absence — exactly as it was.
469
+ *
470
+ * WHAT THE REVISION CHECK IS. The anchor is re-read immediately before the commit and the
471
+ * committed bytes are read back immediately after, so a writer that landed on either side of
472
+ * the window is DETECTED and the whole read-modify-write runs again against the new base.
473
+ * The comparison is over the serialized record rather than the revision number alone,
474
+ * because two writers racing from one base both compute the same next revision — identical
475
+ * bytes mean nothing was lost, and differing bytes mean something was.
476
+ *
477
+ * The final path is the authority. With a custom home that is the legacy default-home path —
478
+ * the only path every writer can derive — and the active-home path is a compatibility mirror.
479
+ * The authority's atomic rename is the commit point. A mirror failure is reported but cannot
480
+ * roll back or reclassify the already committed mutation; the next writer repairs the mirror.
481
+ */
482
+ export function swapServiceInstallState(
483
+ mutate: (current: ServiceInstallState | null, context: ServiceStateMutationContext) => ServiceInstallState | null,
484
+ deps: ServiceStateSwapDeps = {},
485
+ ): ServiceInstallState | null {
486
+ const paths = deps.paths ?? serviceStateWritePaths();
487
+ const authority = paths.at(-1);
488
+ if (authority === undefined) throw new Error("refusing to swap service install state with no state path");
489
+ const mirrors = paths.filter(path => path !== authority);
490
+ const attempts = deps.attempts ?? SERVICE_STATE_SWAP_ATTEMPTS;
491
+ const publish = deps.commitStateFile ?? commitServiceStateFile;
492
+ return withOwnershipMutationLease(paths, () => withServiceStateLocks(paths, () => {
493
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
494
+ const base = authoritativeState(paths, deps.unknownStateError);
495
+ const candidate = mutate(base.current, { revision: base.revision });
496
+ if (candidate === null) return base.current;
497
+ if (base.revision >= Number.MAX_SAFE_INTEGER) {
498
+ throw new Error("service state revision is exhausted; refusing to publish an unversioned mutation");
499
+ }
500
+ const next: ServiceInstallState = { ...candidate, revision: base.revision + 1 };
501
+ const serialized = JSON.stringify(next, null, 2) + "\n";
502
+ deps.beforeCommit?.(attempt);
503
+ assertServiceStateLocksOwned(paths);
504
+ const fresh = authoritativeState(paths, deps.unknownStateError);
505
+ if (fresh.revision !== base.revision || fresh.fingerprint !== base.fingerprint) continue;
506
+ const validate = () => assertServiceStateLocksOwned(paths);
507
+ publish(authority, serialized, validate);
508
+ const committed = authoritativeState([authority]);
509
+ if (committed.revision !== next.revision || committed.fingerprint !== serviceStateFingerprint(next)) continue;
510
+ for (const mirror of mirrors) {
511
+ try { publish(mirror, serialized, validate); }
512
+ catch (error) {
513
+ (deps.onMirrorError ?? ((path, cause) => console.warn(
514
+ `service state committed, but compatibility mirror ${path} could not be refreshed: ${cause instanceof Error ? cause.message : String(cause)}`,
515
+ )))(mirror, error);
516
+ }
517
+ }
518
+ return next;
289
519
  }
520
+ throw new ServiceStateConflictError(authority, attempts);
521
+ }, { waitMs: deps.lockWaitMs, hooks: deps.lockHooks }), deps.mutationLease);
522
+ }
523
+
524
+ export interface RemoveServiceStateDeps {
525
+ readonly paths?: readonly string[];
526
+ readonly unlink?: (path: string) => void;
527
+ readonly lockWaitMs?: number;
528
+ readonly lockHooks?: ServiceStateLockHooks;
529
+ }
530
+
531
+ /**
532
+ * Delete mirrors first and the authority last under the same ownership locks.
533
+ *
534
+ * A crash or mirror error before the final unlink leaves the authority in place, so a stale
535
+ * mirror can never become a migration source and resurrect a released desktop claim.
536
+ */
537
+ export function removeServiceInstallStateRecords(deps: RemoveServiceStateDeps = {}): void {
538
+ const paths = deps.paths ?? serviceStateWritePaths();
539
+ const authority = paths.at(-1);
540
+ if (!authority) return;
541
+ const unlink = deps.unlink ?? unlinkSync;
542
+ withOwnershipMutationLease(paths, () => withServiceStateLocks(paths, () => {
543
+ for (const mirror of paths.slice(0, -1)) {
544
+ assertServiceStateLocksOwned(paths);
545
+ if (existsSync(mirror)) unlink(mirror);
546
+ }
547
+ assertServiceStateLocksOwned(paths);
548
+ if (existsSync(authority)) unlink(authority);
549
+ }, { waitMs: deps.lockWaitMs, hooks: deps.lockHooks }), { waitMs: deps.lockWaitMs });
550
+ }
551
+
552
+ /** The recorded owner of ONE already-read record, or null. Prefer {@link resolveServiceOwnership}. */
553
+ export function serviceOwnership(state: ServiceInstallState | null = readServiceInstallState()): ServiceOwnership | null {
554
+ return state?.ownership ?? null;
555
+ }
556
+
557
+ /**
558
+ * What every state path, together, says about who owns the runtime.
559
+ *
560
+ * An unknown resolution is the answer that matters. \`readServiceInstallState\` collapses
561
+ * unreadable, malformed and absent into one null, and a caller that reads that null as "the
562
+ * CLI owns it" will re-enable the npm launcher over a claim it merely failed to read — the
563
+ * exact demotion the record exists to prevent. Absence is the only thing that may mean no
564
+ * claim.
565
+ */
566
+ export type ServiceStateResolution =
567
+ | { readonly kind: "none"; readonly revision: 0; readonly needsRepair: false }
568
+ | { readonly kind: "state"; readonly state: ServiceInstallState; readonly revision: number; readonly needsRepair: boolean }
569
+ | { readonly kind: "unknown"; readonly reason: string };
570
+
571
+ export type ServiceOwnershipSubject =
572
+ | { readonly kind: "none"; readonly revision: number }
573
+ | { readonly kind: "owned"; readonly ownership: ServiceOwnership; readonly revision: number };
574
+
575
+ export type ServiceOwnershipResolution = ServiceOwnershipSubject
576
+ | { readonly kind: "unknown"; readonly reason: string };
577
+
578
+ export function resolveServiceState(
579
+ evidence: readonly ServiceStateEvidence[] = inspectServiceStateEvidence(),
580
+ ): ServiceStateResolution {
581
+ const selected = selectAuthoritativeServiceState(evidence as readonly ServiceStateRecordEvidence[]);
582
+ if (selected.kind === "unknown") return selected;
583
+ if (selected.kind === "none") return selected;
584
+ return {
585
+ kind: "state",
586
+ state: selected.state as ServiceInstallState,
587
+ revision: selected.revision,
588
+ needsRepair: selected.needsRepair,
589
+ };
590
+ }
591
+
592
+ export function resolveServiceOwnership(
593
+ evidence: readonly ServiceStateEvidence[] = inspectServiceStateEvidence(),
594
+ ): ServiceOwnershipResolution {
595
+ const state = resolveServiceState(evidence);
596
+ if (state.kind === "unknown") return state;
597
+ if (state.kind === "none" || !state.state.ownership) return { kind: "none", revision: state.revision };
598
+ return { kind: "owned", ownership: state.state.ownership, revision: state.revision };
599
+ }
600
+
601
+ export function sameServiceOwnershipSubject(
602
+ left: ServiceOwnershipSubject,
603
+ right: ServiceOwnershipSubject,
604
+ ): boolean {
605
+ if (left.kind !== right.kind || left.revision !== right.revision) return false;
606
+ if (left.kind === "none" || right.kind === "none") return true;
607
+ return left.ownership.owner === right.ownership.owner
608
+ && left.ownership.installId === right.ownership.installId
609
+ && left.ownership.consentGeneration === right.ownership.consentGeneration;
610
+ }
611
+
612
+ function sameServiceOwnershipIdentity(left: ServiceOwnershipSubject, right: ServiceOwnershipSubject): boolean {
613
+ if (left.kind !== right.kind) return false;
614
+ if (left.kind === "none" || right.kind === "none") return true;
615
+ return left.ownership.owner === right.ownership.owner
616
+ && left.ownership.installId === right.ownership.installId
617
+ && left.ownership.consentGeneration === right.ownership.consentGeneration;
618
+ }
619
+
620
+ function serviceOwnershipSubject(
621
+ state: ServiceInstallState | null,
622
+ revision: number,
623
+ ): ServiceOwnershipSubject {
624
+ return state?.ownership
625
+ ? { kind: "owned", ownership: state.ownership, revision }
626
+ : { kind: "none", revision };
627
+ }
628
+
629
+ export class ServiceOwnershipSubjectMismatchError extends Error {
630
+ readonly code = "service-ownership-subject-mismatch" as const;
631
+ constructor(readonly expected: ServiceOwnershipSubject, readonly actual: ServiceOwnershipSubject) {
632
+ super("service ownership changed after consent; resolve again and ask for fresh approval");
633
+ this.name = "ServiceOwnershipSubjectMismatchError";
290
634
  }
291
- return null;
635
+ }
636
+
637
+ export class ServiceOwnershipSubjectUnknownError extends Error {
638
+ readonly code = "service-ownership-subject-unknown" as const;
639
+ constructor(readonly expected: ServiceOwnershipSubject, readonly reason: string) {
640
+ super(`service ownership could not be revalidated after consent (${reason}); nothing was written`);
641
+ this.name = "ServiceOwnershipSubjectUnknownError";
642
+ }
643
+ }
644
+
645
+ export class ServiceTakeoverCompatibilityChangedError extends Error {
646
+ readonly code = "service-takeover-compatibility-changed" as const;
647
+ constructor(readonly actual: ServiceTakeoverCompatibility) {
648
+ super("the managing CLI compatibility changed after consent; resolve again and ask for fresh approval");
649
+ this.name = "ServiceTakeoverCompatibilityChangedError";
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Whether the packaged desktop app owns the runtime.
655
+ *
656
+ * This is the predicate `ocx service repair` and `ocx update` consult before they would
657
+ * re-enable, rewrite or restart the npm service registration. The registration itself is
658
+ * kept either way — the maintainer's decision is that the user's install is never deleted,
659
+ * so this marker is the only thing that makes the takeover durable.
660
+ */
661
+ export function desktopOwnsService(state: ServiceInstallState | null = readServiceInstallState()): boolean {
662
+ return serviceOwnership(state)?.owner === "desktop";
663
+ }
664
+
665
+ /**
666
+ * THE COMPARISON RULE. An installation holds the recorded grant only when both the kind of
667
+ * owner and the install id match its own.
668
+ *
669
+ * The desktop app calls this at launch with the install id from its app-local store. True
670
+ * means this very installation already has consent and must not ask again. False with a
671
+ * non-null `ownership` means a DIFFERENT installation owns the runtime — a reinstalled app,
672
+ * or a second copy — and consent has to be asked before taking over. Null means nothing is
673
+ * recorded and the npm install still owns it.
674
+ */
675
+ export function ownershipGrantedTo(
676
+ ownership: ServiceOwnership | null,
677
+ owner: ServiceOwner,
678
+ installId: string,
679
+ ): boolean {
680
+ return ownership !== null && ownership.owner === owner && ownership.installId === installId;
681
+ }
682
+
683
+ /**
684
+ * The record an ownership write lands on when no install state exists yet.
685
+ *
686
+ * Deliberately carries no `bunPath`, `cliPath` or `launcherPath`: those are baked BY AN
687
+ * INSTALL, and a takeover is not one. Recording the claiming process's own paths as install
688
+ * provenance would make `ocx service status` describe a registration nobody created.
689
+ */
690
+ function ownershipBaseRecord(current: ServiceInstallState | null): ServiceInstallState {
691
+ if (current) return current;
692
+ const codexHome = currentCodexHome();
693
+ return {
694
+ version: 2,
695
+ codexHome,
696
+ opencodexHome: currentOpenCodexHome(),
697
+ codexSqliteHome: resolveCodexSqliteHome({ codexHome }),
698
+ backend: "scheduler",
699
+ };
700
+ }
701
+
702
+ /**
703
+ * Record `claim` as the runtime's owner and return what was written.
704
+ *
705
+ * Idempotent by design: re-recording the same owner and install id leaves the consent
706
+ * generation alone, so every relaunch of an app that already has consent is a no-op on the
707
+ * number. A different owner or a different install id is a new grant and increments it once.
708
+ */
709
+ export interface RecordServiceOwnerRequest {
710
+ readonly owner: ServiceOwner;
711
+ readonly installId: string;
712
+ readonly expectedSubject: ServiceOwnershipSubject;
713
+ readonly expectedCompatibility: Extract<ServiceTakeoverCompatibility, { kind: "supported" }>;
714
+ }
715
+
716
+ export interface RecordServiceOwnerDeps extends ServiceStateSwapDeps {
717
+ /** Re-observes BOTH the registered manager and the current PATH manager inside the lock. */
718
+ readonly observeManagers: () => Readonly<Record<"service-registration" | "path", ManagingCliObservation>>;
719
+ }
720
+
721
+ export function recordServiceOwner(
722
+ request: RecordServiceOwnerRequest,
723
+ deps: RecordServiceOwnerDeps,
724
+ ): Extract<ServiceOwnershipSubject, { kind: "owned" }> {
725
+ if (!request.installId) throw new Error("refusing to record service ownership without an install id");
726
+ if (!request.expectedSubject || request.expectedCompatibility?.kind !== "supported") {
727
+ throw new Error("refusing to record service ownership without the exact approved subject and compatibility token");
728
+ }
729
+ if (!deps || typeof deps.observeManagers !== "function") {
730
+ throw new Error("refusing to record service ownership without a managing-CLI revalidation callback");
731
+ }
732
+ const { observeManagers, ...swapDeps } = deps;
733
+ let recorded: ServiceOwnership | null = null;
734
+ const committed = swapServiceInstallState((current, context) => {
735
+ const actualSubject = serviceOwnershipSubject(current, context.revision);
736
+ if (!sameServiceOwnershipSubject(request.expectedSubject, actualSubject)) {
737
+ throw new ServiceOwnershipSubjectMismatchError(request.expectedSubject, actualSubject);
738
+ }
739
+ let managers: Readonly<Record<"service-registration" | "path", ManagingCliObservation>>;
740
+ try { managers = observeManagers(); }
741
+ catch (error) {
742
+ throw new ServiceTakeoverCompatibilityChangedError({
743
+ kind: "blocked",
744
+ reason: "managing-cli-unknown",
745
+ detail: error instanceof Error ? error.message : String(error),
746
+ minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
747
+ });
748
+ }
749
+ const compatibility = assessServiceTakeoverCompatibility({
750
+ state: current,
751
+ subject: actualSubject,
752
+ managers,
753
+ });
754
+ if (!sameServiceTakeoverCompatibility(request.expectedCompatibility, compatibility)) {
755
+ throw new ServiceTakeoverCompatibilityChangedError(compatibility);
756
+ }
757
+ const previous = current?.ownership ?? null;
758
+ // The ceiling, not just the live claim: a grant that was released left its number
759
+ // behind on purpose, so a later grant cannot reuse it.
760
+ const floor = Math.max(previous?.consentGeneration ?? 0, current?.consentGenerationCeiling ?? 0);
761
+ if (floor >= Number.MAX_SAFE_INTEGER) {
762
+ throw new Error("service ownership consent generation is exhausted; nothing was written");
763
+ }
764
+ recorded = {
765
+ owner: request.owner,
766
+ installId: request.installId,
767
+ consentGeneration: previous && ownershipGrantedTo(previous, request.owner, request.installId)
768
+ ? previous.consentGeneration
769
+ : floor + 1,
770
+ };
771
+ return {
772
+ ...ownershipBaseRecord(current),
773
+ ownership: recorded,
774
+ consentGenerationCeiling: Math.max(floor, recorded.consentGeneration),
775
+ };
776
+ }, {
777
+ ...swapDeps,
778
+ unknownStateError: reason => new ServiceOwnershipSubjectUnknownError(request.expectedSubject, reason),
779
+ });
780
+ if (recorded === null || !committed?.ownership || committed.revision === undefined) {
781
+ throw new Error("service ownership was not recorded");
782
+ }
783
+ return { kind: "owned", ownership: committed.ownership, revision: committed.revision };
784
+ }
785
+
786
+ /**
787
+ * Drop a recorded owner and return what was dropped, or null when nothing was recorded.
788
+ *
789
+ * Writes nothing when there is no claim to release, so asking about an unowned runtime never
790
+ * creates an install record describing a service nobody registered.
791
+ */
792
+ export interface ReleaseServiceOwnerDeps extends ServiceStateSwapDeps {
793
+ /** Service install refreshes provenance first; that known write may advance only revision. */
794
+ readonly allowRevisionAdvance?: boolean;
795
+ }
796
+
797
+ export function releaseServiceOwner(
798
+ expectedSubject: ServiceOwnershipSubject,
799
+ deps: ReleaseServiceOwnerDeps = {},
800
+ ): ServiceOwnership | null {
801
+ const { allowRevisionAdvance = false, ...swapDeps } = deps;
802
+ let released: ServiceOwnership | null = null;
803
+ swapServiceInstallState((current, context) => {
804
+ const actualSubject = serviceOwnershipSubject(current, context.revision);
805
+ const matches = allowRevisionAdvance
806
+ ? sameServiceOwnershipIdentity(expectedSubject, actualSubject) && actualSubject.revision >= expectedSubject.revision
807
+ : sameServiceOwnershipSubject(expectedSubject, actualSubject);
808
+ if (!matches) throw new ServiceOwnershipSubjectMismatchError(expectedSubject, actualSubject);
809
+ released = current?.ownership ?? null;
810
+ if (!current?.ownership) return null;
811
+ const { ownership: _released, ...withoutOwnership } = current;
812
+ // Keep the number. Dropping it makes the generation an ABA token: grant, release, grant
813
+ // again would produce 1 twice, and an app-local record still holding the first 1 would
814
+ // read the second grant as its own prior consent.
815
+ return {
816
+ ...withoutOwnership,
817
+ consentGenerationCeiling: Math.max(
818
+ current.consentGenerationCeiling ?? 0,
819
+ current.ownership.consentGeneration,
820
+ ),
821
+ };
822
+ }, {
823
+ ...swapDeps,
824
+ unknownStateError: reason => new ServiceOwnershipSubjectUnknownError(expectedSubject, reason),
825
+ });
826
+ return released;
292
827
  }
293
828
 
294
829
  /** What ONE state path said. Absent, unreadable and invalid are different answers. */
@@ -301,37 +836,16 @@ export type ServiceStateEvidence =
301
836
  /**
302
837
  * Every state path, with what each one said.
303
838
  *
304
- * `readServiceInstallState` returns the FIRST path that parsed and discards the
305
- * rest, so a valid mirror beside a corrupt one reads as clean. That is the right
306
- * behavior for callers that just need the install state; it is the wrong input
307
- * for deciding ownership, where a disagreement between mirrors is exactly the
308
- * evidence that matters.
839
+ * The final path is authoritative; earlier paths are compatibility mirrors and the
840
+ * migration source only while the authority is absent. Keeping the raw evidence separate
841
+ * lets the selector distinguish migration, degraded mirrors and unordered conflicts.
309
842
  */
310
843
  export function inspectServiceStateEvidence(
311
844
  paths: readonly string[] = serviceStatePaths(),
312
845
  ): readonly ServiceStateEvidence[] {
313
- return paths.map((path): ServiceStateEvidence => {
314
- let raw: string;
315
- try {
316
- raw = readFileSync(path, "utf8");
317
- } catch (error) {
318
- const code = error && typeof error === "object" && "code" in error
319
- ? String((error as { code?: unknown }).code)
320
- : "";
321
- // ENOENT is an answer. EACCES, ENOTDIR and the rest are a failure to ask,
322
- // and collapsing them into absence is how a locked-down state file would
323
- // become permission to write.
324
- if (code === "ENOENT") return { path, kind: "absent" };
325
- return { path, kind: "unreadable", reason: code || String(error) };
326
- }
327
- let parsed: ServiceInstallState | null;
328
- try {
329
- parsed = parseServiceInstallState(JSON.parse(raw));
330
- } catch {
331
- return { path, kind: "invalid" };
332
- }
333
- return parsed ? { path, kind: "valid", state: parsed } : { path, kind: "invalid" };
334
- });
846
+ return paths.map(path => (
847
+ inspectInstallStateBytes(path, at => readFileSync(at, "utf8")) as ServiceStateEvidence
848
+ ));
335
849
  }
336
850
 
337
851
  /** The homes this process is actually using, for comparison against a claim. */