@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
@@ -71,6 +71,8 @@ function deleteCachedProvider(provider: string): number {
71
71
  cache.delete(provider);
72
72
  cacheBytes = Math.max(0, cacheBytes - entry.sizeBytes);
73
73
  if (oldestCachedProvider === provider) recomputeOldestCachedProvider();
74
+ // A removal changes this provider's content as surely as a publication does.
75
+ bumpProviderCacheRevision(provider);
74
76
  return entry.sizeBytes;
75
77
  }
76
78
 
@@ -177,6 +179,34 @@ export function isModelCacheGenerationCurrent(provider: string, generation: stri
177
179
  return generation === captureModelCacheGeneration(provider);
178
180
  }
179
181
 
182
+ /**
183
+ * How many times this provider's cached content has actually changed.
184
+ *
185
+ * Deliberately separate from the generation. A generation revokes an in-flight discovery's right
186
+ * to publish, so it advances on an authority clear and must not be repurposed: moving it on a
187
+ * successful publication would cancel writes that are still legitimate. This counts accepted
188
+ * publications and removals instead, which is what a reader holding a derived roster needs to
189
+ * know, and it advances on exactly the event a generation does not: a discovery that succeeded
190
+ * and changed the rows.
191
+ *
192
+ * Reading is passive. An unseen provider reads as `0` rather than seeding an entry, so observing
193
+ * one cannot alter what a later capture or publication sees.
194
+ */
195
+ const providerCacheRevisions = new Map<string, number>();
196
+ let globalContentRevision = 0;
197
+
198
+ function bumpProviderCacheRevision(provider: string): void {
199
+ providerCacheRevisions.set(provider, (providerCacheRevisions.get(provider) ?? 0) + 1);
200
+ }
201
+
202
+ export function observeModelCacheRevision(provider: string): string {
203
+ // The global term covers a clear that empties the map wholesale, which per-provider counters
204
+ // cannot express: without it, wiping every entry and republishing identical-looking rows would
205
+ // read as unchanged. It also survives pruning, so a retired provider cannot come back with a
206
+ // counter that matches a roster built before it left.
207
+ return `${globalContentRevision}:${providerCacheRevisions.get(provider) ?? 0}`;
208
+ }
209
+
180
210
  /**
181
211
  * Store a live result unless the cache was cleared while that asynchronous discovery was running.
182
212
  * The optional generation keeps existing direct cache writers unchanged while discovery callers can
@@ -200,6 +230,8 @@ export function setCached(
200
230
  oldestCachedAt = now;
201
231
  }
202
232
  enforceAppOwnedMemoryBudget();
233
+ // Published and accepted, so anything derived from this provider's rows is now out of date.
234
+ bumpProviderCacheRevision(provider);
203
235
  return true;
204
236
  }
205
237
 
@@ -220,6 +252,11 @@ export function clearModelCache(
220
252
  } else {
221
253
  if (revokesInFlightDiscovery) globalCacheGeneration += 1;
222
254
  cache.clear();
255
+ // A wholesale clear changes every provider's content at once, and clearing the map means no
256
+ // per-provider counter can record it. Advancing the global term retires every derived roster
257
+ // and lets the per-provider entries be dropped without an ABA on the way back.
258
+ globalContentRevision += 1;
259
+ providerCacheRevisions.clear();
223
260
  cacheBytes = 0;
224
261
  oldestCachedProvider = undefined;
225
262
  oldestCachedAt = null;
@@ -237,6 +274,7 @@ export function reconcileModelCacheProviders(
237
274
  const removedProviders = new Set<string>();
238
275
  const trackedProviders = new Set([
239
276
  ...providerCacheGenerations.keys(),
277
+ ...providerCacheRevisions.keys(),
240
278
  ...failureAt.keys(),
241
279
  ...discoveryStatus.keys(),
242
280
  ...liveModelCounts.keys(),
@@ -247,6 +285,10 @@ export function reconcileModelCacheProviders(
247
285
  if (validProviders.has(provider)) continue;
248
286
  if (!revokedRemovedProviderAuthority) {
249
287
  globalCacheGeneration += 1;
288
+ // Advanced BEFORE any revision entry is dropped, so a provider that comes back cannot
289
+ // present the counter a roster was built against. Without this the delete below is an ABA:
290
+ // the entry returns at zero and an old stamp matches again.
291
+ globalContentRevision += 1;
250
292
  revokedRemovedProviderAuthority = true;
251
293
  }
252
294
  providerCacheGenerations.set(provider, (providerCacheGenerations.get(provider) ?? 0) + 1);
@@ -255,6 +297,11 @@ export function reconcileModelCacheProviders(
255
297
  failureAt.delete(provider);
256
298
  discoveryStatus.delete(provider);
257
299
  liveModelCounts.delete(provider);
300
+ // AFTER the cache deletion, which bumps this provider's revision and would otherwise recreate
301
+ // the entry we just removed. Dropped rather than left behind: a provider the configuration no
302
+ // longer has must not keep an entry alive for the life of the process merely because nothing
303
+ // cleared the whole cache. The global epoch advanced above, so the removal is not an ABA.
304
+ providerCacheRevisions.delete(provider);
258
305
  removedProviders.add(provider);
259
306
  }
260
307
  lastReconciledGeneration = generation;
@@ -2,7 +2,11 @@ import { createHash } from "node:crypto";
2
2
  import { readBoundedResponseBody } from "../lib/bounded-body";
3
3
  import type { CodexAccountCredentialRecord, OcxConfig } from "../types";
4
4
  import { isSelectableCodexPoolAccount } from "./account-id";
5
- import { getValidCodexToken, loadCodexAccountRecordSnapshot } from "./account-store";
5
+ import {
6
+ beginCodexAccountGenerationLiveCheck,
7
+ getValidCodexToken,
8
+ loadCodexAccountRecordSnapshot,
9
+ } from "./account-store";
6
10
  import {
7
11
  getMainAccountToken,
8
12
  getValidMainAccountToken,
@@ -17,6 +21,14 @@ import { loadPersistedCodexRuntime } from "./runtime";
17
21
  import { codexRuntimeStateEpoch } from "./runtime";
18
22
  import upstreamModelsSnapshot from "./data/upstream-models.json";
19
23
  import { codexCredentialMutationEpoch } from "./credential-mutation-epoch";
24
+ import {
25
+ clearObservedCodexModelDenial,
26
+ forgetObservedCodexModelDenialsForAccount,
27
+ observedDeniedCodexAccountIdsForModel,
28
+ recordObservedCodexModelDenial,
29
+ setObservedDenialGenerationCheck,
30
+ resetObservedCodexModelDenialsForTests,
31
+ } from "./observed-model-denials";
20
32
 
21
33
  const CODEX_MODELS_ENDPOINT = "https://chatgpt.com/backend-api/codex/models";
22
34
 
@@ -870,6 +882,11 @@ function needsEntitlementRefresh(
870
882
  const cached = accountModelsCache.get(cacheKeyFor(accountId, clientVersion));
871
883
  if (cached && cached.credentialIdentity !== credentialIdentity) {
872
884
  invalidateCodexModelEntitlementsForAccount(accountId);
885
+ // The credential itself changed, so evidence gathered under the previous one answers for a
886
+ // different subscription. This is the only call site that knows that: the two gated-model
887
+ // sites in `core-codex-account.ts` invalidate a STALE roster for an unchanged credential,
888
+ // and clearing observed refusals there would discard the very evidence #4906 is about.
889
+ forgetObservedCodexModelDenialsForAccount(accountId);
873
890
  } else if (cached && cached.expiresAt > now) {
874
891
  return false;
875
892
  }
@@ -1280,17 +1297,77 @@ export function cachedDeniedCodexAccountIdsForModel(
1280
1297
  if (state === "granted") granted.add(accountId);
1281
1298
  else if (state === "denied") denied.add(accountId);
1282
1299
  }
1300
+ // The roster is not the only evidence, and on this path it is usually the weaker one. A cached
1301
+ // roster expires in five minutes and nothing on the flagship request path refetches it, so
1302
+ // absent an ongoing catalog sync the loop above contributes nothing at all. An upstream
1303
+ // refusal does not expire on that schedule and is not a snapshot of a pending answer: it is
1304
+ // the account's own Codex surface naming this model and declining it (#4906).
1305
+ // The caller's read fence is passed IN rather than applied to the result, so an excluded
1306
+ // account is skipped before the credential-generation validation reads account storage
1307
+ // (#4952). An excluded account must stay UNKNOWN rather than denied, so a profile switch or
1308
+ // a request-owned credential produces the same selection it does today.
1309
+ const observedDenied = observedDeniedCodexAccountIdsForModel(modelId, now, {
1310
+ ...(options.excludeAccountIds ? { excludeAccountIds: options.excludeAccountIds } : {}),
1311
+ });
1312
+ for (const accountId of observedDenied ?? []) denied.add(accountId);
1283
1313
  // One account holds one entry per client version, and upstream filters the roster by that
1284
1314
  // version. So the same account can legitimately carry a granted entry under a current client
1285
1315
  // and a denied one under an older client that predates the model. Positive evidence is
1286
1316
  // authoritative regardless of which version asked for it -- the same rule
1287
1317
  // `codexModelEntitlementStateForRoster` applies within a single entry -- so a grant anywhere
1288
1318
  // clears the denial rather than being outvoted by whichever entry the map happened to yield
1289
- // last.
1319
+ // last. It outranks an observed refusal for the same reason: a confirmed roster that lists the
1320
+ // model is the newer answer, and a rollout that reaches an account must not be held back by a
1321
+ // refusal it has already superseded.
1290
1322
  for (const accountId of granted) denied.delete(accountId);
1291
1323
  return denied.size > 0 ? denied : undefined;
1292
1324
  }
1293
1325
 
1326
+ /**
1327
+ * Record an authenticated upstream refusal as this account's own evidence about `modelId`.
1328
+ *
1329
+ * Scoped to {@link ENTITLEMENT_PREFERRED_NATIVE_OPENAI_MODELS} because that is the set whose
1330
+ * availability varies per account while the row stays visible, and it is the set
1331
+ * {@link cachedDeniedCodexAccountIdsForModel} will read back. A model outside it either has no
1332
+ * per-account variance or is gated by the fail-closed roster path, where an ordering preference
1333
+ * would change nothing.
1334
+ *
1335
+ * The caller must have matched the exact allow-listed refusal body first. A status alone is not
1336
+ * admissible here: 400 covers every malformed request too, and remembering one of those as an
1337
+ * entitlement fact would steer routing away from a perfectly capable account.
1338
+ */
1339
+ // Denial evidence is credential-scoped (#4952). The store stays a leaf module, so the
1340
+ // liveness predicate is injected here, where the account store is already a dependency.
1341
+ setObservedDenialGenerationCheck(beginCodexAccountGenerationLiveCheck);
1342
+
1343
+ export function recordCodexModelDenialEvidence(
1344
+ accountId: string | null | undefined,
1345
+ modelId: string | undefined,
1346
+ generation: number | null | undefined,
1347
+ now = Date.now(),
1348
+ ): void {
1349
+ if (!accountId || !modelId) return;
1350
+ if (!ENTITLEMENT_PREFERRED_NATIVE_OPENAI_MODELS.has(modelId)) return;
1351
+ // A caller that cannot name a credential generation records ACCOUNT-scoped evidence rather
1352
+ // than none. The one production context in that position is `main-pool`, whose credential
1353
+ // lives in `auth.json` and has no pool generation; discarding its refusals would revert
1354
+ // #4906 for the stored main login (#4952). Request-owned `main` never reaches here — its
1355
+ // `accountId` is null and the guard above returns.
1356
+ recordObservedCodexModelDenial(accountId, modelId, typeof generation === "number" ? generation : undefined, now);
1357
+ }
1358
+
1359
+ /** Drop the refusal evidence for a pair the account has just served successfully. */
1360
+ export function clearCodexModelDenialEvidence(
1361
+ accountId: string | null | undefined,
1362
+ modelId: string | undefined,
1363
+ generation: number | null | undefined,
1364
+ ): void {
1365
+ if (!accountId || !modelId) return;
1366
+ // Mirror of the write: an account-scoped success clears account-scoped evidence. It cannot
1367
+ // clear a credential-scoped entry that names a newer generation, and vice versa (#4952).
1368
+ clearObservedCodexModelDenial(accountId, modelId, typeof generation === "number" ? generation : undefined);
1369
+ }
1370
+
1294
1371
  /** Synchronous projection for management/catalog readers after a discovery pass. */
