@bitkyc08/opencodex 2.58.0 → 2.60.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 (303) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  3. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +4 -1
  8. package/src/adapters/anthropic-image-codec.ts +16 -2
  9. package/src/adapters/anthropic-image-normalize.ts +49 -2
  10. package/src/adapters/anthropic.ts +4 -1
  11. package/src/adapters/base.ts +34 -1
  12. package/src/adapters/coding-agent/turn.ts +22 -2
  13. package/src/adapters/command-code.ts +50 -3
  14. package/src/adapters/cursor/catalog.ts +11 -0
  15. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  16. package/src/adapters/cursor/discovery.ts +11 -8
  17. package/src/adapters/cursor/effort-map.ts +16 -2
  18. package/src/adapters/cursor/envelope-echo.ts +55 -2
  19. package/src/adapters/cursor/live-transport.ts +26 -9
  20. package/src/adapters/cursor/message-mapper.ts +3 -2
  21. package/src/adapters/cursor/protobuf-request.ts +8 -5
  22. package/src/adapters/cursor/request-builder.ts +21 -4
  23. package/src/adapters/cursor/thread-continuity.ts +105 -31
  24. package/src/adapters/cursor/tool-guidance.ts +5 -4
  25. package/src/adapters/cursor/transport.ts +19 -0
  26. package/src/adapters/cursor.ts +45 -2
  27. package/src/adapters/devin/cloud-direct/chat.ts +14 -3
  28. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  29. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
  30. package/src/adapters/devin.ts +125 -26
  31. package/src/adapters/google-antigravity-replay.ts +1 -1
  32. package/src/adapters/google-antigravity-wire.ts +55 -4
  33. package/src/adapters/google-http.ts +57 -11
  34. package/src/adapters/google-tool-schema.ts +595 -31
  35. package/src/adapters/google-wire-compiler.ts +93 -10
  36. package/src/adapters/google-wire-shape.ts +461 -0
  37. package/src/adapters/google.ts +60 -10
  38. package/src/adapters/openai-chat/response-events.ts +61 -0
  39. package/src/adapters/openai-chat-images.ts +3 -1
  40. package/src/adapters/openai-chat.ts +10 -11
  41. package/src/adapters/openai-responses/image-gen.ts +8 -6
  42. package/src/adapters/openai-responses/passthrough.ts +25 -4
  43. package/src/adapters/openai-responses/reasoning.ts +7 -0
  44. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  45. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  46. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  47. package/src/adapters/responses-tool-schema.ts +76 -46
  48. package/src/adapters/run-turn-queue.ts +17 -4
  49. package/src/bridge/response-json.ts +1 -1
  50. package/src/bridge/sse.ts +28 -35
  51. package/src/claude/context-windows.ts +22 -0
  52. package/src/claude/outbound.ts +35 -4
  53. package/src/cli/account-api.ts +4 -3
  54. package/src/cli/account-extended.ts +26 -6
  55. package/src/cli/account-orca-import.ts +63 -0
  56. package/src/cli/account.ts +32 -4
  57. package/src/cli/capabilities.ts +40 -0
  58. package/src/cli/claude.ts +29 -1
  59. package/src/cli/codex-cli-update.ts +97 -2
  60. package/src/cli/dispatch.ts +57 -3
  61. package/src/cli/doctor.ts +218 -4
  62. package/src/cli/help.ts +4 -1
  63. package/src/cli/hub.ts +3 -2
  64. package/src/cli/index.ts +95 -22
  65. package/src/cli/models-runtime.ts +33 -4
  66. package/src/cli/opencode.ts +2 -2
  67. package/src/cli/provider.ts +13 -1
  68. package/src/cli/registry.ts +11 -1
  69. package/src/cli/runtime-api.ts +44 -0
  70. package/src/cli/start-args.ts +94 -0
  71. package/src/cli/system-command.ts +2 -0
  72. package/src/client/machine-api.ts +6 -5
  73. package/src/client/machine-listener.ts +16 -3
  74. package/src/client/runtime.ts +26 -2
  75. package/src/clients/config-export/constants.ts +2 -3
  76. package/src/clients/config-export.ts +5 -5
  77. package/src/codex/account-store.ts +146 -5
  78. package/src/codex/auth-api/account-list.ts +19 -11
  79. package/src/codex/auth-api/pool-quota-probe.ts +44 -10
  80. package/src/codex/auth-api/routes.ts +17 -2
  81. package/src/codex/auth-context.ts +16 -12
  82. package/src/codex/catalog/build-entries.ts +25 -4
  83. package/src/codex/catalog/derive-entry.ts +8 -1
  84. package/src/codex/catalog/effort.ts +10 -6
  85. package/src/codex/catalog/gather-capture.ts +22 -2
  86. package/src/codex/catalog/model-hints.ts +66 -33
  87. package/src/codex/catalog/parsing.ts +90 -5
  88. package/src/codex/catalog/provider-models.ts +19 -2
  89. package/src/codex/catalog/reserve-warn.ts +96 -0
  90. package/src/codex/catalog/retained-sync.ts +41 -26
  91. package/src/codex/catalog/routed-gather.ts +61 -3
  92. package/src/codex/cli-installation-identity.ts +210 -0
  93. package/src/codex/cli-installation-targets.ts +158 -0
  94. package/src/codex/context-compat.ts +5 -2
  95. package/src/codex/convergence.ts +5 -0
  96. package/src/codex/desired-state.ts +4 -1
  97. package/src/codex/history-job.ts +6 -6
  98. package/src/codex/history-provider.ts +24 -167
  99. package/src/codex/history-rollout-read.ts +174 -0
  100. package/src/codex/history-state-open.ts +105 -0
  101. package/src/codex/inject/config-toml.ts +44 -2
  102. package/src/codex/inject.ts +3 -2
  103. package/src/codex/internal/catalog-writer.ts +33 -1
  104. package/src/codex/lineage.ts +83 -32
  105. package/src/codex/loopback-target.ts +31 -0
  106. package/src/codex/main-account-hard-lock.ts +2 -1
  107. package/src/codex/main-account.ts +10 -3
  108. package/src/codex/main-device-reauth.ts +17 -9
  109. package/src/codex/model-cache.ts +47 -0
  110. package/src/codex/model-entitlements.ts +80 -2
  111. package/src/codex/observed-model-denials.ts +230 -0
  112. package/src/codex/orca-auth-source.ts +94 -0
  113. package/src/codex/orca-import.ts +219 -0
  114. package/src/codex/prompt-text-probe.ts +289 -16
  115. package/src/codex/quota-401-recovery.ts +12 -0
  116. package/src/codex/quota-types.ts +65 -0
  117. package/src/codex/quota.ts +24 -19
  118. package/src/codex/routing/cooldown-math.ts +8 -47
  119. package/src/codex/routing/pin-drain.ts +57 -0
  120. package/src/codex/routing.ts +20 -16
  121. package/src/codex/shim.ts +1 -1
  122. package/src/codex/subagent-model-fallback.ts +114 -2
  123. package/src/codex/windows-installation-files.ts +224 -0
  124. package/src/combos/failover.ts +125 -5
  125. package/src/config/admitted-identity.ts +222 -0
  126. package/src/config/diagnostics.ts +43 -1
  127. package/src/config/feature-flags.ts +5 -0
  128. package/src/config/load-degrade.ts +33 -0
  129. package/src/config/pending-teardown.ts +8 -0
  130. package/src/config/process-state.ts +36 -3
  131. package/src/config/provider-relative-send-path.ts +16 -0
  132. package/src/config/proxy-env.ts +31 -7
  133. package/src/config/schema/compaction-triggers.ts +11 -0
  134. package/src/config/schema/config-schema.ts +25 -0
  135. package/src/config/schema/leaf-validators.ts +76 -17
  136. package/src/config.ts +2 -2
  137. package/src/generated/compatibility-version.json +410 -258
  138. package/src/generated/model-metadata.ts +1 -1
  139. package/src/grok/reset-coupons.ts +38 -19
  140. package/src/images/loop.ts +6 -1
  141. package/src/integrations/aside-profile-context.ts +37 -3
  142. package/src/integrations/aside-profile-journal.ts +68 -3
  143. package/src/integrations/aside-profiles.ts +128 -3
  144. package/src/integrations/mutation-plan.ts +815 -0
  145. package/src/integrations/writer.ts +85 -99
  146. package/src/lab/live/transport.ts +4 -0
  147. package/src/lab/live/types.ts +5 -0
  148. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  149. package/src/lib/admin-secrets.ts +9 -1
  150. package/src/lib/bounded-body.ts +4 -2
  151. package/src/lib/debug-log-buffer.ts +6 -1
  152. package/src/lib/debug.ts +23 -0
  153. package/src/lib/destination-policy.ts +48 -6
  154. package/src/lib/errors.ts +82 -15
  155. package/src/lib/http-response-semantics.ts +57 -0
  156. package/src/lib/lab-live-pinned-sender.ts +26 -12
  157. package/src/lib/local-destinations.ts +32 -5
  158. package/src/lib/pinned-http.ts +142 -2
  159. package/src/lib/plain-data.ts +103 -0
  160. package/src/lib/process-control.ts +13 -5
  161. package/src/lib/provider-outbound.ts +53 -5
  162. package/src/lib/proxy-env.ts +70 -3
  163. package/src/lib/request-execution-budget.ts +11 -3
  164. package/src/lib/response-body-inactivity.ts +193 -0
  165. package/src/lib/retry-delay.ts +69 -0
  166. package/src/lib/socks5-fetch.ts +741 -0
  167. package/src/lib/spend-ledger-owner.ts +364 -0
  168. package/src/lib/spend-reservation-ledger.ts +332 -35
  169. package/src/lib/windows-system-proxy.ts +16 -11
  170. package/src/lib/workflow-budget.ts +145 -8
  171. package/src/oauth/account-quota-rank.ts +72 -15
  172. package/src/oauth/callback-server.ts +4 -3
  173. package/src/oauth/generic-account-failover.ts +41 -27
  174. package/src/oauth/health.ts +12 -1
  175. package/src/oauth/index.ts +3 -107
  176. package/src/oauth/login-flow-state.ts +127 -0
  177. package/src/oauth/orcarouter.ts +15 -2
  178. package/src/oauth/store.ts +8 -0
  179. package/src/providers/codex-capacity.ts +9 -0
  180. package/src/providers/derive.ts +34 -17
  181. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  182. package/src/providers/devin-provider-merge-migration.ts +33 -12
  183. package/src/providers/free-directory.ts +20 -2
  184. package/src/providers/key-failover.ts +327 -25
  185. package/src/providers/model-rename-migration.ts +56 -1
  186. package/src/providers/model-rename-startup.ts +7 -5
  187. package/src/providers/openai-sidecar.ts +4 -0
  188. package/src/providers/openai-virtual-models.ts +42 -2
  189. package/src/providers/opencode-go-transport.ts +14 -5
  190. package/src/providers/quota/antigravity.ts +22 -2
  191. package/src/providers/quota/report-cache.ts +3 -0
  192. package/src/providers/quota/vendor-probes-key.ts +1 -1
  193. package/src/providers/registry/entries-core.ts +39 -17
  194. package/src/providers/registry/entries-extended.ts +131 -1
  195. package/src/providers/registry/model-ids.ts +168 -0
  196. package/src/providers/registry/model-seeds.ts +87 -21
  197. package/src/providers/registry/types.ts +2 -0
  198. package/src/providers/resolved-model-policy-merge.ts +167 -0
  199. package/src/providers/resolved-model-policy.ts +406 -0
  200. package/src/providers/stale-vision-classification-migration.ts +137 -0
  201. package/src/responses/apply-patch-envelope.ts +32 -11
  202. package/src/responses/bridge-search-replay-cache.ts +152 -0
  203. package/src/responses/code-mode-helper-compat.ts +26 -16
  204. package/src/responses/custom-tool-compat.ts +1 -1
  205. package/src/responses/freeform-wrapper-scan.ts +279 -0
  206. package/src/responses/hosted-tool-policy.ts +85 -2
  207. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  208. package/src/responses/progressive-freeform-input.ts +130 -0
  209. package/src/responses/reasoning-envelope.ts +30 -0
  210. package/src/responses/schema.ts +9 -2
  211. package/src/responses/state.ts +5 -12
  212. package/src/responses/tool-name-aliases.ts +15 -1
  213. package/src/router.ts +91 -115
  214. package/src/routing/compatibility/behavior.ts +9 -0
  215. package/src/routing/compatibility/subject.ts +16 -1
  216. package/src/server/adapter-resolve.ts +9 -0
  217. package/src/server/auth-cors.ts +29 -0
  218. package/src/server/chat-completions.ts +13 -5
  219. package/src/server/chat-native-sse.ts +26 -9
  220. package/src/server/chat-native.ts +10 -4
  221. package/src/server/claude-messages.ts +30 -5
  222. package/src/server/effort-row.ts +11 -3
  223. package/src/server/grok-responses-control-frame.ts +160 -1
  224. package/src/server/gui-static.ts +36 -2
  225. package/src/server/inbound-body-admission.ts +187 -0
  226. package/src/server/index/serve-options.ts +86 -29
  227. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  228. package/src/server/index/websocket-handler.ts +6 -1
  229. package/src/server/index.ts +24 -25
  230. package/src/server/management/api-access.ts +3 -4
  231. package/src/server/management/aside-profile-routes.ts +266 -7
  232. package/src/server/management/config-routes.ts +48 -7
  233. package/src/server/management/context.ts +3 -0
  234. package/src/server/management/integration-routes.ts +287 -5
  235. package/src/server/management/metrics-routes.ts +20 -0
  236. package/src/server/management/model-rows.ts +224 -12
  237. package/src/server/management/provider-capability-config.ts +35 -7
  238. package/src/server/management/provider-routes.ts +70 -18
  239. package/src/server/management/route-registry.ts +14 -0
  240. package/src/server/management/shared.ts +10 -3
  241. package/src/server/management/system-restart.ts +7 -2
  242. package/src/server/management/system-routes.ts +2 -0
  243. package/src/server/management/usage-aggregate-cache.ts +4 -0
  244. package/src/server/management-api.ts +2 -0
  245. package/src/server/management-auth.ts +15 -1
  246. package/src/server/proxy-liveness.ts +97 -2
  247. package/src/server/readiness.ts +29 -10
  248. package/src/server/relay-eager.ts +24 -2
  249. package/src/server/relay.ts +136 -34
  250. package/src/server/request-log.ts +80 -3
  251. package/src/server/request-metrics.ts +236 -0
  252. package/src/server/responses/adapter-continuation.ts +74 -30
  253. package/src/server/responses/adapter-delivery.ts +39 -8
  254. package/src/server/responses/adapter-dispatch.ts +63 -30
  255. package/src/server/responses/compact.ts +89 -18
  256. package/src/server/responses/compaction-routing.ts +111 -0
  257. package/src/server/responses/core-codex-account.ts +90 -24
  258. package/src/server/responses/core-combo.ts +7 -7
  259. package/src/server/responses/core-normalize.ts +16 -15
  260. package/src/server/responses/core-opaque-recovery.ts +1 -0
  261. package/src/server/responses/core-options.ts +4 -0
  262. package/src/server/responses/encrypted-payload.ts +20 -2
  263. package/src/server/responses/fetch-helpers.ts +68 -2
  264. package/src/server/responses/passthrough-delivery.ts +41 -15
  265. package/src/server/responses/passthrough-dispatch.ts +145 -53
  266. package/src/server/responses/passthrough-execution.ts +11 -1
  267. package/src/server/responses/policy-fallback.ts +5 -13
  268. package/src/server/responses/request-prepare.ts +96 -17
  269. package/src/server/responses/request-send-budget.ts +89 -8
  270. package/src/server/responses/request-sidecar-auth.ts +17 -9
  271. package/src/server/responses/request-spend.ts +38 -9
  272. package/src/server/responses/request-transport.ts +15 -12
  273. package/src/server/responses/run-turn-execution.ts +45 -8
  274. package/src/server/responses/sidecar-execution.ts +19 -2
  275. package/src/server/responses/ws-upstream.ts +16 -28
  276. package/src/server/responses-custom-tool-repair.ts +29 -56
  277. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  278. package/src/server/sse-frame-buffer.ts +12 -10
  279. package/src/server/sse-payload-rewrite.ts +37 -10
  280. package/src/server/system-env-shell.ts +5 -1
  281. package/src/server/system-env.ts +7 -1
  282. package/src/server/workflow-refusal.ts +56 -2
  283. package/src/service/cli.ts +16 -6
  284. package/src/service/guards.ts +10 -0
  285. package/src/service/health.ts +43 -0
  286. package/src/service/state.ts +7 -2
  287. package/src/tray/windows-tray.ps1 +155 -3
  288. package/src/types/accounts.ts +4 -0
  289. package/src/types/config.ts +111 -6
  290. package/src/types/provider.ts +37 -0
  291. package/src/types/request.ts +9 -1
  292. package/src/types/tools.ts +14 -0
  293. package/src/types/wire.ts +9 -1
  294. package/src/types.ts +1 -0
  295. package/src/usage/expected-prices.ts +28 -0
  296. package/src/usage/log.ts +87 -4
  297. package/src/vision/eligibility.ts +88 -9
  298. package/src/vision/plan.ts +34 -10
  299. package/src/web-search/executor.ts +41 -2
  300. package/src/web-search/loop.ts +6 -1
  301. package/src/web-search/passthrough-bridge.ts +39 -5
  302. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
  303. package/gui/dist/assets/index-C5-RdDmD.css +0 -1
