@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
@@ -41,7 +41,7 @@ export type { RaycastAbility, RaycastAbilityName, RaycastModelEntry, RaycastProv
41
41
  export { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast";
42
42
 
43
43
  import type { OpencodeLaunchEnv, OpencodeCatalogModel, ExportContext, PiModelEntry, ManagedContribution, ManagedFragment, ExportClientId, ExportClientSpec } from "./config-export/contracts";
44
- import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, GAJAE_API_KEY_ENV, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV } from "./config-export/constants";
44
+ import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV } from "./config-export/constants";
45
45
  import { exportModelLabel, authoritativeContextWindow, outputBudgetFor, normalizeExportModels, inputModalitiesForClient, opencodeModelCapabilities, proxyAdmissionHeaders, singleFragment } from "./config-export/model-metadata";
46
46
  import { buildOmpClientConfig, summarizeOmp, buildOmpContribution } from "./config-export/omp";
47
47
  import { buildDshClientConfig, summarizeDsh, buildDshContribution } from "./config-export/dsh";
@@ -867,7 +867,7 @@ export interface GajaeModelEntry {
867
867
  /** Gajae validates strictly: an unknown field fails the whole config. */
868
868
  export interface GajaeProviderBlock {
869
869
  baseUrl: string;
870
- apiKeyEnv: string;
870
+ apiKey: string;
871
871
  api: "openai-completions";
872
872
  models: GajaeModelEntry[];
873
873
  }
@@ -1056,7 +1056,7 @@ function buildGajaeClientConfig(ctx: ExportContext): GajaeGeneratedConfig {
1056
1056
  providers: {
1057
1057
  [OPENCODE_PROVIDER_ID]: {
1058
1058
  baseUrl: ctx.baseUrl,
1059
- apiKeyEnv: GAJAE_API_KEY_ENV,
1059
+ apiKey: LOOPBACK_API_KEY_PLACEHOLDER,
1060
1060
  api: "openai-completions",
1061
1061
  models,
1062
1062
  },
@@ -1320,8 +1320,8 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
1320
1320
  id: "gajae",
1321
1321
  filename: "gajae-models.yaml",
1322
1322
  destination: env => gajaeConfigPath(env),
1323
- apiKeyEnv: GAJAE_API_KEY_ENV,
1324
- exportHint: `export ${GAJAE_API_KEY_ENV}=<your key>`,
1323
+ apiKeyEnv: "",
1324
+ exportHint: "No environment variable is needed for the loopback provider.",
1325
1325
  build: buildGajaeClientConfig,
1326
1326
  format: "yaml",
1327
1327
  summarize: summarizeGajae,
@@ -1,3 +1,4 @@
1
+ import { readOrcaAuthSource } from "./orca-auth-source";
1
2
  import { createHash } from "node:crypto";
2
3
  import { closeSync, existsSync, fstatSync, readFileSync, mkdirSync, openSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
4
  import { join } from "node:path";
@@ -52,11 +53,20 @@ function isObject(value: unknown): value is Record<string, unknown> {
52
53
  }
53
54
 
54
55
  function isCredential(value: unknown): value is CodexAccountCredentials {
56
+ const hasNoSource = isObject(value)
57
+ && value.sourceAuthPath === undefined
58
+ && value.sourceSubject === undefined;
59
+ const hasCompleteSource = isObject(value)
60
+ && typeof value.sourceAuthPath === "string"
61
+ && value.sourceAuthPath.length > 0
62
+ && typeof value.sourceSubject === "string"
63
+ && value.sourceSubject.length > 0;
55
64
  return isObject(value)
56
65
  && typeof value.accessToken === "string"
57
66
  && typeof value.refreshToken === "string"
58
67
  && typeof value.expiresAt === "number"
59
- && typeof value.chatgptAccountId === "string";
68
+ && typeof value.chatgptAccountId === "string"
69
+ && (hasNoSource || hasCompleteSource);
60
70
  }
61
71
 
62
72
  function isCredentialRecord(value: unknown): value is CodexAccountCredentialRecord {
@@ -78,6 +88,7 @@ export function refreshGrantFingerprintForToken(refreshToken: string): string {
78
88
  }
79
89
 
80
90
  function recordGrantFingerprint(record: CodexAccountCredentialRecord): string | undefined {
91
+ if (record.credential?.sourceAuthPath) return undefined;
81
92
  return record.refreshGrantFingerprint ?? (
82
93
  record.credential ? refreshGrantFingerprintForToken(record.credential.refreshToken) : undefined
83
94
  );
@@ -359,6 +370,23 @@ export function isCodexAccountGenerationLive(id: string, generation: number): bo
359
370
  return !!record?.credential && record.deletedAt == null && record.generation === generation;
360
371
  }
361
372
 
373
+ /**
374
+ * The same verdict as {@link isCodexAccountGenerationLive}, over ONE store load.
375
+ *
376
+ * `readCodexAccountRecord` is `loadCodexAccountRecordStore()[id]`, so asking it per row
377
+ * reloads, reparses and renormalizes the whole file per row — the exact shape
378
+ * {@link loadCodexAccountRecordSnapshot} was added to avoid. The denial reader resolves
379
+ * several accounts in one synchronous pass on the request path, so it opens one checker and
380
+ * closes over the snapshot instead.
381
+ */
382
+ export function beginCodexAccountGenerationLiveCheck(): (id: string, generation: number) => boolean {
383
+ const snapshot = loadCodexAccountRecordSnapshot();
384
+ return (id, generation) => {
385
+ const record = snapshot[id];
386
+ return !!record?.credential && record.deletedAt == null && record.generation === generation;
387
+ };
388
+ }
389
+
362
390
  export function saveCodexAccountCredentialIfGeneration(
363
391
  id: string,
364
392
  generation: number,
@@ -426,7 +454,7 @@ export function commitRefreshedCodexCredentialWithAliases(
426
454
  return withCredentialMutationLockSync(() => {
427
455
  const store = loadCodexAccountRecordStore();
428
456
  const current = store[id];
429
- if (!current || current.generation !== generation || current.deletedAt != null || !current.credential) {
457
+ if (!current || current.generation !== generation || current.deletedAt != null || !current.credential || current.credential.sourceAuthPath) {
430
458
  return { committed: false, propagatedAliases: [] };
431
459
  }
432
460
  const priorCredential = current.credential;
@@ -458,7 +486,7 @@ export function commitRefreshedCodexCredentialWithAliases(
458
486
  && priorCredential.chatgptAccountId === cred.chatgptAccountId
459
487
  ) {
460
488
  for (const [aliasId, alias] of Object.entries(store)) {
461
- if (aliasId === id || alias.deletedAt != null || !alias.credential) continue;
489
+ if (aliasId === id || alias.deletedAt != null || !alias.credential || alias.credential.sourceAuthPath) continue;
462
490
  if (recordGrantFingerprint(alias) !== priorFingerprint) continue;
463
491
  if (alias.credential.accessToken !== priorCredential.accessToken) continue;
464
492
  if (alias.credential.expiresAt !== priorCredential.expiresAt) continue;
@@ -585,6 +613,41 @@ function withCredentialMutationLockSync<T>(fn: () => T): T {
585
613
  }
586
614
 
587
615
  type CodexTokenResult = { accessToken: string; chatgptAccountId: string; generation: number };
616
+ type CodexRefreshGenerationHandoff = (accountId: string, fromGeneration: number, toGeneration: number) => void;
617
+ // var (hoisted, initialized to undefined) rather than const: a registrar reached
618
+ // through an import cycle can register while this module body is still evaluating,
619
+ // and the lazily created Set must be reachable rather than in the temporal dead zone.
620
+ var refreshGenerationHandoffs: Set<CodexRefreshGenerationHandoff> | undefined;
621
+ function refreshHandoffs(): Set<CodexRefreshGenerationHandoff> {
622
+ return refreshGenerationHandoffs ??= new Set();
623
+ }
624
+
625
+ /** Register process-local state that must follow a credential refresh generation. */
626
+ export function registerCodexRefreshGenerationHandoff(handoff: CodexRefreshGenerationHandoff): () => void {
627
+ const set = refreshHandoffs();
628
+ set.add(handoff);
629
+ return () => set.delete(handoff);
630
+ }
631
+
632
+ /**
633
+ * Invoke every registered handoff for one committed `fromGeneration` to `toGeneration` move.
634
+ *
635
+ * Each listener runs independently and a throw is contained: by the time handoffs run the
636
+ * rotated credential is already persisted, so a failing listener must not reject the shared
637
+ * refresh promise (surviving waiters would see a refresh failure that never happened), must
638
+ * not starve the remaining listeners, and must not block plan reconciliation. The warning
639
+ * carries no account id or token material — the same scrub refresh error messages get.
640
+ */
641
+ function dispatchRefreshGenerationHandoffs(accountId: string, fromGeneration: number, toGeneration: number): void {
642
+ for (const handoff of refreshHandoffs()) {
643
+ try {
644
+ handoff(accountId, fromGeneration, toGeneration);
645
+ } catch (error) {
646
+ console.warn("[codex-auth] a refresh generation handoff listener failed", error);
647
+ }
648
+ }
649
+ }
650
+
588
651
  type CodexRefreshResult = CodexTokenResult & {
589
652
  credential?: CodexAccountCredentials;
590
653
  /**
@@ -630,7 +693,14 @@ export type CodexRefreshProvenance = "self-refresh" | "joined-lineage" | "extern
630
693
 
631
694
  /** Terminal outcome of one forced refresh, as seen by the caller that requested it. */
632
695
  export type ForcedRefreshOutcome =
633
- | { kind: "resolved"; provenance: CodexRefreshProvenance; generation: number; rotated: boolean }
696
+ | {
697
+ kind: "resolved";
698
+ provenance: CodexRefreshProvenance;
699
+ generation: number;
700
+ rotated: boolean;
701
+ /** Same-grant records advanced by this refresh, at their committed generations. */
702
+ propagatedAliases?: { id: string; generation: number }[];
703
+ }
634
704
  | { kind: "failed"; error: unknown };
635
705
  const MAX_CODEX_REFRESH_FLIGHTS = 32;
636
706
  const CODEX_REFRESH_FLIGHT_STALE_MS = 120_000;
@@ -776,7 +846,7 @@ function findFreshCredentialForGrant(
776
846
  // require both ids to be present and exactly equal rather than inferring identity from the grant.
777
847
  if (!expectedChatgptAccountId) return null;
778
848
  for (const [candidateId, candidate] of Object.entries(records)) {
779
- if (candidateId === excludeId || candidate.deletedAt != null || !candidate.credential) continue;
849
+ if (candidateId === excludeId || candidate.deletedAt != null || !candidate.credential || candidate.credential.sourceAuthPath) continue;
780
850
  if (recordGrantFingerprint(candidate) !== refreshGrantFingerprint) continue;
781
851
  if (!candidate.credential.chatgptAccountId) continue;
782
852
  if (candidate.credential.chatgptAccountId !== expectedChatgptAccountId) continue;
@@ -926,6 +996,9 @@ export async function forceRefreshCodexPoolToken(
926
996
  provenance: classify(resolved),
927
997
  generation: resolved.generation,
928
998
  rotated: resolved.accessToken !== options.rejectedAccessToken,
999
+ ...(resolved.propagatedAliases?.length
1000
+ ? { propagatedAliases: resolved.propagatedAliases }
1001
+ : {}),
929
1002
  });
930
1003
  },
931
1004
  error => {
@@ -967,6 +1040,57 @@ export async function getValidCodexToken(
967
1040
  };
968
1041
  }
969
1042
 
1043
+ /** Source credentials never join refresh flights or spend a refresh grant, including after 401. */
1044
+ function resolveOrcaSourceToken(id: string, forced?: ForcedRefreshFence): CodexRefreshResult {
1045
+ const initial = readCodexAccountRecord(id);
1046
+ const initialCredential = initial?.credential;
1047
+ if (!initial || initial.deletedAt != null || !initialCredential?.sourceAuthPath || !initialCredential.sourceSubject) {
1048
+ throw new CodexCredentialGenerationConflictError();
1049
+ }
1050
+ const sourceCredential = readOrcaAuthSource(initialCredential.sourceAuthPath);
1051
+ if (sourceCredential.chatgptAccountId !== initialCredential.chatgptAccountId
1052
+ || sourceCredential.sourceSubject !== initialCredential.sourceSubject) {
1053
+ throw new Error("Orca credential identity changed; reimport the account explicitly.");
1054
+ }
1055
+ if (sourceCredential.accessToken === initialCredential.accessToken
1056
+ && sourceCredential.expiresAt === initialCredential.expiresAt) {
1057
+ if (forced?.rejectedAccessToken === sourceCredential.accessToken) {
1058
+ throw new Error("Orca bearer was rejected; update the account in Orca and retry.");
1059
+ }
1060
+ return { accessToken: sourceCredential.accessToken, chatgptAccountId: sourceCredential.chatgptAccountId,
1061
+ generation: initial.generation, provenance: "external-replacement" };
1062
+ }
1063
+
1064
+ return withCredentialMutationLockSync(() => {
1065
+ const store = loadCodexAccountRecordStore();
1066
+ const record = store[id];
1067
+ const prior = record?.credential;
1068
+ if (!record || record.deletedAt != null || !prior?.sourceAuthPath || !prior.sourceSubject) {
1069
+ throw new CodexCredentialGenerationConflictError();
1070
+ }
1071
+ if (record.generation !== initial.generation) throw new CodexCredentialGenerationConflictError();
1072
+ const credential = readOrcaAuthSource(prior.sourceAuthPath);
1073
+ if (credential.chatgptAccountId !== prior.chatgptAccountId || credential.sourceSubject !== prior.sourceSubject) {
1074
+ throw new Error("Orca credential identity changed; reimport the account explicitly.");
1075
+ }
1076
+ if (credential.accessToken !== prior.accessToken || credential.expiresAt !== prior.expiresAt) {
1077
+ store[id] = {
1078
+ credential, generation: record.generation + 1, replacedAt: Date.now(),
1079
+ // The source identity was checked above. Retire old-generation writers without
1080
+ // splitting this same account's retained quota history on every source rotation.
1081
+ quotaHistoryIdentity: record.quotaHistoryIdentity,
1082
+ ...preservedValidationMetadata(record),
1083
+ };
1084
+ persistCredentialMutation(store);
1085
+ }
1086
+ if (forced?.rejectedAccessToken === credential.accessToken) {
1087
+ throw new Error("Orca bearer was rejected; update the account in Orca and retry.");
1088
+ }
1089
+ return { accessToken: credential.accessToken, chatgptAccountId: credential.chatgptAccountId,
1090
+ generation: store[id]!.generation, provenance: "external-replacement" };
1091
+ });
1092
+ }
1093
+
970
1094
  async function resolveCodexToken(
971
1095
  id: string,
972
1096
  forced?: ForcedRefreshFence,
@@ -976,6 +1100,7 @@ async function resolveCodexToken(
976
1100
  const record = readCodexAccountRecord(id);
977
1101
  const cred = record?.deletedAt == null ? record?.credential : undefined;
978
1102
  if (!record || !cred) throw new CodexCredentialUnavailableError();
1103
+ if (cred.sourceAuthPath) return resolveOrcaSourceToken(id, forced);
979
1104
  const refreshGrantFingerprint = recordGrantFingerprint(record);
980
1105
  if (!refreshGrantFingerprint) throw new CodexCredentialUnavailableError();
981
1106
 
@@ -1005,6 +1130,7 @@ async function resolveCodexToken(
1005
1130
  const refreshed = await awaitOwnCancellation(existing.promise, callerSignal);
1006
1131
  const current = readCodexAccountRecord(id);
1007
1132
  const currentCred = current?.deletedAt == null ? current?.credential : undefined;
1133
+ if (currentCred?.sourceAuthPath) return resolveOrcaSourceToken(id, forced);
1008
1134
  // The flight owner already committed this credential, and it is the one stored
1009
1135
  // for this account: adopt the stored state instead of CAS-writing the identical
1010
1136
  // bytes, which would bump the generation a second time and invalidate the
@@ -1126,6 +1252,7 @@ async function resolveCodexToken(
1126
1252
  const lockedRecord = readCodexAccountRecord(id);
1127
1253
  const lockedCred = lockedRecord?.deletedAt == null ? lockedRecord?.credential : undefined;
1128
1254
  if (!lockedRecord || !lockedCred) throw new CodexCredentialGenerationConflictError();
1255
+ if (lockedCred.sourceAuthPath) return resolveOrcaSourceToken(id, forced);
1129
1256
  const startGeneration = lockedRecord.generation;
1130
1257
  const lockedRefreshGrantFingerprint = recordGrantFingerprint(lockedRecord);
1131
1258
  if (lockedRefreshGrantFingerprint !== refreshGrantFingerprint) {
@@ -1302,6 +1429,19 @@ async function resolveCodexToken(
1302
1429
  * committed result, for every waiter, including none.
1303
1430
  */
1304
1431
  const refreshPromise = fetchPromise.then(async (result): Promise<CodexRefreshResult> => {
1432
+ // Generation-dependent completion belongs to the flight, not to its initiating
1433
+ // request. The owner may stop waiting after a disconnect while this detached work
1434
+ // still commits G+1; advance process-local affinities before any waiter observes
1435
+ // the result (and even when there are no surviving waiters).
1436
+ if (result.selfRefreshed) {
1437
+ dispatchRefreshGenerationHandoffs(id, result.generation - 1, result.generation);
1438
+ // A propagated alias committed at its OWN generation, so its affinities sit at
1439
+ // alias.generation - 1: without this handoff they fail the exact-generation
1440
+ // liveness check on the very next request that reads them.
1441
+ for (const alias of result.propagatedAliases ?? []) {
1442
+ dispatchRefreshGenerationHandoffs(alias.id, alias.generation - 1, alias.generation);
1443
+ }
1444
+ }
1305
1445
  await notePlanFromRefreshedAccessToken(id, result.accessToken, result.generation);
1306
1446
  // One settlement path for the whole flight: the refreshing account, then any dormant alias that
1307
1447
  // adopted the same rotated JWT. An alias holds the identical access token, so a changed
@@ -1331,6 +1471,7 @@ async function resolveCodexToken(
1331
1471
  // Provenance rides out with the rest: a joiner that adopts this result needs the
1332
1472
  // flight's own classification, not a guess made at the adoption site (#3019).
1333
1473
  ...(result.provenance !== undefined ? { provenance: result.provenance } : {}),
1474
+ ...(result.propagatedAliases?.length ? { propagatedAliases: result.propagatedAliases } : {}),
1334
1475
  ...(result.resolvedGrantFingerprint !== undefined
1335
1476
  ? { resolvedGrantFingerprint: result.resolvedGrantFingerprint }
1336
1477
  : {}),
@@ -82,9 +82,9 @@ export function mainQuotaWithCarriedResetCredits(
82
82
  /**
83
83
  * Why an account needs the operator. `missing_credential`, `refresh_failed`, and
84
84
  * `quota_unauthorized` are the three causes this surface tells apart on its own. `unauthorized`
85
- * and `forbidden` exist because the shared health projection may return them; today
86
- * `projectCodexAccountHealth` only ever produces `refresh_failed`, so accepting the full union
87
- * keeps this field correct if that projection widens rather than silently dropping a reason.
85
+ * and `forbidden` come from the shared health projection: `projectCodexAccountHealth` maps a
86
+ * stored verification failure's `http_status:401`/`http_status:403` to them, so the union has
87
+ * to accept every reason the projection can emit rather than silently dropping one.
88
88
  */
89
89
  export type CodexAccountReauthReason =
90
90
  | "missing_credential"
@@ -105,18 +105,26 @@ export function poolAccountDto(
105
105
  const plan = codexPlanValue(account.plan);
106
106
  const quota = quotaForPlan(quotaResult.quota, plan);
107
107
  const runtimeReauth = isAccountNeedsReauth(account.id);
108
+ const rawReauthReason: CodexAccountReauthReason | undefined = !hasCredential
109
+ ? "missing_credential"
110
+ : quotaResult.reauthReason;
108
111
  const needsReauth = !hasCredential || quotaResult.needsReauth || runtimeReauth;
109
- const health = projectCodexAccountHealth({ accountId: account.id, needsReauth });
112
+ // An in-memory reauth mark carries no cause of its own, so it must not name one: passing
113
+ // a caller reason here would outrank the stored verdict's http_status inside the
114
+ // projection and hide unauthorized/forbidden until the mark is gone. With no caller
115
+ // reason the projection falls back to the persisted cause, then to refresh_failed.
116
+ const healthReason = rawReauthReason === "quota_unauthorized" || rawReauthReason === "missing_credential"
117
+ ? "unauthorized"
118
+ : rawReauthReason;
119
+ const health = projectCodexAccountHealth({
120
+ accountId: account.id,
121
+ needsReauth,
122
+ reauthReason: needsReauth ? healthReason : undefined,
123
+ });
110
124
  // `needsReauth` is an OR of three independent causes plus a persisted verdict resolved inside the
111
125
  // health projection. Emitting only the boolean is what left #4212's reporter guessing which
112
126
  // account took their model away and why, so name the cause they actually have to act on.
113
- const reauthReason: CodexAccountReauthReason | undefined = !hasCredential
114
- ? "missing_credential"
115
- : runtimeReauth
116
- ? "refresh_failed"
117
- : quotaResult.needsReauth
118
- ? "quota_unauthorized"
119
- : health.status === "reauth_required" ? health.reason : undefined;
127
+ const reauthReason: CodexAccountReauthReason | undefined = rawReauthReason ?? (health.status === "reauth_required" ? health.reason : undefined);
120
128
  return {
121
129
  id: account.id,
122
130
  email: projectEmail(account.email, maskEmails) ?? account.email,
@@ -1,4 +1,4 @@
1
- import { capturePoolQuotaWriter, getValidCodexToken, isCodexAccountGenerationLive, forceRefreshCodexPoolToken, markCodexAccountValidated, markCodexAccountValidationFailed, readCodexAccountRecord, CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError, TokenRefreshError } from "../account-store";
1
+ import { capturePoolQuotaWriter, getValidCodexToken, isCodexAccountGenerationLive, forceRefreshCodexPoolToken, markCodexAccountValidated, markCodexAccountValidationFailed, readCodexAccountRecord, isTerminalCodexPoolRefreshFailure, CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError, TokenRefreshError } from "../account-store";
2
2
  import type { PoolQuotaWriter } from "../quota-types";
3
3
  import { isValidWhamHistoryObservation, getAccountQuota, isCompleteCodexQuotaRecoverySnapshot, parseUsageQuota, setAccountQuotaFromParsed } from "../quota";
4
4
  import type { StoredAccountQuota, WhamUsageResponse } from "../quota";
@@ -9,7 +9,7 @@ import { codexWarmupFailureReason, warmCodexAccount } from "../warmup";
9
9
  import { readBoundedResponseBody } from "../../lib/bounded-body";
10
10
  import { ResourceAdmissionError } from "../../lib/admission";
11
11
  import { WHAM_REQUEST_TIMEOUT_MS } from "../quota-recovery-timing";
12
- import { claimQuotaRecovery, quotaRecoveryTerminalFor, releaseQuotaRecovery, settleQuotaRecovery, settleQuotaRecoveryTerminal } from "../quota-401-recovery";
12
+ import { claimQuotaRecovery, fencePropagatedQuotaRecovery, quotaRecoveryTerminalFor, releaseQuotaRecovery, settleQuotaRecovery, settleQuotaRecoveryTerminal } from "../quota-401-recovery";
13
13
  import { seedLoginRowsForTests } from "./login-state";
14
14
  import { nonEmptyPlan } from "./runtime-config";
15
15
 
@@ -46,6 +46,8 @@ export interface PoolQuotaResult {
46
46
  resetRefreshLineage?: ManualResetRefreshLineage;
47
47
  quota: StoredAccountQuota | null;
48
48
  needsReauth: boolean;
49
+ /** Failure source observed while obtaining the quota, when reauthentication is required. */
50
+ reauthReason?: "refresh_failed" | "quota_unauthorized";
49
51
  /** Credential generation whose cache or network result this DTO state belongs to. */
50
52
  credentialGeneration?: number;
51
53
  /** Present only when this call freshly parsed a WHAM usage response. */
@@ -178,7 +180,7 @@ export async function recoverPoolQuotaFrom401(ctx: {
178
180
  // the credential it condemned, so a late terminal response arriving after the operator
179
181
  // re-authenticated would quarantine the replacement.
180
182
  markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration);
181
- return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
183
+ return { quota: existing ?? null, needsReauth: true, reauthReason: "quota_unauthorized", credentialGeneration: rejectedGeneration };
182
184
  }
183
185
 
184
186
  const claim = claimQuotaRecovery(accountId, rejectedGeneration);
@@ -186,7 +188,7 @@ export async function recoverPoolQuotaFrom401(ctx: {
186
188
  // A lineage fenced by a TERMINAL refresh failure stays terminal. Without this, the
187
189
  // budget being used would make the next bare 401 report a dead credential as healthy.
188
190
  if (quotaRecoveryTerminalFor(accountId, rejectedGeneration)) {
189
- return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
191
+ return { quota: existing ?? null, needsReauth: true, reauthReason: "refresh_failed", credentialGeneration: rejectedGeneration };
190
192
  }
191
193
  // Otherwise: this lineage spent its attempt, another caller is mid-refresh, or a
192
194
  // transient failure is backing off. Report transient and let the next poll try —
@@ -205,6 +207,12 @@ export async function recoverPoolQuotaFrom401(ctx: {
205
207
  onSettled: outcome => {
206
208
  if (outcome.kind === "resolved") {
207
209
  settleQuotaRecovery(accountId, claim.claimId, outcome);
210
+ // A propagated alias adopted the rotated grant without spending its own claim. Fence
211
+ // each committed alias generation so a later 401 on it cannot claim a second refresh
212
+ // of the same underlying grant.
213
+ for (const alias of outcome.propagatedAliases ?? []) {
214
+ fencePropagatedQuotaRecovery(alias.id, alias.generation);
215
+ }
208
216
  } else if (outcome.error instanceof TokenRefreshError && isTerminalRefreshError(outcome.error)) {
209
217
  // A revoked or expired grant does not become valid on the next poll. Releasing it
210
218
  // into backoff would let the following bare 401 find a non-terminal record and
@@ -219,8 +227,15 @@ export async function recoverPoolQuotaFrom401(ctx: {
219
227
  // A refresh that failed terminally is the one case where the credential really is gone.
220
228
  // Everything else is unknown, and unknown is not proof.
221
229
  if (e instanceof TokenRefreshError && isTerminalRefreshError(e)) {
230
+ // Persist the same verdict the token guardian writes: the in-memory mark dies with
231
+ // this process, and only the stored terminal failure keeps a cached listing from
232
+ // calling the dead grant healthy after a restart.
233
+ markCodexAccountValidationFailed(accountId, `refresh_${e.reason}`, {
234
+ expectedGeneration: rejectedGeneration,
235
+ terminal: true,
236
+ });
222
237
  markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration);
223
- return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
238
+ return { quota: existing ?? null, needsReauth: true, reauthReason: "refresh_failed", credentialGeneration: rejectedGeneration };
224
239
  }
225
240
  return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration };
226
241
  }
@@ -252,7 +267,7 @@ export async function recoverPoolQuotaFrom401(ctx: {
252
267
  // let the next poll call a dead credential healthy. The evidence is about the
253
268
  // REFRESHED credential, which is what the replay used.
254
269
  markAccountNeedsReauth(accountId, writerGeneration, refreshed.generation);
255
- return { quota: existing ?? null, needsReauth: true, credentialGeneration: refreshed.generation };
270
+ return { quota: existing ?? null, needsReauth: true, reauthReason: "quota_unauthorized", credentialGeneration: refreshed.generation };
256
271
  }
257
272
  return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation };
258
273
  }
@@ -393,9 +408,23 @@ export async function fetchFreshPoolAccountQuota(
393
408
  quotaProbeSkipped: true,
394
409
  }, quotaProbeEvidence);
395
410
  }
396
- if (e instanceof TokenRefreshError) {
411
+ // Terminal means the grant itself is dead or missing; an `unknown` refresh failure (a
412
+ // token-endpoint 5xx, a transport blip) may clear, so it reports transient like the
413
+ // 401-recovery path instead of quarantining a healthy account (#2887). A revoked or
414
+ // expired grant is also written to the record as a terminal validation failure, the same
415
+ // verdict the token guardian persists: the in-memory mark dies with this process, and
416
+ // the stored verdict is what keeps a cached listing from calling the dead grant healthy
417
+ // after a restart.
418
+ if (isTerminalCodexPoolRefreshFailure(e)) {
419
+ if (e instanceof TokenRefreshError && isTerminalRefreshError(e)) {
420
+ markCodexAccountValidationFailed(accountId, `refresh_${e.reason}`, {
421
+ expectedGeneration: requestCredentialGeneration,
422
+ terminal: true,
423
+ });
424
+ }
425
+ markAccountNeedsReauth(accountId, captureConfigGeneration(), requestCredentialGeneration);
397
426
  return withQuotaProbeEvidence(
398
- { quota: existing ?? null, needsReauth: true, credentialGeneration: requestCredentialGeneration },
427
+ { quota: existing ?? null, needsReauth: true, reauthReason: "refresh_failed", credentialGeneration: requestCredentialGeneration },
399
428
  quotaProbeEvidence,
400
429
  );
401
430
  }
@@ -476,9 +505,14 @@ export async function fetchPoolAccountQuota(
476
505
  && generation !== undefined && record.generation === generation
477
506
  && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? configuredPlan)) {
478
507
  try {
508
+ // Quota I/O may outlive the source capture. Never validate a rotated or revoked link
509
+ // using an older generation's observation. Explicit validation consent still applies.
510
+ const sourceToken = record.credential.sourceAuthPath ? await getValidToken(accountId) : null;
511
+ if (sourceToken && (sourceToken.generation !== generation
512
+ || !isCodexAccountGenerationLive(accountId, generation))) return result;
479
513
  await warmCodexAccount({
480
- accessToken: record.credential.accessToken,
481
- chatgptAccountId: record.credential.chatgptAccountId,
514
+ accessToken: sourceToken?.accessToken ?? record.credential.accessToken,
515
+ chatgptAccountId: sourceToken?.chatgptAccountId ?? record.credential.chatgptAccountId,
482
516
  });
483
517
  markCodexAccountValidated(accountId, Date.now(), generation);
484
518
  clearAccountNeedsReauth(accountId, generation);
@@ -7,7 +7,7 @@ import { getAccountQuotaHistory, listAccountQuotas } from "../quota";
7
7
  import { deleteCodexAccount } from "../account-lifecycle";
8
8
  import { isCodexAccountPaused, setCodexAccountPaused } from "../account-pause";
9
9
  import { clearCodexAccountPin, isCodexAccountPriorityKey, pinnedCodexAccountId, setCodexAccountPin, setCodexAccountPriority } from "../account-priority";
10
- import { codexQuotaScopeForModel, clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, resetCodexRoutingForManualSelection } from "../routing";
10
+ import { codexAccountPinDrainReason, codexQuotaScopeForModel, clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, resetCodexRoutingForManualSelection } from "../routing";
11
11
  import { DEFAULT_ACCOUNT_PRIORITY, MAX_ACCOUNT_PRIORITY, MIN_ACCOUNT_PRIORITY, normalizeAccountPoolStickyLimit, normalizeCodexAccountPoolStrategy, parseAccountPoolStickyLimit, parseCodexAccountPoolStrategy, parseAccountPriority } from "../pool-rotation";
12
12
  import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
13
13
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
@@ -235,7 +235,22 @@ export async function handleCodexAuthAPI(
235
235
  else setCodexAccountPin(runtimeConfig, targetAccountId);
236
236
  resetCodexRoutingForManualSelection(targetAccountId);
237
237
  saveRuntimeConfig(config, runtimeConfig);
238
- return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true });
238
+ // A pin this route accepts can still be dropped by the very next resolve, and saying
239
+ // nothing about that is what made the setting look ignored (#4521). The checks above
240
+ // refuse an account that cannot be selected at all; this reports the one remaining
241
+ // outcome they do not cover, from the same predicate routing releases on, so the two
242
+ // cannot drift. Absent means the pin survives — additive for existing clients.
243
+ // `appliesImmediately` is unchanged: it answers whether thread affinity was cleared,
244
+ // not whether the pin is durable.
245
+ const pinDrainReason = body.accountId == null
246
+ ? undefined
247
+ : codexAccountPinDrainReason(runtimeConfig, targetAccountId);
248
+ return jsonResponse({
249
+ ok: true,
250
+ activeCodexAccountId: body.accountId,
251
+ appliesImmediately: true,
252
+ ...(pinDrainReason !== undefined ? { pinDrained: true, pinDrainReason } : {}),
253
+ });
239
254
  }
240
255
 
241
256
  if (url.pathname === "/api/codex-auth/active" && req.method === "GET") {
@@ -135,20 +135,22 @@ export function requestOwnedMainPinState(
135
135
  }
136
136
 
137
137
  /**
138
- * Every thread keys as ITSELF, never as its parent (#4546, wp8).
138
+ * One conversation tree keys as ONE cohort (#4780).
139
139
  *
140
- * The old rule preferred `x-codex-parent-thread-id`, so every child of one parent bound under
141
- * the RAW parent id -- one shared entry, unrelated to the root's own `app:HMAC(session, thread)`
142
- * binding -- and a grandchild keyed on its own parent landed on a key nobody had ever bound.
143
- * A child therefore started cold while its parent was being served warm somewhere, and no
144
- * child could hold a binding of its own.
140
+ * Upstream keys its prompt cache on something the whole tree shares -- `prompt_cache_key` is the
141
+ * session id, or `{source}:{parent_thread_id}` for an internal session -- and one `AgentControl`
142
+ * whose `session_id` is the root thread's id is shared with every sub-agent. While the proxy
143
+ * keyed per thread, two requests could carry an identical `prompt_cache_key` and be served by
144
+ * different accounts, so the split member asserted a warm prefix that was deterministically cold
145
+ * on its account. Nothing failed; the prompt was replayed in full and the tokens burned.
145
146
  *
146
- * Now a request with a `thread-id` keys as HMAC(session ?? parent, thread). A root is
147
- * unchanged, a child gets an independent key, and a request naming only a parent rides the
148
- * parent's lane under HMAC(parent, parent) -- the same one-to-one lane it always had, minus
149
- * the caller-supplied identifier that used to sit in Pool state. Which requests produce no
150
- * key at all is unchanged. First placement for a child is what consults the family, through
151
- * `recordCodexThreadLineage` below and the placement hook in ./routing.
147
+ * THIS IS NOT A REVERT OF #4546 wp8. wp8 fixed a different defect -- a child bound under the RAW
148
+ * parent id, an identity unrelated to the root's own binding, so siblings shared an entry the
149
+ * root was not on and a grandchild landed on a key nobody had bound. A cohort key has no such
150
+ * incoherence, because the root's own binding IS the cohort key. What wp8 additionally gave each
151
+ * thread, a binding of its own, is what this deliberately gives up.
152
+ *
153
+ * Which requests produce no key at all is unchanged, through both units.
152
154
  *
153
155
  * The derivation itself lives in ./lineage so a lineage record's conversation key and the key
154
156
  * the thread actually binds under can never drift apart.
@@ -1419,6 +1421,7 @@ export function materializeCodexUpstreamAuth(
1419
1421
  if (!stored?.accessToken || !isMainAccountTokenLive()) {
1420
1422
  throw new CodexMainSubstitutionUnavailableError();
1421
1423
  }
1424
+ selected.delete("chatgpt-account-id");
1422
1425
  selected.set("authorization", `Bearer ${stored.accessToken}`);
1423
1426
  if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId);
1424
1427
  observeSelectedMainCredential(stored, writer);
@@ -1496,6 +1499,7 @@ export async function materializeCodexUpstreamAuthAsync(
1496
1499
  ...(options.nativeMainRefreshDependencies ?? {}),
1497
1500
  });
1498
1501
  if (!stored?.accessToken) throw new CodexMainSubstitutionUnavailableError();
1502
+ selected.delete("chatgpt-account-id");
1499
1503
  selected.set("authorization", `Bearer ${stored.accessToken}`);
1500
1504
  if (stored.chatgptAccountId) selected.set("chatgpt-account-id", stored.chatgptAccountId);
1501
1505
  observeSelectedMainCredential(stored, writer);