1295
1372
  export function cachedAvailableAccountGatedNativeModels(
1296
1373
  now = Date.now(),
@@ -1351,6 +1428,7 @@ export function resetCodexModelEntitlementCacheForTests(): void {
1351
1428
  negativeCredentialMemo.clear();
1352
1429
  entitlementEnsureFlights.clear();
1353
1430
  runtimeVersionMemo = null;
1431
+ resetObservedCodexModelDenialsForTests();
1354
1432
  }
1355
1433
 
1356
1434
  /** Test-only snapshot for proving publication fences, which cache lookup intentionally masks. */
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Per-account model denials observed from an authenticated upstream refusal.
3
+ *
4
+ * [Decision Log]
5
+ * - 목적과 의도: Keep the one piece of account-specific model evidence that is never stale --
6
+ * the upstream's own refusal -- instead of discarding it after a single retry.
7
+ * - 기존 구현 및 제약 조건: `cachedDeniedCodexAccountIdsForModel` reads authenticated `/models`
8
+ * rosters, and those entries expire five minutes after they are fetched
9
+ * (`MODEL_ROSTER_TTL_MS`). Nothing on the flagship request path refills them, because
10
+ * `resolveCodexModelEntitlements` is awaited only for `ACCOUNT_GATED_NATIVE_OPENAI_MODELS`,
11
+ * which no longer holds the flagships. So the denial set is usually absent, the #4797
12
+ * ordering rules become the identity function, and the pool picks on quota alone (#4906).
13
+ * - 검토한 주요 대안: Fetch a roster on the flagship request path, lengthen the roster TTL, or
14
+ * infer availability from the account's plan name.
15
+ * - 선택한 방식: Record the exact upstream unsupported-model refusal per (account, model) and
16
+ * let selection read it alongside the roster.
17
+ * - 다른 대안 대신 이 방식을 선택한 이유: A roster fetch on the request path puts an
18
+ * authenticated upstream call in front of the most commonly requested models in the product,
19
+ * which is what the cache-only contract exists to prevent. A longer TTL keeps a shard's
20
+ * stale absence around for longer without adding any evidence. A plan name proves nothing
21
+ * about a grant -- `available_in_plans` for `gpt-6-astra` lists `free` while free accounts
22
+ * are refused -- and #3022 is what happened the last time availability was inferred rather
23
+ * than observed.
24
+ * - 장점, 단점 및 영향: A refusal is spent once and then remembered, so the pool stops
25
+ * re-sending a model to the account that just refused it. The evidence is confirmed rather
26
+ * than inferred, it is still only an ordering preference, and it is overridden by any
27
+ * positive roster grant for the same pair.
28
+ *
29
+ * What this deliberately is NOT: an eligibility filter. Readers treat these ids exactly like
30
+ * roster denials -- `withoutModelDeniedAccounts` restores them when filtering would empty the
31
+ * candidate list, and `preferModelEntitledAccount` leaves the active account alone when no
32
+ * entitled alternative exists. No model is hidden from any catalog and no request is refused
33
+ * before dispatch, so the 2026-09-04 owner decision that the flagships fail open is untouched.
34
+ */
35
+
36
+ /**
37
+ * Six hours, against a five-minute roster TTL.
38
+ *
39
+ * The asymmetry is the point. A roster entry is a snapshot of an answer that may simply not
40
+ * have arrived yet, so it expires quickly and absence means "unknown". A refusal is an answer:
41
+ * upstream named this model and this account and said no. It still expires, because a rollout
42
+ * can reach an account between two requests, and the two faster paths back are a positive
43
+ * roster grant (which overrides this outright) and a successful response from the same account
44
+ * for the same model (which clears the entry).
45
+ */
46
+ const OBSERVED_DENIAL_TTL_MS = 6 * 60 * 60_000;
47
+
48
+ /** Bounded like the roster cache: pool size times flagship count, with room to spare. */
49
+ const OBSERVED_DENIAL_MAX_ENTRIES = 512;
50
+
51
+ /**
52
+ * One entry of refusal evidence.
53
+ *
54
+ * `generation` is the credential generation the refusal was observed under (#4952). Evidence
55
+ * is about a CREDENTIAL, not an account id: reauthenticating the same internal account can
56
+ * swap the subscription underneath it, so a refusal from the previous credential says nothing
57
+ * about the replacement. Carrying the generation lets a reader ignore superseded evidence and
58
+ * lets a late reply from an older generation be refused rather than applied.
59
+ *
60
+ * It is OPTIONAL because not every account that can be refused has one. A `main-pool` context
61
+ * — the stored main login taking part in rotation — carries a real `accountId` and a
62
+ * `writerGeneration`, but no pool credential generation, because its credential lives in
63
+ * `auth.json` rather than the pool store. Dropping its evidence would have silently reverted
64
+ * #4906 for that account: the pool would re-send the same model to the login that just refused
65
+ * it, on every request, forever. An entry without a generation is account-scoped, and the
66
+ * generation fences below simply do not apply to it. This is the same rule the quota writer
67
+ * already uses in `core-codex-account.ts`, where an absent credential generation skips the
68
+ * liveness check instead of discarding the write.
69
+ */
70
+ interface ObservedDenial {
71
+ expiresAt: number;
72
+ generation?: number;
73
+ }
74
+
75
+ /** `accountId\u0000modelId` -> entry. Insertion order is the eviction order. */
76
+ const observedDenials = new Map<string, ObservedDenial>();
77
+
78
+ /** Whether `generation` is still the account's live credential. */
79
+ type GenerationLiveCheck = (accountId: string, generation: number) => boolean;
80
+
81
+ /**
82
+ * Opens one liveness check.
83
+ *
84
+ * A FACTORY rather than a bare predicate because the production implementation reads the
85
+ * credential store, and a lookup can ask about several accounts. Loading once per lookup and
86
+ * closing over that snapshot is the shape `loadCodexAccountRecordSnapshot` exists for; a bare
87
+ * predicate would reload and reparse the whole store per row, on the request path.
88
+ *
89
+ * Injected rather than imported at module scope so this stays a leaf module: `account-store`
90
+ * reads the credential file, and a unit test of this map should not have to stand one up.
91
+ */
92
+ type GenerationLiveCheckFactory = () => GenerationLiveCheck;
93
+
94
+ let beginGenerationLiveCheck: GenerationLiveCheckFactory = () => () => true;
95
+
96
+ /** Wire the liveness predicate. Called once at startup; tests substitute their own. */
97
+ export function setObservedDenialGenerationCheck(begin: GenerationLiveCheckFactory): void {
98
+ beginGenerationLiveCheck = begin;
99
+ }
100
+
101
+ function denialKey(accountId: string, modelId: string): string {
102
+ return `${accountId}\u0000${modelId}`;
103
+ }
104
+
105
+ function accountIdOfDenialKey(key: string): string {
106
+ return key.slice(0, key.indexOf("\u0000"));
107
+ }
108
+
109
+ function modelIdOfDenialKey(key: string): string {
110
+ return key.slice(key.indexOf("\u0000") + 1);
111
+ }
112
+
113
+ /**
114
+ * Remember that `accountId` was refused `modelId` by its own authenticated upstream.
115
+ *
116
+ * Re-recording refreshes the entry rather than extending an older one, so a pair that keeps
117
+ * being refused stays remembered and one that stops being refused ages out.
118
+ */
119
+ export function recordObservedCodexModelDenial(
120
+ accountId: string,
121
+ modelId: string,
122
+ generation: number | undefined,
123
+ now = Date.now(),
124
+ ): void {
125
+ // A refusal dispatched under generation G can arrive after G+1 has been saved. Reject it
126
+ // BEFORE touching the map at all, not merely when this key already holds newer evidence:
127
+ // with no entry for this key the stale row would otherwise be inserted, and at the entry
128
+ // bound it would evict a valid row that nothing can restore (#4952).
129
+ if (generation !== undefined && !beginGenerationLiveCheck()(accountId, generation)) return;
130
+ const key = denialKey(accountId, modelId);
131
+ const existing = observedDenials.get(key);
132
+ // Second fence, for the window where the replacement credential has been dispatched but the
133
+ // store read above still answers live. Both sides must name a generation to be comparable;
134
+ // an account-scoped entry is not older or newer than a credential-scoped one.
135
+ if (existing !== undefined && existing.generation !== undefined && generation !== undefined
136
+ && existing.generation > generation) return;
137
+ // Delete before set so the refreshed entry moves to the back of the eviction order.
138
+ observedDenials.delete(key);
139
+ observedDenials.set(key, { expiresAt: now + OBSERVED_DENIAL_TTL_MS, generation });
140
+ while (observedDenials.size > OBSERVED_DENIAL_MAX_ENTRIES) {
141
+ const oldest = observedDenials.keys().next();
142
+ if (oldest.done) break;
143
+ observedDenials.delete(oldest.value);
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Forget one pair, because the account just served the model.
149
+ *
150
+ * A success is newer and stronger evidence than the refusal that preceded it: whatever the
151
+ * entitlement was when upstream refused, it is not that now.
152
+ */
153
+ export function clearObservedCodexModelDenial(
154
+ accountId: string,
155
+ modelId: string,
156
+ generation: number | undefined,
157
+ ): void {
158
+ const key = denialKey(accountId, modelId);
159
+ const existing = observedDenials.get(key);
160
+ if (!existing) return;
161
+ // The mirror of the write fence: a success dispatched under G arriving after G+1 was
162
+ // refused must not clear the replacement's evidence (#4952). Equal generations clear,
163
+ // because that is the ordinary "this account just served this model" case.
164
+ if (existing.generation !== undefined && generation !== undefined
165
+ && existing.generation > generation) return;
166
+ observedDenials.delete(key);
167
+ }
168
+
169
+ /**
170
+ * Forget every pair for one account, because its credential changed.
171
+ *
172
+ * A reauthenticated account can be a different subscription entirely, so evidence gathered
173
+ * under the previous credential says nothing about this one -- the same reasoning
174
+ * `invalidateCodexModelEntitlementsForAccount` applies to cached rosters.
175
+ */
176
+ export function forgetObservedCodexModelDenialsForAccount(accountId: string | null | undefined): void {
177
+ if (!accountId) return;
178
+ for (const key of [...observedDenials.keys()]) {
179
+ if (accountIdOfDenialKey(key) === accountId) observedDenials.delete(key);
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Accounts refused `modelId` within the retention window.
185
+ *
186
+ * Returns `undefined` rather than an empty set when nothing is recorded, matching
187
+ * `cachedDeniedCodexAccountIdsForModel`: a caller must not be able to read "no evidence" as
188
+ * "nobody is denied".
189
+ */
190
+ export function observedDeniedCodexAccountIdsForModel(
191
+ modelId: string | undefined,
192
+ now = Date.now(),
193
+ options: { excludeAccountIds?: ReadonlySet<string> } = {},
194
+ ): ReadonlySet<string> | undefined {
195
+ if (!modelId) return undefined;
196
+ const denied = new Set<string>();
197
+ // Opened lazily and at most once: a lookup that matches no credential-scoped row must not
198
+ // read the credential store at all.
199
+ let isLive: GenerationLiveCheck | undefined;
200
+ for (const [key, entry] of [...observedDenials]) {
201
+ if (entry.expiresAt <= now) {
202
+ observedDenials.delete(key);
203
+ continue;
204
+ }
205
+ // Model and the caller's exclusion fence FIRST. The issue asks for identity validation
206
+ // after the exclusion read, and an excluded account — a draining profile switch, or a
207
+ // request-owned credential — must produce no credential-store read on its behalf.
208
+ if (modelIdOfDenialKey(key) !== modelId) continue;
209
+ const accountId = accountIdOfDenialKey(key);
210
+ if (options.excludeAccountIds?.has(accountId)) continue;
211
+ if (entry.generation !== undefined) {
212
+ isLive ??= beginGenerationLiveCheck();
213
+ // Superseded evidence stops denying the replacement. It is SKIPPED, not deleted: the
214
+ // predicate cannot tell "this account reauthenticated" from "the credential store could
215
+ // not be read", and deleting on the second would throw away valid evidence that a
216
+ // transient read failure was never entitled to touch. Skipping already delivers the
217
+ // routing outcome the issue asks for, on every read, without depending on the
218
+ // conditional account-wide forget that cannot run when no roster was ever cached.
219
+ // Superseded rows still leave by TTL, by eviction, and by the account-wide forget.
220
+ if (!isLive(accountId, entry.generation)) continue;
221
+ }
222
+ denied.add(accountId);
223
+ }
224
+ return denied.size > 0 ? denied : undefined;
225
+ }
226
+
227
+ export function resetObservedCodexModelDenialsForTests(): void {
228
+ observedDenials.clear();
229
+ beginGenerationLiveCheck = () => () => true;
230
+ }
@@ -0,0 +1,94 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, parse, relative, resolve } from "node:path";
3
+
4
+ const MAX_AUTH_BYTES = 256 * 1024;
5
+ export const ORCA_ACCOUNT_DIRECTORY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
+
7
+ export function sameLocalPath(left: string, right: string): boolean {
8
+ return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
9
+ }
10
+
11
+ /** Reject links at every component; never follow an external credential reference. */
12
+ export function assertPlainLocalPath(path: string): string {
13
+ if (/^(?:\\\\|\/\/)/.test(path)) throw new Error("Network and device credential paths are unsupported.");
14
+ const absolute = resolve(path);
15
+ let current = parse(absolute).root;
16
+ for (const component of relative(current, absolute).split(/[\\/]/).filter(Boolean)) {
17
+ current = join(current, component);
18
+ if (lstatSync(current).isSymbolicLink()) throw new Error("Unsafe local credential path.");
19
+ }
20
+ if (!sameLocalPath(realpathSync(absolute), absolute)) throw new Error("Unsafe local credential path.");
21
+ return absolute;
22
+ }
23
+
24
+ export function readBoundedLocalFile(path: string, limit = MAX_AUTH_BYTES): string {
25
+ assertPlainLocalPath(path);
26
+ const before = lstatSync(path);
27
+ if (!before.isFile() || before.size > limit) throw new Error("Invalid local credential file.");
28
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
29
+ try {
30
+ const opened = fstatSync(fd);
31
+ if (!opened.isFile() || opened.size > limit || before.dev !== opened.dev || before.ino !== opened.ino) {
32
+ throw new Error("Local credential file changed.");
33
+ }
34
+ // The cap bounds the read, not the allocation: tiny registry files should not
35
+ // allocate 32 MiB on each preview/recheck. One extra byte detects concurrent growth.
36
+ const bytes = Buffer.alloc(opened.size + 1);
37
+ let length = 0;
38
+ while (length < bytes.length) {
39
+ const count = readSync(fd, bytes, length, bytes.length - length, null);
40
+ if (!count) break;
41
+ length += count;
42
+ }
43
+ if (length !== opened.size) throw new Error("Local credential file changed.");
44
+ assertPlainLocalPath(path);
45
+ const after = lstatSync(path);
46
+ if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs) {
47
+ throw new Error("Local credential file changed.");
48
+ }
49
+ return bytes.subarray(0, length).toString("utf8");
50
+ } finally { closeSync(fd); }
51
+ }
52
+
53
+ function object(value: unknown): Record<string, unknown> {
54
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid local OAuth credential.");
55
+ return value as Record<string, unknown>;
56
+ }
57
+
58
+ export function parseOrcaAuth(raw: string, requireFresh = true) {
59
+ const auth = object(JSON.parse(raw));
60
+ if ((auth.auth_mode !== undefined && auth.auth_mode !== "chatgpt")
61
+ || (auth.OPENAI_API_KEY !== undefined && auth.OPENAI_API_KEY !== null && auth.OPENAI_API_KEY !== "")) {
62
+ throw new Error("Expected a ChatGPT OAuth credential.");
63
+ }
64
+ const tokens = object(auth.tokens);
65
+ if (typeof tokens.access_token !== "string" || typeof tokens.account_id !== "string" || !tokens.account_id) {
66
+ throw new Error("Local OAuth credential lacks account identity.");
67
+ }
68
+ const parts = tokens.access_token.split(".");
69
+ if (parts.length !== 3) throw new Error("Invalid local OAuth bearer.");
70
+ const claims = object(JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8")));
71
+ const identity = object(claims["https://api.openai.com/auth"]);
72
+ if (identity.chatgpt_account_id !== tokens.account_id || typeof claims.sub !== "string" || !claims.sub
73
+ || typeof claims.exp !== "number" || !Number.isSafeInteger(claims.exp) || !Number.isSafeInteger(claims.exp * 1000)
74
+ || (requireFresh && claims.exp * 1000 <= Date.now() + 60_000)) {
75
+ throw new Error("Local OAuth identity or expiry is invalid.");
76
+ }
77
+ return { accessToken: tokens.access_token, refreshToken: "", expiresAt: claims.exp * 1000,
78
+ chatgptAccountId: tokens.account_id, sourceSubject: claims.sub };
79
+ }
80
+
81
+ export function readOrcaAuthSource(path: string) {
82
+ if (!isAbsolute(path) || !ORCA_ACCOUNT_DIRECTORY.test(dirname(dirname(path)).split(/[\\/]/).pop() ?? "")
83
+ || dirname(path).split(/[\\/]/).pop() !== "home"
84
+ || dirname(dirname(dirname(path))).split(/[\\/]/).pop() !== "codex-accounts"
85
+ || path.split(/[\\/]/).pop() !== "auth.json") throw new Error("Invalid Orca credential location.");
86
+ try {
87
+ const accountDirectory = dirname(dirname(path)).split(/[\\/]/).pop()!;
88
+ if (readBoundedLocalFile(join(dirname(path), ".orca-managed-home"), 128).trim() !== accountDirectory) {
89
+ throw new Error("Orca home ownership marker does not match.");
90
+ }
91
+ return { ...parseOrcaAuth(readBoundedLocalFile(path)), sourceAuthPath: path };
92
+ }
93
+ catch { throw new Error("Orca credential unavailable; update the account in Orca and retry."); }
94
+ }