@@ -0,0 +1,105 @@
1
+ /**
2
+ * How the read-only Codex history preflight opens the state store.
3
+ *
4
+ * Split out of `history-provider.ts` deliberately. That file is the single largest module in
5
+ * `src/codex/` and sits just under the repository's 2000-line ratchet; the reasoning below is
6
+ * load-bearing and long, and appending it there would have pushed the file over. Keeping the
7
+ * open policy in one small module also puts the decision somewhere it can be read on its own,
8
+ * which matters because getting it wrong is silent in both directions.
9
+ */
10
+ import { existsSync } from "node:fs";
11
+ import { pathToFileURL } from "node:url";
12
+ import { Database, constants as sqliteConstants } from "bun:sqlite";
13
+
14
+ /**
15
+ * Read-only open flags that skip WAL shared memory entirely.
16
+ *
17
+ * `immutable=1` has to arrive as a `file:` URI, and a URI filename needs SQLITE_OPEN_URI.
18
+ * Same idiom as the storage scanner, the log-guard inspector and the coordinator doctor, for
19
+ * the same reason: never touch a foreign store's WAL protocol.
20
+ */
21
+ const IMMUTABLE_READONLY_FLAGS = sqliteConstants.SQLITE_OPEN_READONLY | sqliteConstants.SQLITE_OPEN_URI;
22
+
23
+ /** Is this the "there is no shared memory and I may not create it" open failure? */
24
+ export function isStateDbCantOpenError(error: unknown): boolean {
25
+ const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
26
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
27
+ // Matched on the code with a message fallback, exactly like classifyRecoverableHistoryError:
28
+ // the same SQLite condition reaches us as a code on some platforms and as bare text on others.
29
+ return code === "SQLITE_CANTOPEN" || message.includes("unable to open database file");
30
+ }
31
+
32
+ /** Which step of the primary attempt a test wants to fail. */
33
+ export type StateDbPreflightOpenPhase = "open" | "first-read";
34
+
35
+ /**
36
+ * Test-only knob: force the primary attempt to fail with a supplied error.
37
+ *
38
+ * The fallback below turns on a condition this repository cannot reproduce deterministically
39
+ * from a test: whether a plain read-only open of a cleanly-closed WAL store fails or quietly
40
+ * creates the `-shm` depends on the platform VFS and on the directory the store sits in.
41
+ * Pinning the NARROWING — sidecars absent admits the immutable read, either sidecar present
42
+ * still refuses — therefore needs the failure supplied rather than provoked, or the test would
43
+ * assert the host's SQLite build instead of this decision (#4943).
44
+ *
45
+ * The phase exists because the platforms disagree about WHEN the condition is raised, not only
46
+ * about whether it is: see the first-read note on `openCodexStateForPreflight`.
47
+ */
48
+ let openFailureForTests: ((path: string, phase: StateDbPreflightOpenPhase) => unknown) | undefined;
49
+ export function setStateDbPreflightOpenFailureForTests(hook: typeof openFailureForTests): void {
50
+ openFailureForTests = hook;
51
+ }
52
+
53
+ /**
54
+ * Open the Codex state store for the read-only preflight.
55
+ *
56
+ * `{ readonly: true }` is tried FIRST and stays the primary path, because it is the only mode
57
+ * that can see a live WAL: it joins the writer's shared memory, so a thread another process
58
+ * just migrated to paginated history is visible here and the preflight refuses on it. An
59
+ * immutable open reads the last checkpointed main database instead. Making that the primary
60
+ * path would trade a refusal for a stale snapshot, and a refusal this preflight fails to
61
+ * observe is a config transition that proceeds over history Codex owns — the exact outcome the
62
+ * whole guard exists to prevent.
63
+ *
64
+ * A WAL store closed cleanly is the case that has no shared memory to join. SQLite cannot
65
+ * create the `-shm` from a read-only connection, so the open fails SQLITE_CANTOPEN on a store
66
+ * that is perfectly healthy, the catch-all in the preflight folds that into
67
+ * `history_injection_preflight_unavailable`, and `ocx sync` refuses on every attempt with no
68
+ * way forward (#4943).
69
+ *
70
+ * So the fallback is admitted only in the state where the absent sidecars are what make an
71
+ * immutable read exact rather than stale: no `-wal` and no `-shm` on disk means no writer is
72
+ * attached and no committed content sits outside the main database, so the main file IS the
73
+ * whole store and the two modes cannot disagree. Either sidecar present keeps the original
74
+ * error and the refusal that follows from it — a `-wal` holds content this connection would
75
+ * not read, and a `-shm` means a writer is attached, and neither is a store this preflight
76
+ * may inspect from a snapshot.
77
+ *
78
+ * The first read belongs INSIDE this attempt. `sqlite3_open_v2` does not touch page 1, so a
79
+ * store whose header says WAL is not inspected until the first prepare — which is where the
80
+ * missing shared memory is discovered on macOS, one caller frame above this function. Opening
81
+ * here and reading there put the classification and the failure in different scopes: the
82
+ * fallback was never reached, and the operator got the catch-all refusal the fix was supposed
83
+ * to remove. Linux hides this because its SQLite materializes the sidecars on that first read
84
+ * and never fails at all (#4943, macOS CI).
85
+ */
86
+ export function openCodexStateForPreflight(resolvedPath: string): Database {
87
+ let db: Database | undefined;
88
+ try {
89
+ const forced = openFailureForTests?.(resolvedPath, "open");
90
+ if (forced) throw forced;
91
+ db = new Database(resolvedPath, { readonly: true });
92
+ const forcedRead = openFailureForTests?.(resolvedPath, "first-read");
93
+ if (forcedRead) throw forcedRead;
94
+ // Page 1, read while the failure is still this function's to classify.
95
+ db.query<{ tables: number }, []>("SELECT count(*) AS tables FROM sqlite_master").get();
96
+ return db;
97
+ } catch (error) {
98
+ db?.close();
99
+ if (!isStateDbCantOpenError(error)) throw error;
100
+ if (existsSync(`${resolvedPath}-wal`) || existsSync(`${resolvedPath}-shm`)) throw error;
101
+ // pathToFileURL percent-encodes the reserved characters a naive `file:${path}` would
102
+ // misparse as a query or fragment.
103
+ return new Database(`${pathToFileURL(resolvedPath).href}?immutable=1`, IMMUTABLE_READONLY_FLAGS);
104
+ }
105
+ }
@@ -55,6 +55,46 @@ export function applyEol(content: string, eol: "\r\n" | "\n"): string {
55
55
  return eol === "\n" ? lf : lf.replace(/\n/g, "\r\n");
56
56
  }
57
57
 
58
+ /** Label Codex shows for the injected provider when the operator has not chosen one. */
59
+ export const DEFAULT_CODEX_PROVIDER_DISPLAY_NAME = "OpenCodex Proxy";
60
+
61
+ /** Longest label accepted, matching the display-label policy used for provider names. */
62
+ const MAX_CODEX_PROVIDER_DISPLAY_NAME_LENGTH = 128;
63
+
64
+ /** Would this label put a control character into config.toml? */
65
+ function hasControlCharacter(value: string): boolean {
66
+ // Checked by code point rather than by a control-character regex, which needs a lint
67
+ // suppression this repository's hygiene gate rejects — and which reads no more clearly.
68
+ for (const character of value) {
69
+ const code = character.codePointAt(0) ?? 0;
70
+ if (code < 0x20 || code === 0x7f) return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ /**
76
+ * Which label to write, given whatever the config holds.
77
+ *
78
+ * Presentation only, and deliberately separate from identity: routing resolves through the
79
+ * provider id `opencodex` in the root `model_provider` line and the `[model_providers.opencodex]`
80
+ * header, neither of which is derived from this value. So a rename cannot reroute a thread or
81
+ * orphan a row that already names that id (#4810).
82
+ *
83
+ * Every rejected value falls back to the default rather than being emitted or omitted. Codex
84
+ * refuses to load a provider with no name, so writing a blank one would break the whole config
85
+ * file rather than one thread — strictly worse than the branding it was meant to remove. That is
86
+ * also why there is no way to suppress the field: suppression here means choosing a neutral
87
+ * label. A control character or an over-long value is rejected for the same reason, because
88
+ * `tomlString` would faithfully encode something Codex may still reject.
89
+ */
90
+ export function resolveCodexProviderDisplayName(configured?: string): string {
91
+ const trimmed = (configured ?? "").trim();
92
+ if (!trimmed) return DEFAULT_CODEX_PROVIDER_DISPLAY_NAME;
93
+ if (trimmed.length > MAX_CODEX_PROVIDER_DISPLAY_NAME_LENGTH) return DEFAULT_CODEX_PROVIDER_DISPLAY_NAME;
94
+ if (hasControlCharacter(trimmed)) return DEFAULT_CODEX_PROVIDER_DISPLAY_NAME;
95
+ return trimmed;
96
+ }
97
+
58
98
  export function buildProviderTableBlock(
59
99
  port: number,
60
100
  supportsWebsockets?: boolean,
@@ -84,12 +124,13 @@ export function buildProviderTableBlock(
84
124
  export function buildProviderTableBlockForTarget(
85
125
  target: CodexRoutingTarget,
86
126
  supportsWebsockets = false,
127
+ displayName?: string,
87
128
  ): string {
88
129
  const lines = [
89
130
  "",
90
131
  OCX_SECTION_MARKER,
91
132
  "[model_providers.opencodex]",
92
- 'name = "OpenCodex Proxy"',
133
+ `name = ${tomlString(resolveCodexProviderDisplayName(displayName))}`,
93
134
  `base_url = ${tomlString(target.baseUrl)}`,
94
135
  'wire_api = "responses"',
95
136
  // false only in the authless Desktop opt-in (#1107); true keeps the App/TUI account gate.
@@ -518,6 +559,7 @@ export function buildProfileFileForTarget(
518
559
  catalogPath?: string | null,
519
560
  supportsWebsockets = false,
520
561
  fastMode?: boolean,
562
+ displayName?: string,
521
563
  ): string {
522
564
  const origin = routingTargetOrigin(target);
523
565
  const host = new URL(origin).host;
@@ -542,7 +584,7 @@ export function buildProfileFileForTarget(
542
584
  ];
543
585
  if (catalogPath) lines.push(`model_catalog_json = ${tomlString(catalogPath)}`);
544
586
  if (fastMode !== undefined) lines.push("", "[features]", `fast_mode = ${fastMode ? "true" : "false"}`);
545
- lines.push(buildProviderTableBlockForTarget(target, supportsWebsockets).trimEnd(), "");
587
+ lines.push(buildProviderTableBlockForTarget(target, supportsWebsockets, displayName).trimEnd(), "");
546
588
  return lines.join("\n");
547
589
  }
548
590
 
@@ -369,7 +369,7 @@ async function injectCodexConfigImpl(
369
369
  content =
370
370
  content.trimEnd() +
371
371
  "\n" +
372
- buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {}));
372
+ buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {}), config?.codexProviderDisplayName);
373
373
  // 3) Keep existing `openai`-tagged threads reaching the proxy (see above). Ownership rules
374
374
  // are the Design B ones: a user's own root line is never replaced.
375
375
  if (keepRootOverrideAlongsideTable) {
@@ -430,6 +430,7 @@ async function injectCodexConfigImpl(
430
430
  catalogPath,
431
431
  websocketsEnabled(config ?? {}),
432
432
  config?.fastMode,
433
+ config?.codexProviderDisplayName,
433
434
  );
434
435
  content = applyEol(content, eol);
435
436
 
@@ -492,7 +493,7 @@ async function injectCodexConfigImpl(
492
493
  */
493
494
  if (hadOcxProviderTableOnDisk && !providerTableMode) {
494
495
  content = applyEol(
495
- content.trimEnd() + "\n" + buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {})),
496
+ content.trimEnd() + "\n" + buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {}), config?.codexProviderDisplayName),
496
497
  eol,
497
498
  );
498
499
  }
@@ -1,4 +1,4 @@
1
- import { chmodSync, linkSync, mkdirSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, linkSync, mkdirSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
 
4
4
  import {
@@ -37,6 +37,38 @@ export interface CatalogBackupWriteIO {
37
37
 
38
38
  let backupTempSequence = 0;
39
39
 
40
+ /**
41
+ * Do the prepared bytes differ from what is already on disk at `prepared.path`?
42
+ *
43
+ * The single no-op rule for both files the catalog layer owns — the active catalog
44
+ * and Codex's models cache — so the two writers cannot drift apart. Every
45
+ * mtime-keyed reader has to treat a rewrite as a change: the app-server staleness
46
+ * classifier (#857) compares a file's mtime against each running Codex's start
47
+ * time, so rewriting identical bytes marks every already-running Codex as stale
48
+ * even though nothing changed. #1459 established this rule for the catalog; the
49
+ * cache is the second writer that has to apply it, because
50
+ * `refreshCodexModelCatalog` reports `cacheSynced` straight into the startup
51
+ * warning in `handleStart`.
52
+ *
53
+ * Deliberately a Buffer rather than a decoded string: `readFileSync(path, "utf8")`
54
+ * substitutes U+FFFD for every invalid byte, so a file holding a raw 0x80 decodes
55
+ * equal to prepared content holding a legitimately encoded U+FFFD. Comparing
56
+ * decoded strings would then classify a malformed file as identical, skip the
57
+ * atomic repair write, and leave the corruption on disk.
58
+ *
59
+ * An unreadable or absent file reports "differs", so the caller performs the real
60
+ * write; that also converges a file that does not exist yet.
61
+ */
62
+ export function preparedBytesDifferFromDisk(prepared: PreparedCatalogFileWrite): boolean {
63
+ let onDisk: Buffer;
64
+ try {
65
+ onDisk = readFileSync(prepared.path);
66
+ } catch {
67
+ return true;
68
+ }
69
+ return !onDisk.equals(Buffer.from(prepared.content, "utf8"));
70
+ }
71
+
40
72
  function isMissingPathError(error: unknown): boolean {
41
73
  return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
42
74
  }
@@ -14,9 +14,11 @@
14
14
  *
15
15
  * What it is for, and what it is not for:
16
16
  *
17
- * - FIRST PLACEMENT. A child with no binding of its own may start where its family is already
18
- * warm; see `pickLineageServingAccount` in ./routing. Once bound, the child is an ordinary
19
- * binding, so a later move of the parent does not drag it.
17
+ * - FIRST PLACEMENT. Largely subsumed by cohort keying (#4780): a member of a tree that any
18
+ * other member has already bound resolves to that same binding, so there is nothing to place.
19
+ * `pickLineageServingAccount` in ./routing remains for the one case cohort keying cannot
20
+ * unify -- a session-less chain whose parent is not recorded in this scope -- and is gated on
21
+ * the parent's key actually differing from this request's.
20
22
  * - COST ATTRIBUTION. {@link codexThreadLineageLookup} and {@link codexLineageRootForRequest}
21
23
  * answer which root workflow a conversation belongs to, so a grandchild's spend aggregates
22
24
  * onto the root. No budget is implemented here.
@@ -155,30 +157,83 @@ export function codexConversationKeyFor(familyId: string, threadId: string): str
155
157
  .digest("base64url")}`;
156
158
  }
157
159
 
160
+ /**
161
+ * The cohort anchor's key: the same string on both sides of the derivation.
162
+ *
163
+ * A cohort is identified by one value the whole tree shares, so the key is that value keyed
164
+ * against itself rather than against a member. Using {@link codexConversationKeyFor} keeps one
165
+ * derivation in the module, which is what guarantees a lineage record's `conversationKey` stays
166
+ * byte-identical to the key the thread actually binds under.
167
+ */
168
+ function cohortKeyFromAnchor(sessionId: string | undefined, fallbackAnchor: string): string {
169
+ const anchor = sessionId ?? fallbackAnchor;
170
+ return codexConversationKeyFor(anchor, anchor);
171
+ }
172
+
173
+ /**
174
+ * The cohort this request belongs to, or undefined when it names no cohort at all.
175
+ *
176
+ * The session IS the cohort, which is exactly how upstream keys the prompt cache:
177
+ * `prompt_cache_key()` returns `responses_metadata.session_id` (or `{source}:{parent_thread_id}`
178
+ * for an internal session), and `AgentControl.session_id` is the root thread's id, shared with
179
+ * every sub-agent spawned from that root. Two requests carrying the same `prompt_cache_key` must
180
+ * not be served by different accounts, and keying on the session is what makes that structural
181
+ * rather than a hint (#4780).
182
+ *
183
+ * Without a session there is nothing in the headers that names the tree, so the cohort is
184
+ * whatever the parent is already bound to. That lookup is what keeps a chain of parent-only
185
+ * turns converging on one key: anchoring each depth on its own parent would split the cohort
186
+ * again at every hop. When the parent has not been seen in this scope the parent id anchors it,
187
+ * which is the same key that parent derives for itself.
188
+ */
189
+ function cohortConversationKey(
190
+ headers: Headers,
191
+ sessionId: string | undefined,
192
+ parentThreadId: string | undefined,
193
+ now: number,
194
+ ): string | undefined {
195
+ if (sessionId !== undefined) return cohortKeyFromAnchor(sessionId, sessionId);
196
+ if (parentThreadId === undefined) return undefined;
197
+ const recorded = liveLineageRecord(
198
+ lineageByScope.get(codexLineageScopeKey(headers)),
199
+ parentThreadId,
200
+ now,
201
+ );
202
+ return recorded?.conversationKey ?? cohortKeyFromAnchor(undefined, parentThreadId);
203
+ }
204
+
158
205
  /**
159
206
  * Resolve a request's conversation identity, or undefined when it carries no bindable thread
160
207
  * identity at all.
161
208
  *
162
- * The set of requests that produce NO key is deliberately unchanged from the pre-#4546 rule: a
163
- * bare `thread-id` with neither a session nor a parent stays unbound, exactly as the Desktop
164
- * fallback required both halves of its pair. Only the VALUE moves, and only for requests that
165
- * name a parent:
209
+ * The set of requests that produce NO key is deliberately unchanged, through both #4546 and
210
+ * #4780: a bare `thread-id` with neither a session nor a parent stays unbound, exactly as the
211
+ * Desktop fallback required both halves of its pair. Only the VALUE moves.
212
+ *
213
+ * Every member of one tree resolves to the SAME key, because the cohort is the binding unit
214
+ * (#4780):
215
+ *
216
+ * - root (`session-id` + `thread-id`) -> the session's cohort key;
217
+ * - child and grandchild (parent + own `thread-id`) -> the same cohort key, since they carry the
218
+ * same session;
219
+ * - parent-only (no `thread-id`) -> the same cohort key, derived from the session or, without
220
+ * one, read from the parent's record.
166
221
  *
167
- * - root (`session-id` + `thread-id`) -> HMAC(session, thread), unchanged;
168
- * - child (parent + own `thread-id`) -> HMAC(session ?? parent, thread), previously the raw parent
169
- * id, which is what made siblings share one entry and made a child's first turn land on a key
170
- * the root had never bound;
171
- * - parent-only (no `thread-id`) -> the parent's OWN recorded key when this scope has one, and
172
- * otherwise HMAC(session ?? parent, parent).
222
+ * THIS IS NOT A REVERT OF #4546 wp8, and reading it as one would flip it straight back. wp8
223
+ * fixed a real incoherence: a child keyed under the RAW parent id, which is a different identity
224
+ * from the root's own `app:HMAC(session, thread)` binding, so siblings shared an entry unrelated
225
+ * to the root's and a grandchild keying on its own parent landed on a key nobody had ever bound.
226
+ * A cohort key cannot produce that, because the root's own binding IS the cohort key: there is
227
+ * one identity for the tree rather than two competing ones. What wp8 additionally gave each
228
+ * thread -- a binding of its own -- is what #4780 deliberately gives up, and the reason is that
229
+ * upstream never agreed to it: `prompt_cache_key` is keyed on the session the whole tree shares,
230
+ * so a proxy that splits the tree makes every split member assert a warm prefix that is cold on
231
+ * its account.
173
232
  *
174
- * That last case is the one with a trap in it. A parent-only turn belongs to the parent's
175
- * conversation, so it has to land on the binding the parent is already using -- but the parent's
176
- * key is HMAC(session, thread), and HMAC(parent, parent) reproduces it only when the session id
177
- * and the thread id are the same string. Codex's own root happens to satisfy that, which is
178
- * exactly why deriving the key looks correct until a caller whose session differs from its thread
179
- * starts a COLD conversation on every parent-only turn and overwrites the parent's record on the
180
- * way through. So the recorded key wins, the session-derived key is the fallback that reproduces
181
- * it when the parent has not been seen in this scope, and the raw parent id is never the answer.
233
+ * The parent-only case keeps the trap it always had. Such a turn belongs to the parent's
234
+ * conversation, so it must land on the binding the parent is already using. With a session in
235
+ * hand that is immediate, since both derive the same cohort key. Without one, the recorded key
236
+ * wins and the parent id anchors the fallback; the raw parent id is never the answer.
182
237
  */
183
238
  export function codexConversationIdentity(
184
239
  headers: Headers,
@@ -190,24 +245,20 @@ export function codexConversationIdentity(
190
245
 
191
246
  if (threadId === undefined) {
192
247
  if (parentThreadId === undefined) return undefined;
193
- const recorded = liveLineageRecord(
194
- lineageByScope.get(codexLineageScopeKey(headers)),
195
- parentThreadId,
196
- now,
197
- );
248
+ const parentOnlyKey = cohortConversationKey(headers, sessionId, parentThreadId, now);
249
+ if (parentOnlyKey === undefined) return undefined;
198
250
  return {
199
- conversationKey: recorded?.conversationKey
200
- ?? codexConversationKeyFor(sessionId ?? parentThreadId, parentThreadId),
251
+ conversationKey: parentOnlyKey,
201
252
  recordThreadId: parentThreadId,
202
253
  ...(sessionId !== undefined ? { sessionId } : {}),
203
254
  legacyConversationKey: parentThreadId,
204
255
  declaresParent: false,
205
256
  };
206
257
  }
207
- const familyId = sessionId ?? parentThreadId;
208
- if (familyId === undefined) return undefined;
258
+ const conversationKey = cohortConversationKey(headers, sessionId, parentThreadId, now);
259
+ if (conversationKey === undefined) return undefined;
209
260
  return {
210
- conversationKey: codexConversationKeyFor(familyId, threadId),
261
+ conversationKey,
211
262
  recordThreadId: threadId,
212
263
  ...(sessionId !== undefined ? { sessionId } : {}),
213
264
  ...(parentThreadId !== undefined ? { parentThreadId } : {}),
@@ -313,7 +364,7 @@ function lineageFor(
313
364
  const parentConversationKey = parentThreadId === undefined
314
365
  ? undefined
315
366
  : parentRecord?.conversationKey
316
- ?? codexConversationKeyFor(identity.sessionId ?? parentThreadId, parentThreadId);
367
+ ?? cohortKeyFromAnchor(identity.sessionId, parentThreadId);
317
368
  const rootSessionKey = parentConversationKey === undefined
318
369
  ? identity.conversationKey
319
370
  : parentRecord?.rootSessionKey ?? parentConversationKey;
@@ -5,6 +5,37 @@ import { NATIVE_RESERVE_MODEL } from "./catalog/native-models";
5
5
  export const CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE =
6
6
  "Luna Reserve compatibility is only available as a conversation model, not a vision helper. Choose another vision model.";
7
7
 
8
+ export const CODEX_RESERVE_OPT_IN_REQUIRED_MESSAGE =
9
+ "Luna Reserve (gpt-reserve) is not forwarded without the local Desktop authless opt-in."
10
+ + " OpenCodex holds no Reserve entitlement to send for this request, so the upstream would answer with a"
11
+ + " usage-limit error that names neither the cause nor the fix."
12
+ + " Enable the opt-in with 'ocx system settings --desktop-authless on' (or set codexDesktopAuthless to true"
13
+ + " in config.json), then retry. Choose another model to keep working without it.";
14
+
15
+ /**
16
+ * Strict complement of {@link isCodexReserveRequestEligible} for the FLAG reason ONLY (#4940).
17
+ *
18
+ * Read the two together. This answers a narrower question: Reserve was asked for, every ingress
19
+ * condition eligibility requires already holds, and the single missing piece is the operator
20
+ * opt-in. Flipping `codexDesktopAuthless` to true therefore always turns a true here into a true
21
+ * from {@link isCodexReserveRequestEligible}, which is what makes it honest for the refusal to
22
+ * name that one setting. The other two ineligibility reasons are deliberately not covered: a
23
+ * client role and a non-loopback admission source are different situations, and the correct
24
+ * answer for both is still to forward exactly as before.
25
+ *
26
+ * Callers classify the concrete destination as canonical forward before using this predicate, the
27
+ * same obligation {@link isCodexReserveHelperUnsupported} carries. An operator who has aliased or
28
+ * routed `gpt-reserve` onto some other provider owns a path that works, and it must keep working.
29
+ */
30
+ export function isCodexReserveOptInMissing(
31
+ config: Pick<OcxConfig, "codexDesktopAuthless" | "runtimeRole">,
32
+ modelId: string,
33
+ admission: Pick<DataPlaneAdmission, "source"> | undefined,
34
+ ): boolean {
35
+ return modelId === NATIVE_RESERVE_MODEL && config.codexDesktopAuthless !== true
36
+ && config.runtimeRole !== "client" && admission?.source === "loopback";
37
+ }
38
+
8
39
  /** Callers classify the concrete destination as canonical forward before using this predicate. */
9
40
  export function isCodexReserveHelperUnsupported(
10
41
  config: Pick<OcxConfig, "codexDesktopAuthless" | "runtimeRole">,
@@ -1,7 +1,8 @@
1
1
  import type { OcxConfig } from "../types";
2
2
  import { getMainPolicyQuota } from "./quota";
3
+ import { MAIN_ACCOUNT_HARD_LOCK_PERCENT } from "./quota-types";
3
4
 
4
- export const MAIN_ACCOUNT_HARD_LOCK_PERCENT = 99;
5
+ export { MAIN_ACCOUNT_HARD_LOCK_PERCENT };
5
6
 
6
7
  export interface MainAccountHardLockStatus {
7
8
  enabled: boolean;
@@ -296,7 +296,10 @@ function persistNativeMainReauthTokens(
296
296
  * main account's reauth quarantine for the new credential generation.
297
297
  */
298
298
  export function beginNativeMainReauth(): {
299
- commit: (tokens: NativeMainReauthTokens) => Promise<{ chatgptAccountId: string }>;
299
+ commit: (
300
+ tokens: NativeMainReauthTokens,
301
+ options?: { signal?: AbortSignal },
302
+ ) => Promise<{ chatgptAccountId: string }>;
300
303
  } {
301
304
  const expected = readMainAuthJsonCredential();
302
305
  if (!expected || !expected.chatgptAccountId) {
@@ -305,7 +308,10 @@ export function beginNativeMainReauth(): {
305
308
  );
306
309
  }
307
310
  return {
308
- async commit(tokens: NativeMainReauthTokens): Promise<{ chatgptAccountId: string }> {
311
+ async commit(
312
+ tokens: NativeMainReauthTokens,
313
+ options: { signal?: AbortSignal } = {},
314
+ ): Promise<{ chatgptAccountId: string }> {
309
315
  if (!tokens.accessToken || !tokens.refreshToken || !tokens.idToken) {
310
316
  throw new NativeMainReauthUnavailableError("Device grant did not produce a complete token set");
311
317
  }
@@ -322,11 +328,12 @@ export function beginNativeMainReauth(): {
322
328
  "Native main traffic is blocked by startup or recovery state",
323
329
  );
324
330
  }
331
+ if (options.signal?.aborted) throw options.signal.reason;
325
332
  assertMainAuthJsonSnapshotUnchanged(expected);
326
333
  persistNativeMainReauthTokens(expected, tokens);
327
334
  clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
328
335
  return { chatgptAccountId: tokens.chatgptAccountId };
329
- }, { waitMs: 30_000 });
336
+ }, { waitMs: 30_000, signal: options.signal });
330
337
  },
331
338
  };
332
339
  }
@@ -54,7 +54,9 @@ interface ActiveFlow {
54
54
  /** Set once auth.json has been replaced; cancellation can no longer win. */
55
55
  published: boolean;
56
56
  /** Snapshot-holding commit prepared at start; closure-private identity. */
57
- prepared?: { commit: (tokens: NativeMainReauthTokens) => Promise<{ chatgptAccountId: string }> };
57
+ prepared?: {
58
+ commit: (tokens: NativeMainReauthTokens, options?: { signal?: AbortSignal }) => Promise<{ chatgptAccountId: string }>;
59
+ };
58
60
  }
59
61
 
60
62
  /** Bounded terminal retention so status/cancel stay answerable after completion. */
@@ -65,7 +67,9 @@ const terminalFlows = new Map<string, { status: MainDeviceReauthStatus; expiresA
65
67
 
66
68
  export interface MainDeviceReauthDeps {
67
69
  login?: (ctrl: OAuthController) => Promise<NativeDeviceLogin>;
68
- beginCommit?: () => { commit: (tokens: NativeMainReauthTokens) => Promise<{ chatgptAccountId: string }> };
70
+ beginCommit?: () => {
71
+ commit: (tokens: NativeMainReauthTokens, options?: { signal?: AbortSignal }) => Promise<{ chatgptAccountId: string }>;
72
+ };
69
73
  flowId?: () => string;
70
74
  now?: () => number;
71
75
  }
@@ -81,10 +85,13 @@ function sweepTerminal(now: number): void {
81
85
  }
82
86
 
83
87
  function finish(flow: ActiveFlow, status: MainDeviceReauthStatus, now: number): void {
84
- // Publication beats a racing cancellation: once auth.json was replaced the
85
- // honest terminal is succeeded, never cancelled (080). Every other terminal
86
- // is first-write-wins so a superseded or cancelled completion cannot
87
- // publish a later result.
88
+ // Terminal results are first-write-wins, with one exception (080): once the
89
+ // commit actually replaced auth.json, the honest terminal is succeeded. The
90
+ // signal now fences the claim wait and the pre-write recheck, but it cannot
91
+ // fence the gap between the synchronous write and this call — the claim
92
+ // teardown and the promise resolution both yield, so a cancel arriving there
93
+ // would otherwise report "cancelled" for a credential that was replaced and
94
+ // a reauth quarantine that was cleared.
88
95
  if (isTerminal(flow.status)) {
89
96
  if (!(flow.published && status.status === "succeeded")) return;
90
97
  }
@@ -165,15 +172,16 @@ export function startMainDeviceReauth(deps: MainDeviceReauthDeps = {}): MainDevi
165
172
  if (flow.controller.signal.aborted) return;
166
173
  if (!isTerminal(flow.status)) flow.status = { flowId, status: "committing" };
167
174
  // Recheck immediately before the write: a cancel that landed while the
168
- // grant was resolving must not reach auth.json. Once the commit DOES
169
- // publish, the terminal is succeeded even if a cancel raced it (080).
175
+ // grant was resolving must not reach auth.json. The commit itself is
176
+ // fenced by the same signal, so a cancel delivered while the claim
177
+ // waits aborts the publication instead of racing it.
170
178
  if (activeFlow !== flow || flow.controller.signal.aborted || isTerminal(flow.status)) return;
171
179
  await flow.prepared!.commit({
172
180
  accessToken: grant.credential.access,
173
181
  refreshToken: grant.credential.refresh,
174
182
  idToken: grant.idToken,
175
183
  chatgptAccountId: grant.credential.accountId!,
176
- });
184
+ }, { signal: flow.controller.signal });
177
185
  flow.published = true;
178
186
  finish(flow, { flowId, status: "succeeded", credentialUpdated: true }, clock());
179
187
  } catch (error) {