@bitkyc08/opencodex 2.76.0 → 2.78.0-preview.20261005

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 (353) hide show
  1. package/bin/ocx.mjs +11 -10
  2. package/gui/dist/assets/App-CppMz-6w.js +54 -0
  3. package/gui/dist/assets/App-lCIslZ1L.css +1 -0
  4. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-CJWQqtVv.js} +1 -1
  5. package/gui/dist/assets/index-DQt9srNi.js +94 -0
  6. package/gui/dist/assets/index-DmQB8D_R.css +1 -0
  7. package/gui/dist/assets/quota-summary-BMsyoZH2.js +1 -0
  8. package/gui/dist/index.html +4 -3
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/beta-allowlist.ts +19 -2
  11. package/src/adapters/anthropic/client-identity.ts +13 -4
  12. package/src/adapters/anthropic/native-client-preamble.ts +31 -0
  13. package/src/adapters/anthropic/passthrough.ts +103 -25
  14. package/src/adapters/antigravity-refusal.ts +28 -0
  15. package/src/adapters/cursor/protobuf-request.ts +6 -0
  16. package/src/adapters/cursor/request-builder.ts +3 -0
  17. package/src/adapters/cursor/structured-output.ts +13 -0
  18. package/src/adapters/cursor/types.ts +2 -0
  19. package/src/adapters/devin/cloud-direct/chat.ts +7 -2
  20. package/src/adapters/devin/trajectory.ts +49 -0
  21. package/src/adapters/devin.ts +13 -0
  22. package/src/adapters/ollama-native.ts +62 -19
  23. package/src/adapters/openai-responses/passthrough.ts +133 -103
  24. package/src/adapters/openai-responses/request-strips.ts +10 -4
  25. package/src/adapters/zed.ts +26 -4
  26. package/src/bridge/response-json.ts +14 -14
  27. package/src/bridge/sse.ts +15 -5
  28. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  29. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  30. package/src/claude/agents-inject.ts +4 -43
  31. package/src/claude/gateway-cache.ts +16 -13
  32. package/src/claude/inbound-model-options.ts +9 -2
  33. package/src/claude/intercept/cli-catalog.ts +27 -16
  34. package/src/claude/intercept/client-hello.ts +103 -0
  35. package/src/claude/intercept/model-bindings.ts +3 -2
  36. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  37. package/src/claude/intercept/picker-budget.ts +41 -0
  38. package/src/claude/intercept/picker-listener.ts +279 -66
  39. package/src/claude/outbound.ts +2 -0
  40. package/src/claude/subagent-force-status.ts +53 -0
  41. package/src/claude/subagent-model.ts +93 -0
  42. package/src/cli/access-audio-input.ts +84 -0
  43. package/src/cli/access-audio-live.ts +99 -0
  44. package/src/cli/access-audio.ts +123 -0
  45. package/src/cli/access-data-client.ts +127 -0
  46. package/src/cli/access-data-plane.ts +104 -0
  47. package/src/cli/access-data-response.ts +76 -0
  48. package/src/cli/access.ts +65 -2
  49. package/src/cli/account-api.ts +33 -6
  50. package/src/cli/account-auth.ts +111 -42
  51. package/src/cli/account-key-quota.ts +118 -0
  52. package/src/cli/account-main.ts +11 -4
  53. package/src/cli/account-next-actions.ts +63 -0
  54. package/src/cli/account-policy-dto.ts +68 -0
  55. package/src/cli/account-policy.ts +167 -0
  56. package/src/cli/account-target.ts +8 -0
  57. package/src/cli/account.ts +37 -9
  58. package/src/cli/agent-runtime-settings.ts +130 -0
  59. package/src/cli/agent-settings.ts +101 -0
  60. package/src/cli/agent.ts +29 -2
  61. package/src/cli/alias.ts +7 -2
  62. package/src/cli/aside-profiles.ts +2 -2
  63. package/src/cli/capabilities-access-remote.ts +147 -0
  64. package/src/cli/capabilities-accounts.ts +195 -0
  65. package/src/cli/capabilities-agents-routing.ts +414 -0
  66. package/src/cli/capabilities-base.ts +783 -0
  67. package/src/cli/capabilities-command.ts +1 -0
  68. package/src/cli/capabilities-integrations.ts +264 -0
  69. package/src/cli/capabilities-lab.ts +214 -0
  70. package/src/cli/capabilities-observe-system.ts +662 -0
  71. package/src/cli/capabilities-provider-models.ts +302 -0
  72. package/src/cli/capabilities.ts +30 -1190
  73. package/src/cli/capability-types.ts +54 -0
  74. package/src/cli/catalog-command-result.ts +57 -0
  75. package/src/cli/chatgpt-command.ts +35 -9
  76. package/src/cli/claude-agent-startup-sync.ts +17 -0
  77. package/src/cli/claude-desktop-profile.ts +100 -0
  78. package/src/cli/claude-desktop.ts +39 -6
  79. package/src/cli/claude.ts +22 -6
  80. package/src/cli/codex-shim-autorestore.ts +2 -1
  81. package/src/cli/codex-shim-readiness.ts +109 -0
  82. package/src/cli/combo-input.ts +88 -0
  83. package/src/cli/combo-result.ts +52 -0
  84. package/src/cli/combo-stats.ts +123 -0
  85. package/src/cli/combo.ts +37 -14
  86. package/src/cli/companion-timeline.ts +142 -0
  87. package/src/cli/companion-usage.ts +146 -0
  88. package/src/cli/companion.ts +12 -3
  89. package/src/cli/config-command.ts +24 -8
  90. package/src/cli/connect.ts +19 -0
  91. package/src/cli/dispatch.ts +55 -82
  92. package/src/cli/doctor.ts +22 -4
  93. package/src/cli/export-command.ts +20 -9
  94. package/src/cli/gui.ts +8 -3
  95. package/src/cli/help-catalog.ts +101 -0
  96. package/src/cli/help-models-context.ts +17 -0
  97. package/src/cli/help-navigation.ts +50 -0
  98. package/src/cli/help-recovery.ts +66 -0
  99. package/src/cli/help.ts +69 -11
  100. package/src/cli/index.ts +38 -27
  101. package/src/cli/injection-follow.ts +35 -0
  102. package/src/cli/inspect.ts +5 -0
  103. package/src/cli/integration-aside-sync.ts +69 -0
  104. package/src/cli/integration-cursor.ts +106 -0
  105. package/src/cli/integration-input.ts +60 -0
  106. package/src/cli/integration-journal.ts +49 -0
  107. package/src/cli/integration-plan-dto.ts +156 -0
  108. package/src/cli/integration-preview.ts +215 -0
  109. package/src/cli/integrations.ts +24 -18
  110. package/src/cli/json-input.ts +134 -0
  111. package/src/cli/link.ts +32 -4
  112. package/src/cli/local-sync-result.ts +41 -0
  113. package/src/cli/log-follow.ts +84 -0
  114. package/src/cli/log-view-filter.ts +146 -0
  115. package/src/cli/logout-command.ts +44 -0
  116. package/src/cli/model-picker-ordering.ts +137 -0
  117. package/src/cli/models-custom-input.ts +107 -0
  118. package/src/cli/models-custom-runtime.ts +112 -0
  119. package/src/cli/models-order.ts +194 -0
  120. package/src/cli/models-runtime-subcommands.ts +2 -0
  121. package/src/cli/models-runtime.ts +9 -1
  122. package/src/cli/models.ts +66 -81
  123. package/src/cli/observe-stream.ts +142 -0
  124. package/src/cli/observe.ts +75 -34
  125. package/src/cli/opencode.ts +54 -10
  126. package/src/cli/provider-batch.ts +77 -0
  127. package/src/cli/provider-lifecycle-runtime.ts +170 -0
  128. package/src/cli/provider-result.ts +124 -0
  129. package/src/cli/provider-runtime.ts +15 -12
  130. package/src/cli/provider-settings.ts +143 -0
  131. package/src/cli/provider.ts +116 -64
  132. package/src/cli/registry.ts +90 -24
  133. package/src/cli/remote-workspace-hub.ts +83 -0
  134. package/src/cli/remote-workspace.ts +6 -1
  135. package/src/cli/restart-failure.ts +27 -0
  136. package/src/cli/root.ts +42 -9
  137. package/src/cli/route-policy-write.ts +137 -0
  138. package/src/cli/route-policy.ts +8 -0
  139. package/src/cli/runtime-api.ts +57 -80
  140. package/src/cli/secret-args.ts +149 -0
  141. package/src/cli/settings-result.ts +30 -0
  142. package/src/cli/storage.ts +77 -7
  143. package/src/cli/system-command.ts +60 -2
  144. package/src/cli/system-restart-client.ts +20 -3
  145. package/src/cli/system-settings-parity.ts +83 -0
  146. package/src/cli/tray-proxy.ts +3 -2
  147. package/src/cli/update-restart-candidate.ts +21 -0
  148. package/src/cli/update-restart-child.ts +81 -0
  149. package/src/cli/update-restart-home.ts +38 -0
  150. package/src/cli/update-restart-transport.ts +193 -0
  151. package/src/cli/update-restart.ts +260 -0
  152. package/src/cli/usage-model-search.ts +58 -0
  153. package/src/cli/usage-report.ts +13 -5
  154. package/src/cli/v2-input.ts +66 -0
  155. package/src/cli/v2-local-output.ts +37 -0
  156. package/src/cli/v2-runtime.ts +90 -0
  157. package/src/cli/v2.ts +102 -144
  158. package/src/clients/config-export/contracts.ts +100 -0
  159. package/src/clients/config-export/droid.ts +54 -2
  160. package/src/clients/config-export/dsh.ts +21 -0
  161. package/src/clients/config-export/kilo.ts +12 -2
  162. package/src/clients/config-export/model-metadata.ts +10 -4
  163. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  164. package/src/clients/config-export.ts +53 -67
  165. package/src/clients/omo-role-models.ts +17 -3
  166. package/src/codex/account-credit-use.ts +4 -2
  167. package/src/codex/account-runtime-state.ts +21 -0
  168. package/src/codex/account-store.ts +11 -47
  169. package/src/codex/account-usability.ts +11 -8
  170. package/src/codex/auth-api/account-list.ts +73 -63
  171. package/src/codex/auth-api/main-account-probe.ts +25 -12
  172. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  173. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  174. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  175. package/src/codex/auth-context.ts +30 -11
  176. package/src/codex/catalog/aggregation.ts +3 -0
  177. package/src/codex/catalog/build-entries.ts +1 -1
  178. package/src/codex/catalog/bundled.ts +9 -2
  179. package/src/codex/catalog/heal-observation.ts +66 -0
  180. package/src/codex/catalog/model-hints.ts +2 -1
  181. package/src/codex/catalog/model-visibility.ts +6 -3
  182. package/src/codex/catalog/parsing.ts +2 -0
  183. package/src/codex/catalog/provider-models.ts +13 -4
  184. package/src/codex/catalog/publication-observer.ts +19 -0
  185. package/src/codex/catalog/remote.ts +13 -15
  186. package/src/codex/catalog/restore.ts +19 -6
  187. package/src/codex/catalog/retained-sync.ts +60 -11
  188. package/src/codex/catalog/routed-removal.ts +116 -0
  189. package/src/codex/catalog/write-audit-contract.ts +21 -0
  190. package/src/codex/catalog/write-audit.ts +171 -0
  191. package/src/codex/catalog-self-heal.ts +284 -0
  192. package/src/codex/catalog-write-serialization.ts +71 -7
  193. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  194. package/src/codex/codex-home-owner.ts +124 -0
  195. package/src/codex/convergence.ts +57 -16
  196. package/src/codex/features.ts +8 -1
  197. package/src/codex/inject/restore.ts +46 -4
  198. package/src/codex/inject.ts +11 -1
  199. package/src/codex/internal/catalog-writer.ts +120 -13
  200. package/src/codex/journal.ts +49 -13
  201. package/src/codex/main-account.ts +144 -11
  202. package/src/codex/management-convergence.ts +3 -0
  203. package/src/codex/quota-refresh-outcome.ts +17 -2
  204. package/src/codex/quota-types.ts +36 -1
  205. package/src/codex/quota.ts +67 -16
  206. package/src/codex/refresh.ts +20 -9
  207. package/src/codex/routing/cooldown-math.ts +10 -4
  208. package/src/codex/routing/selection.ts +11 -4
  209. package/src/codex/routing.ts +9 -9
  210. package/src/codex/runtime.ts +7 -1
  211. package/src/codex/shim-diagnostics.ts +80 -0
  212. package/src/codex/shim-fingerprint.ts +7 -2
  213. package/src/codex/shim-migration.ts +153 -0
  214. package/src/codex/shim-overlay.ts +509 -0
  215. package/src/codex/shim-path-resolution.ts +145 -0
  216. package/src/codex/shim-probe.ts +4 -3
  217. package/src/codex/shim-state-file.ts +93 -7
  218. package/src/codex/shim.ts +54 -107
  219. package/src/codex/subagent-model-fallback.ts +2 -1
  220. package/src/codex/sync.ts +24 -4
  221. package/src/combos/failover.ts +101 -1
  222. package/src/combos/index.ts +1 -0
  223. package/src/combos/jev.ts +13 -3
  224. package/src/combos/resolve.ts +12 -0
  225. package/src/config/atomic-write.ts +13 -8
  226. package/src/config/diagnostics.ts +23 -1
  227. package/src/config/load-degrade.ts +7 -0
  228. package/src/config/provider-validation.ts +3 -0
  229. package/src/config/schema/chatgpt-desktop.ts +22 -0
  230. package/src/config/schema/compaction-triggers.ts +8 -0
  231. package/src/config/schema/config-schema.ts +43 -14
  232. package/src/config/schema/leaf-validators.ts +8 -3
  233. package/src/config/subagent-models.ts +12 -0
  234. package/src/generated/compatibility-version.json +645 -237
  235. package/src/grok/session-identity.ts +33 -0
  236. package/src/integrations/catalog-refresh.ts +1 -1
  237. package/src/integrations/merge.ts +13 -5
  238. package/src/integrations/mutation-plan.ts +50 -5
  239. package/src/integrations/omp-yaml-source.ts +181 -1
  240. package/src/integrations/ownership-policy.ts +66 -0
  241. package/src/integrations/registry.ts +50 -1
  242. package/src/integrations/state.ts +106 -19
  243. package/src/integrations/target.ts +25 -2
  244. package/src/integrations/writer.ts +107 -35
  245. package/src/lib/gui-pair-capability.ts +17 -0
  246. package/src/lib/package-tree-integrity.ts +42 -1
  247. package/src/lib/provider-client-headers.ts +62 -0
  248. package/src/lib/proxy-env.ts +3 -0
  249. package/src/lib/test-home-guard.ts +2 -1
  250. package/src/oauth/anthropic-account-refusal.ts +19 -8
  251. package/src/oauth/anthropic-continuity.ts +65 -0
  252. package/src/oauth/anthropic-identity.ts +75 -0
  253. package/src/oauth/anthropic-routing.ts +20 -15
  254. package/src/oauth/anthropic.ts +34 -3
  255. package/src/oauth/generic-account-failover.ts +21 -0
  256. package/src/oauth/health.ts +32 -5
  257. package/src/oauth/index.ts +42 -35
  258. package/src/oauth/local-token-detect.ts +7 -0
  259. package/src/oauth/pool-settings-capability.ts +9 -2
  260. package/src/oauth/refresh-policy.ts +2 -0
  261. package/src/oauth/store.ts +55 -6
  262. package/src/oauth/types.ts +9 -0
  263. package/src/protocols/encoders/messages.ts +4 -3
  264. package/src/protocols/plan-snapshot.ts +1 -1
  265. package/src/protocols/settings.ts +28 -4
  266. package/src/providers/antigravity-effort-families.ts +75 -0
  267. package/src/providers/antigravity-models.ts +54 -16
  268. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  269. package/src/providers/derive.ts +2 -0
  270. package/src/providers/model-rename-fields.ts +3 -0
  271. package/src/providers/new-model-policy.ts +48 -10
  272. package/src/providers/registry/entries-extended.ts +3 -0
  273. package/src/providers/registry/model-ids.ts +2 -0
  274. package/src/providers/registry/model-seeds.ts +5 -0
  275. package/src/providers/registry/types.ts +4 -0
  276. package/src/responses/compaction.ts +19 -0
  277. package/src/router.ts +6 -0
  278. package/src/server/auth-cors.ts +9 -0
  279. package/src/server/caller-session-identity.ts +32 -0
  280. package/src/server/chat-completions.ts +10 -0
  281. package/src/server/claude-messages.ts +29 -15
  282. package/src/server/droid-reasoning-default.ts +50 -0
  283. package/src/server/github-copilot-responses-repair.ts +3 -6
  284. package/src/server/grok-responses-control-frame.ts +2 -2
  285. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  286. package/src/server/gui-session.ts +33 -11
  287. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  288. package/src/server/index/serve-options.ts +13 -7
  289. package/src/server/index.ts +1 -1
  290. package/src/server/management/agent-settings-routes.ts +12 -134
  291. package/src/server/management/config-routes.ts +1 -1
  292. package/src/server/management/context.ts +8 -1
  293. package/src/server/management/cursor-integration-routes.ts +3 -3
  294. package/src/server/management/integration-routes.ts +69 -11
  295. package/src/server/management/link-routes.ts +20 -13
  296. package/src/server/management/model-routes.ts +3 -3
  297. package/src/server/management/model-row-export-metadata.ts +150 -0
  298. package/src/server/management/model-rows.ts +148 -9
  299. package/src/server/management/native-integration-routes.ts +2 -2
  300. package/src/server/management/oauth-account-routes.ts +96 -30
  301. package/src/server/management/route-registry.ts +29 -27
  302. package/src/server/management/shared.ts +19 -5
  303. package/src/server/management/subagent-model-routes.ts +214 -0
  304. package/src/server/messages-native-eligibility.ts +3 -6
  305. package/src/server/messages-native-oauth.ts +82 -35
  306. package/src/server/messages-native.ts +78 -16
  307. package/src/server/relay.ts +14 -44
  308. package/src/server/request-log-generation-window.ts +38 -0
  309. package/src/server/request-log.ts +13 -0
  310. package/src/server/responses/adapter-dispatch.ts +152 -28
  311. package/src/server/responses/codex-auth-error.ts +10 -2
  312. package/src/server/responses/combo-stream-preflight.ts +29 -3
  313. package/src/server/responses/compaction-routing.ts +10 -1
  314. package/src/server/responses/core-auth.ts +67 -30
  315. package/src/server/responses/core-codex-account.ts +6 -3
  316. package/src/server/responses/core-combo-failure.ts +5 -1
  317. package/src/server/responses/core-combo-native.ts +7 -1
  318. package/src/server/responses/core-combo.ts +64 -7
  319. package/src/server/responses/core-normalize.ts +7 -0
  320. package/src/server/responses/core-options.ts +5 -0
  321. package/src/server/responses/fetch-helpers.ts +9 -0
  322. package/src/server/responses/passthrough-delivery.ts +1 -1
  323. package/src/server/responses/policy-fallback.ts +1 -1
  324. package/src/server/responses/request-prepare.ts +5 -0
  325. package/src/server/responses/request-spend.ts +30 -30
  326. package/src/server/responses-custom-tool-repair.ts +2 -2
  327. package/src/server/responses-hosted-image-display.ts +70 -9
  328. package/src/server/responses-terminal-repair.ts +15 -2
  329. package/src/server/sse-frame-buffer.ts +38 -36
  330. package/src/server/sse-payload-rewrite.ts +78 -49
  331. package/src/server/ws-bridge.ts +1 -11
  332. package/src/service/cli.ts +6 -1
  333. package/src/service/ownership-mutation-lease.d.mts +26 -0
  334. package/src/service/ownership-mutation-lease.mjs +76 -1
  335. package/src/service/windows-taskxml.ts +1 -2
  336. package/src/service-manager-probe.ts +36 -2
  337. package/src/types/config.ts +6 -0
  338. package/src/types/provider.ts +28 -10
  339. package/src/update/index.ts +2 -1
  340. package/src/update/npm-invocation.mjs +35 -3
  341. package/src/update/proxy-liveness-probe.mjs +38 -8
  342. package/src/update/update-failure-guidance.d.mts +6 -0
  343. package/src/update/update-failure-guidance.mjs +30 -5
  344. package/src/usage/expected-prices.ts +9 -0
  345. package/src/usage/log.ts +7 -0
  346. package/src/usage/summary.ts +82 -1
  347. package/src/web-search/loop.ts +11 -8
  348. package/src/web-search/run-turn-loop.ts +10 -7
  349. package/gui/dist/assets/App-CddorHdA.js +0 -51
  350. package/gui/dist/assets/App-DMudtISW.css +0 -1
  351. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  352. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  353. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
@@ -5,12 +5,21 @@ import {
5
5
  AtomicWriteResidualTempError,
6
6
  AtomicWriteSecretResidualError,
7
7
  atomicWriteFile,
8
- getConfigDir,
9
8
  resolveWriteTarget,
10
9
  type AtomicWriteIO,
11
- } from "../../config";
10
+ } from "../../config/atomic-write";
11
+ import { getConfigDir } from "../../config/paths";
12
+ import { readConfigAdmissionSnapshot } from "../../config/diagnostics";
13
+ import { ConfigMutationLockError, withConfigMutationLockSync } from "../../config/mutation-lock";
14
+ import { codexCatalogAuditPath } from "../catalog/write-audit";
15
+ import type { CatalogAuditConfigSource, CatalogAuditRefusalReason, CatalogWriteAuditDetails } from "../catalog/write-audit-contract";
16
+ import { ocxRoutedRowCount } from "../catalog/routed-removal";
17
+ import { notifyCatalogPublication } from "../catalog/publication-observer";
12
18
  import {
13
19
  assertCatalogWritePermit,
20
+ auditCatalogWriteWithPermit,
21
+ catalogWritePermitContext,
22
+ CatalogWritePermitRefusal,
14
23
  type CatalogWritePermit,
15
24
  } from "../catalog-write-serialization";
16
25
  import {
@@ -61,14 +70,15 @@ let backupTempSequence = 0;
61
70
  * An unreadable or absent file reports "differs", so the caller performs the real
62
71
  * write; that also converges a file that does not exist yet.
63
72
  */
64
- export function preparedBytesDifferFromDisk(prepared: PreparedCatalogFileWrite): boolean {
65
- let onDisk: Buffer;
66
- try {
67
- onDisk = readFileSync(prepared.path);
68
- } catch {
69
- return true;
70
- }
71
- return !onDisk.equals(Buffer.from(prepared.content, "utf8"));
73
+ function readExistingCatalogBytes(path: string): Buffer | null {
74
+ try { return readFileSync(path); } catch { return null; }
75
+ }
76
+
77
+ export function preparedBytesDifferFromDisk(
78
+ prepared: PreparedCatalogFileWrite,
79
+ onDisk: Buffer | null = readExistingCatalogBytes(prepared.path),
80
+ ): boolean {
81
+ return onDisk === null || !onDisk.equals(Buffer.from(prepared.content, "utf8"));
72
82
  }
73
83
 
74
84
  function isMissingPathError(error: unknown): boolean {
@@ -199,16 +209,105 @@ function publishCatalogBackup(
199
209
  return "written";
200
210
  }
201
211
 
202
- /** Replace the active catalog with the caller's already-prepared bytes. */
212
+ export type CatalogFileReplacement =
213
+ | { readonly kind: "written" }
214
+ | { readonly kind: "unchanged" }
215
+ | { readonly kind: "refused"; readonly reason: "unbacked-routed-clear" };
216
+
217
+ function routedRowsFromJson(content: string): number | null {
218
+ try { return ocxRoutedRowCount(JSON.parse(content)); } catch { return null; }
219
+ }
220
+
221
+ function configSourceNow(): CatalogAuditConfigSource {
222
+ try {
223
+ const snapshot = readConfigAdmissionSnapshot();
224
+ return snapshot.kind === "read" ? snapshot.diagnostics.source
225
+ : snapshot.diagnostics.source === "default" ? "default" : "unreadable";
226
+ } catch { return "unreadable"; }
227
+ }
228
+
229
+ function routedRowsOnDisk(path: string): number | null {
230
+ try { return routedRowsFromJson(readFileSync(path, "utf8")); } catch { return null; }
231
+ }
232
+
233
+ function auditWrite(
234
+ permit: CatalogWritePermit,
235
+ home: string,
236
+ details: CatalogWriteAuditDetails,
237
+ register: boolean,
238
+ ): void {
239
+ try {
240
+ if (auditCatalogWriteWithPermit(permit, home, details) !== "created"
241
+ || !register || details.configSource !== "file") return;
242
+ // The manifest rejects external CODEX_HOME paths. Such audit files remain uninstall
243
+ // residuals; diagnostic creation does not widen config ownership or claim cleanup.
244
+ if (!recordOwnedConfigPath(getConfigDir(), codexCatalogAuditPath(home))) return;
245
+ } catch { /* Audit and registration failures cannot change the publication outcome. */ }
246
+ }
247
+
248
+ /** Audit an actual refusal before the replacement funnel. Caller must avoid a second funnel event. */
249
+ export function auditRefusedCatalogReplacement(
250
+ permit: CatalogWritePermit,
251
+ home: string,
252
+ prepared: PreparedCatalogFileWrite,
253
+ reason: CatalogAuditRefusalReason,
254
+ ): void {
255
+ assertCatalogWritePermit(permit, home);
256
+ auditWrite(permit, home, {
257
+ target: "catalog", outcome: "refused", reason,
258
+ routedBefore: routedRowsOnDisk(prepared.path), routedAfter: routedRowsFromJson(prepared.content),
259
+ configSource: configSourceNow(),
260
+ }, true);
261
+ }
262
+
263
+ /** One funnel for exact-byte idempotence and intent admission, under a live K permit. */
203
264
  export function replaceActiveCodexCatalog(
204
265
  permit: CatalogWritePermit,
205
266
  owningCodexHome: string,
206
267
  prepared: PreparedCatalogFileWrite,
207
268
  io?: AtomicWriteIO,
208
- ): void {
269
+ ): CatalogFileReplacement {
209
270
  assertCatalogWritePermit(permit, owningCodexHome);
271
+ const { intent } = catalogWritePermitContext(permit);
272
+ if (intent === "cache") {
273
+ throw new CatalogWritePermitRefusal("A models-cache permit cannot replace the Codex catalog.");
274
+ }
275
+ const onDisk = readExistingCatalogBytes(prepared.path);
276
+ if (!preparedBytesDifferFromDisk(prepared, onDisk)) {
277
+ notifyCatalogPublication({ kind: "published", path: prepared.path, intent });
278
+ return { kind: "unchanged" };
279
+ }
280
+ const routedBefore = onDisk === null ? null : routedRowsFromJson(onDisk.toString("utf8"));
281
+ const routedAfter = routedRowsFromJson(prepared.content);
282
+ const finish = (result: CatalogFileReplacement): CatalogFileReplacement => {
283
+ if (result.kind !== "unchanged") auditWrite(permit, owningCodexHome, {
284
+ target: "catalog", outcome: result.kind,
285
+ reason: result.kind === "refused" ? result.reason : undefined,
286
+ routedBefore, routedAfter, configSource: configSourceNow(),
287
+ }, io === undefined);
288
+ if (result.kind !== "refused") notifyCatalogPublication({ kind: "published", path: prepared.path, intent });
289
+ return result;
290
+ };
291
+ if (intent === "refresh" && (routedBefore ?? 0) > 0 && routedAfter === 0) {
292
+ // K -> C, including the replacement: a config save cannot race the authority check.
293
+ try {
294
+ return finish(withConfigMutationLockSync(() => {
295
+ const snapshot = readConfigAdmissionSnapshot();
296
+ if (snapshot.kind !== "read" || snapshot.diagnostics.source !== "file") {
297
+ return { kind: "refused", reason: "unbacked-routed-clear" } as const;
298
+ }
299
+ atomicWriteFile(prepared.path, prepared.content, io);
300
+ resetCodexAppServerCatalogStateCache();
301
+ return { kind: "written" } as const;
302
+ }));
303
+ } catch (error) {
304
+ if (error instanceof ConfigMutationLockError) return finish({ kind: "refused", reason: "unbacked-routed-clear" });
305
+ throw error;
306
+ }
307
+ }
210
308
  atomicWriteFile(prepared.path, prepared.content, io);
211
309
  resetCodexAppServerCatalogStateCache();
310
+ return finish({ kind: "written" });
212
311
  }
213
312
 
214
313
  /** Atomically publish the catalog-path-keyed immutable backup without clobbering. */
@@ -242,8 +341,16 @@ export function replaceCodexModelsCache(
242
341
  owningCodexHome: string,
243
342
  prepared: PreparedCatalogFileWrite,
244
343
  io?: AtomicWriteIO,
245
- ): void {
344
+ ): Exclude<CatalogFileReplacement, { kind: "refused" }> {
246
345
  assertCatalogWritePermit(permit, owningCodexHome);
346
+ catalogWritePermitContext(permit);
347
+ if (!preparedBytesDifferFromDisk(prepared)) return { kind: "unchanged" };
348
+ const routedBefore = routedRowsOnDisk(prepared.path);
247
349
  atomicWriteFile(prepared.path, prepared.content, io);
248
350
  resetCodexAppServerCatalogStateCache();
351
+ auditWrite(permit, owningCodexHome, {
352
+ target: "cache", outcome: "written", routedBefore,
353
+ routedAfter: routedRowsFromJson(prepared.content), configSource: configSourceNow(),
354
+ }, io === undefined);
355
+ return { kind: "written" };
249
356
  }
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { atomicWriteFile } from "../config";
5
+ import { assertCodexHomeOwner, CODEX_HOME_JOURNAL_FILE, CodexHomeOwnerRefusal, opencodexHomeForInjection, readCodexHomeJournal, type CodexHomeOwnerRefusalReason } from "./codex-home-owner";
5
6
  import { hasInjectedCodexRouting } from "./injected-marker";
6
7
  import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths";
7
8
 
@@ -14,7 +15,7 @@ import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths";
14
15
  * agreed with the producer, and the pair stayed green. One exported constant
15
16
  * removes the opportunity.
16
17
  */
17
- export const JOURNAL_PATH = join(CODEX_HOME, "opencodex-journal.json");
18
+ export const JOURNAL_PATH = join(CODEX_HOME, CODEX_HOME_JOURNAL_FILE);
18
19
 
19
20
  export type JournalOwner =
20
21
  | { kind: "process"; pid: number }
@@ -71,12 +72,17 @@ interface Journal {
71
72
  * file we actually touched.
72
73
  */
73
74
  injectedCatalogPath?: string | null;
75
+ /** Injection binding survives native snapshot replacement and is released by native restore. */
76
+ opencodexHome?: string;
74
77
  pid: number;
75
78
  owner?: JournalOwner;
76
79
  timestamp: string;
77
80
  }
78
81
 
79
82
  export interface RestoreJournalResult {
83
+ /** An unchanged generated profile could not be restored; distinct from a preserved user edit. */
84
+ profileRestoreFailed?: true;
85
+ ownershipRefusal?: CodexHomeOwnerRefusalReason;
80
86
  configRestored: boolean;
81
87
  profileRestored: boolean;
82
88
  configChanged: boolean;
@@ -146,6 +152,7 @@ export interface WriteJournalOptions {
146
152
  * plugins, model choice, and trusted projects.
147
153
  */
148
154
  export function writeJournal(options: WriteJournalOptions = {}): void {
155
+ assertCodexHomeOwner(CODEX_HOME);
149
156
  if (!existsSync(CODEX_CONFIG_PATH)) return;
150
157
  const config = options.configContent ?? readFileSync(CODEX_CONFIG_PATH, "utf-8");
151
158
  // Ownership is decided HERE, from the bytes about to be journaled — never taken
@@ -155,7 +162,12 @@ export function writeJournal(options: WriteJournalOptions = {}): void {
155
162
  // The caller's verdict only authorizes REPLACEMENT. It is weaker evidence than
156
163
  // the check above (it may describe bytes read a moment earlier), so an
157
164
  // unclassified call creates a first snapshot but never overwrites one.
158
- if (existsSync(JOURNAL_PATH) && readJournal() && options.currentStateIsNative !== true) return;
165
+ const inspection = readCodexHomeJournal(JOURNAL_PATH);
166
+ if (inspection.kind === "unknown") {
167
+ throw new CodexHomeOwnerRefusal("owner-unknown");
168
+ }
169
+ const previous = inspection.kind === "read" ? inspection.journal as unknown as Journal : null;
170
+ if (previous && options.currentStateIsNative !== true) return;
159
171
  const profile = existsSync(CODEX_PROFILE_PATH)
160
172
  ? readFileSync(CODEX_PROFILE_PATH, "utf-8")
161
173
  : null;
@@ -163,6 +175,7 @@ export function writeJournal(options: WriteJournalOptions = {}): void {
163
175
  version: 1,
164
176
  originalConfig: Buffer.from(config).toString("base64"),
165
177
  originalProfile: profile !== null ? Buffer.from(profile).toString("base64") : null,
178
+ opencodexHome: opencodexHomeForInjection(previous?.opencodexHome),
166
179
  pid: process.pid,
167
180
  owner: options.owner?.kind === "client"
168
181
  ? { kind: "client", apiKeyId: options.owner.apiKeyId }
@@ -187,7 +200,10 @@ export function markJournalInjectedState(
187
200
  profile: string | null,
188
201
  ownership: InjectedJournalOwnership,
189
202
  ): void {
190
- const journal = readJournal();
203
+ assertCodexHomeOwner(CODEX_HOME);
204
+ const inspection = readCodexHomeJournal(JOURNAL_PATH);
205
+ if (inspection.kind === "unknown") throw new CodexHomeOwnerRefusal("owner-unknown");
206
+ const journal = inspection.kind === "read" ? inspection.journal as unknown as Journal : null;
191
207
  if (!journal) return;
192
208
  // The first exact injected config is the only safe whole-snapshot restore boundary for
193
209
  // the first native snapshot. A later reinjection may preserve user edits made while routed;
@@ -204,6 +220,7 @@ export function markJournalInjectedState(
204
220
  journal.injectedRootWebSearch = ownership.injectedRootWebSearch ?? null;
205
221
  journal.replacedRootWebSearch = ownership.replacedRootWebSearch ?? null;
206
222
  journal.injectedCatalogPath = ownership.injectedCatalogPath;
223
+ journal.opencodexHome = opencodexHomeForInjection(journal.opencodexHome);
207
224
  atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
208
225
  }
209
226
 
@@ -240,19 +257,24 @@ export function journaledInjectedCatalogPath(): string | null {
240
257
  }
241
258
 
242
259
  export function removeJournal(): void {
260
+ assertCodexHomeOwner(CODEX_HOME);
243
261
  try { unlinkSync(JOURNAL_PATH); } catch { /* ignore */ }
244
262
  }
245
263
 
246
- function readJournal(cleanInvalid = true): Journal | null {
247
- if (!existsSync(JOURNAL_PATH)) return null;
248
- try {
249
- const journal = JSON.parse(readFileSync(JOURNAL_PATH, "utf-8")) as Journal;
250
- if (journal.version !== 1) throw new Error("unknown version");
251
- return journal;
252
- } catch {
253
- if (cleanInvalid) removeJournal();
254
- return null;
255
- }
264
+ /** Successful field-level native restore releases ownership while retaining recovery evidence. */
265
+ export function releaseJournalHomeBinding(): void {
266
+ assertCodexHomeOwner(CODEX_HOME);
267
+ const inspection = readCodexHomeJournal(JOURNAL_PATH);
268
+ if (inspection.kind === "unknown") throw new CodexHomeOwnerRefusal("owner-unknown");
269
+ if (inspection.kind === "missing" || inspection.journal.opencodexHome === undefined) return;
270
+ delete inspection.journal.opencodexHome;
271
+ atomicWriteFile(JOURNAL_PATH, JSON.stringify(inspection.journal));
272
+ }
273
+
274
+ // Kept compatible with existing read-only callers; invalid evidence is never cleanup authority.
275
+ function readJournal(_cleanInvalid = true): Journal | null {
276
+ const inspected = readCodexHomeJournal(JOURNAL_PATH);
277
+ return inspected.kind === "read" ? inspected.journal as unknown as Journal : null;
256
278
  }
257
279
 
258
280
  /**
@@ -274,6 +296,12 @@ export function journalOwner(options: { readOnly?: boolean } = {}): JournalOwner
274
296
  }
275
297
 
276
298
  export function restoreJournalState(): RestoreJournalResult {
299
+ try { assertCodexHomeOwner(CODEX_HOME); }
300
+ catch (error) {
301
+ if (!(error instanceof CodexHomeOwnerRefusal)) throw error;
302
+ return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false,
303
+ complete: false, unverified: true, ownershipRefusal: error.reason };
304
+ }
277
305
  const journal = readJournal();
278
306
  if (!journal) {
279
307
  return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, complete: false, unverified: false };
@@ -295,6 +323,7 @@ export function restoreJournalState(): RestoreJournalResult {
295
323
  };
296
324
  }
297
325
 
326
+ assertCodexHomeOwner(CODEX_HOME);
298
327
  let configRestored = comparison.configAlreadyOriginal;
299
328
  let profileRestored = comparison.profileAlreadyOriginal;
300
329
  if (configUnchanged && !configRestored) {
@@ -330,6 +359,7 @@ export function restoreJournalState(): RestoreJournalResult {
330
359
  profileChanged: !profileUnchanged,
331
360
  complete,
332
361
  unverified: false,
362
+ ...(profileUnchanged && !profileRestored ? { profileRestoreFailed: true as const } : {}),
333
363
  };
334
364
  }
335
365
 
@@ -342,6 +372,12 @@ export interface ReconcileJournalOptions {
342
372
  }
343
373
 
344
374
  export function reconcileJournal(options: ReconcileJournalOptions = {}): boolean {
375
+ try { assertCodexHomeOwner(CODEX_HOME); }
376
+ catch (error) {
377
+ if (!(error instanceof CodexHomeOwnerRefusal)) throw error;
378
+ console.error(error.message);
379
+ return false;
380
+ }
345
381
  const journal = readJournal();
346
382
  if (!journal) return false;
347
383
  const owner = journalOwner();
@@ -1,11 +1,15 @@
1
+ import { classifyChatgptRefreshFailure, noteChatgptRefreshFailure } from "./chatgpt-refresh-failure";
2
+ import { readBoundedResponseBody } from "../lib/bounded-body";
1
3
  import { createHash } from "node:crypto";
2
4
  import { readFileSync, statSync } from "node:fs";
3
5
  import { join } from "node:path";
4
6
  import { readCodexTokens } from "./auth-collision";
5
7
  import {
8
+ CHATGPT_CLIENT_ID,
9
+ CHATGPT_TOKEN_URL,
10
+ credsFromToken,
6
11
  decodeJwtPayload,
7
12
  extractAccountId,
8
- refreshChatGPTToken,
9
13
  } from "../oauth/chatgpt";
10
14
  import type { OAuthCredentials } from "../oauth/types";
11
15
  import { extractChatgptPlanType } from "./plan";
@@ -17,7 +21,12 @@ import {
17
21
  import { atomicWriteFile, resolveWriteTarget } from "../config/atomic-write";
18
22
  import { resolveCodexHomeDir } from "./home";
19
23
  import { assertNotRealCodexHomeUnderTest } from "../lib/test-home-guard";
20
- import { clearAccountNeedsReauth } from "./account-runtime-state";
24
+ import {
25
+ clearAccountNeedsReauth,
26
+ isMainRefreshGrantRejected,
27
+ markMainRefreshGrantRejected,
28
+ clearMainRefreshGrantRejection,
29
+ } from "./account-runtime-state";
21
30
  import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
22
31
  import { withNativeMainExclusiveClaim } from "./native-main-claim";
23
32
  import { resolveNativeProfileContext } from "./native-profile-store";
@@ -78,6 +87,68 @@ export class MainAccountTokenRefreshError extends Error {
78
87
  }
79
88
  }
80
89
 
90
+ /**
91
+ * The native-main refresh, posted here rather than through `refreshChatGPTToken` so the token
92
+ * endpoint's structured `error` code survives to {@link classifyChatgptRefreshFailure}.
93
+ *
94
+ * `refreshChatGPTToken` collapses the refusal into one display string, and classifying a grant
95
+ * from that string is what let `refresh_token_reused` pass as transient -- the grant never
96
+ * retired, every request re-earning its own 401 -- while a 5xx whose description merely read
97
+ * "session expired" passed as proof and would have retired a live grant.
98
+ *
99
+ * Same endpoint, client id and grant type as every other ChatGPT refresh, and the success path
100
+ * is `credsFromToken` itself, so only the failure branch differs: it classifies by code, exactly
101
+ * as the stored-pool refresh does.
102
+ */
103
+ async function refreshNativeMainGrant(
104
+ refreshToken: string,
105
+ options: { signal: AbortSignal },
106
+ ): Promise<OAuthCredentials> {
107
+ const resp = await fetch(CHATGPT_TOKEN_URL, {
108
+ method: "POST",
109
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
110
+ body: new URLSearchParams({
111
+ grant_type: "refresh_token",
112
+ client_id: CHATGPT_CLIENT_ID,
113
+ refresh_token: refreshToken,
114
+ }).toString(),
115
+ signal: options.signal,
116
+ });
117
+ if (!resp.ok) {
118
+ const body = await readBoundedResponseBody(resp, { signal: options.signal, maxBytes: 16_384, fatalUtf8: true })
119
+ .catch(() => undefined);
120
+ const failure = classifyChatgptRefreshFailure(resp.status, body?.displaySafe ? body.text : "");
121
+ noteChatgptRefreshFailure("native main", resp.status, failure);
122
+ throw new MainAccountTokenRefreshError(failure.reason === "unknown" ? "transient" : "reauth");
123
+ }
124
+ let credentials: OAuthCredentials;
125
+ try {
126
+ credentials = credsFromToken((await resp.json()) as Record<string, unknown>);
127
+ } catch {
128
+ if (options.signal.aborted) throw options.signal.reason;
129
+ noteNativeMainRefresh("transient", resp.status);
130
+ // Parsing errors may include provider body text; retain only the fixed verdict.
131
+ throw new MainAccountTokenRefreshError("transient");
132
+ }
133
+ noteNativeMainRefresh("ok", resp.status);
134
+ return credentials;
135
+ }
136
+
137
+ /**
138
+ * One line per native-main refresh verdict, so a dead grant is diagnosable from service.log.
139
+ *
140
+ * A revoked session is invisible from the outside: the grant is refused, nothing is written, and
141
+ * the request fails with a status that looks like every other 401. Carries the token endpoint's
142
+ * HTTP status and its structured `error` code and nothing else -- never a token, never a body.
143
+ */
144
+ function noteNativeMainRefresh(outcome: string, status?: number, code?: string): void {
145
+ console.warn(
146
+ `[codex] native main refresh: ${outcome}`
147
+ + (status === undefined ? "" : ` status=${status}`)
148
+ + ` code=${code && /^[A-Za-z0-9._-]{1,64}$/.test(code) ? code : "none"}`,
149
+ );
150
+ }
151
+
81
152
  function nonEmptyString(value: unknown): string | undefined {
82
153
  return typeof value === "string" && value.trim() ? value : undefined;
83
154
  }
@@ -147,13 +218,45 @@ function mainAccessTokenFresh(accessToken: string | undefined, now: number, skew
147
218
  }
148
219
 
149
220
  /** A refresh grant makes native main routeable even when the current access token is expired. */
150
- export function isMainAccountCredentialUsable(now = Date.now()): boolean {
221
+ function mainRefreshGrantKey(current: MainAuthJsonCredential): string | undefined {
222
+ return current.refreshToken
223
+ ? `${current.path}:${refreshGrantFingerprintForToken(current.refreshToken)}` : undefined;
224
+ }
225
+
226
+ function credentialRefreshGrantRejected(current: MainAuthJsonCredential | null): boolean {
227
+ const key = current && mainRefreshGrantKey(current);
228
+ return !!key && isMainRefreshGrantRejected(key);
229
+ }
230
+
231
+ export interface MainAccountCredentialStatus {
232
+ refreshGrantRejected: boolean;
233
+ hasRefreshGrant: boolean;
234
+ usable: boolean;
235
+ }
236
+
237
+ /** One owned physical observation; no credential material escapes this view. */
238
+ export function getMainAccountCredentialStatus(now = Date.now()): MainAccountCredentialStatus {
151
239
  const current = readMainAuthJsonCredential();
152
- return !!current?.refreshToken || mainAccessTokenFresh(current?.accessToken, now, 0);
240
+ const refreshGrantRejected = credentialRefreshGrantRejected(current);
241
+ const hasRefreshGrant = !!current?.refreshToken && !refreshGrantRejected;
242
+ return {
243
+ refreshGrantRejected,
244
+ hasRefreshGrant,
245
+ usable: !refreshGrantRejected && (hasRefreshGrant || mainAccessTokenFresh(current?.accessToken, now, 0)),
246
+ };
247
+ }
248
+
249
+ /** Current owned profile only; callers must hold the native read/selection permission. */
250
+ export function isMainAccountRefreshGrantRejected(): boolean {
251
+ return getMainAccountCredentialStatus().refreshGrantRejected;
252
+ }
253
+
254
+ export function isMainAccountCredentialUsable(now = Date.now()): boolean {
255
+ return getMainAccountCredentialStatus(now).usable;
153
256
  }
154
257
 
155
258
  export function hasMainAccountRefreshGrant(): boolean {
156
- return !!readMainAuthJsonCredential()?.refreshToken;
259
+ return getMainAccountCredentialStatus().hasRefreshGrant;
157
260
  }
158
261
 
159
262
  function assertMainAuthJsonSnapshotUnchanged(expected: MainAuthJsonCredential): void {
@@ -342,14 +445,26 @@ async function resolveMainAccountToken(
342
445
  dependencies: NativeMainRefreshDependencies = {},
343
446
  rejectedAccessToken?: string,
344
447
  ): Promise<{ accessToken: string; chatgptAccountId: string } | null> {
448
+ if (dependencies.signal?.aborted) throw dependencies.signal.reason;
345
449
  const initial = readMainAuthJsonCredential();
346
- if (!initial) return null;
450
+ // Every skip below ends a forced refresh WITHOUT asking the token endpoint anything, which from
451
+ // the outside is indistinguishable from a refusal: same failed request, no write, no verdict.
452
+ // A forced refresh is the only caller that passes `rejectedAccessToken`, so only it reports.
453
+ if (!initial) {
454
+ if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-no-credential");
455
+ return null;
456
+ }
457
+ if (credentialRefreshGrantRejected(initial)) throw new MainAccountTokenRefreshError("reauth");
347
458
  const now = Date.now();
348
459
  if (initial.accessToken !== rejectedAccessToken
349
460
  && mainAccessTokenFresh(initial.accessToken, now, MAIN_TOKEN_REFRESH_SKEW_MS)) {
461
+ // The stored credential is no longer the one upstream rejected: somebody else replaced it
462
+ // while this request was in flight, so there is nothing here to refresh or to retire.
463
+ if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-credential-replaced");
350
464
  return { accessToken: initial.accessToken!, chatgptAccountId: initial.chatgptAccountId };
351
465
  }
352
466
  if (!initial.refreshToken) {
467
+ if (rejectedAccessToken !== undefined) noteNativeMainRefresh("skipped-no-refresh-grant");
353
468
  return initial.accessToken !== rejectedAccessToken
354
469
  && mainAccessTokenFresh(initial.accessToken, now, 0)
355
470
  ? { accessToken: initial.accessToken!, chatgptAccountId: initial.chatgptAccountId }
@@ -380,6 +495,7 @@ async function resolveMainAccountToken(
380
495
  () => withCodexRefreshFileLock(lockKey, signal, async () => {
381
496
  const locked = readMainAuthJsonCredential();
382
497
  if (!locked) throw new MainAuthJsonChangedDuringRefreshError();
498
+ if (credentialRefreshGrantRejected(locked)) throw new MainAccountTokenRefreshError("reauth");
383
499
  if (!locked.refreshToken
384
500
  || refreshGrantFingerprintForToken(locked.refreshToken) !== lockKey) {
385
501
  if (locked.accessToken !== rejectedAccessToken
@@ -392,16 +508,31 @@ async function resolveMainAccountToken(
392
508
  && mainAccessTokenFresh(locked.accessToken, Date.now(), MAIN_TOKEN_REFRESH_SKEW_MS)) {
393
509
  return { accessToken: locked.accessToken!, chatgptAccountId: locked.chatgptAccountId };
394
510
  }
395
- const refresh = dependencies.refreshToken
396
- ?? ((refreshToken: string, options: { signal: AbortSignal }) => refreshChatGPTToken(refreshToken, options));
511
+ const refresh = dependencies.refreshToken ?? refreshNativeMainGrant;
397
512
  let refreshed: OAuthCredentials;
398
513
  try {
399
514
  refreshed = await refresh(locked.refreshToken, { signal });
400
515
  } catch (cause) {
516
+ // A refusal already classified at the endpoint keeps that verdict. An injected refresh
517
+ // threw an ordinary Error and has only its message, so prose still decides there --
518
+ // and only there, where there is no structured code to read.
401
519
  const message = cause instanceof Error ? cause.message.toLowerCase() : "";
402
- const reason = /invalid_grant|invalidated|revoked|expired/.test(message)
403
- ? "reauth" as const
404
- : "transient" as const;
520
+ const reason = cause instanceof MainAccountTokenRefreshError
521
+ ? cause.reason
522
+ : dependencies.refreshToken && /invalid_grant|invalidated|revoked|expired/.test(message)
523
+ ? "reauth" as const
524
+ : "transient" as const;
525
+ // The shared point every request path reaches a terminal native-main verdict through:
526
+ // the forced refresh after an upstream 401, the pre-send refresh of an expired bearer,
527
+ // and the compact twin all land here. Recording the quarantine at the callers instead
528
+ // left the native main slot alone unrecorded -- `refreshNativeMainForwardAuth` has no
529
+ // `quarantine` flag for its caller to act on the way the stored-pool twin does -- so a
530
+ // revoked session stayed `needsReauth: false` and every request re-earned its own 401.
531
+ if (signal.aborted) throw signal.reason;
532
+ if (reason === "reauth") {
533
+ assertMainAuthJsonSnapshotUnchanged(locked);
534
+ markMainRefreshGrantRejected(mainRefreshGrantKey(locked)!);
535
+ }
405
536
  throw new MainAccountTokenRefreshError(reason, { cause });
406
537
  }
407
538
  // The refresh may resolve after the caller went away (an implementation that does
@@ -411,6 +542,8 @@ async function resolveMainAccountToken(
411
542
  // before its own commit above.
412
543
  if (dependencies.signal?.aborted) throw dependencies.signal.reason;
413
544
  const result = persistRefreshedMainAuthJson(locked, refreshed);
545
+ // A permitted in-flight success speaks only for its own profile and grant.
546
+ clearMainRefreshGrantRejection(mainRefreshGrantKey(locked)!);
414
547
  if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
415
548
  return result;
416
549
  }),
@@ -143,6 +143,7 @@ export function projectCatalogOnlyOutcome({
143
143
  */
144
144
  export function createManagementConvergeCodex(
145
145
  config: Readonly<OcxConfig>,
146
+ options: Readonly<{ beforeCommit?: () => boolean; expectedCatalogPath?: string }> = {},
146
147
  ): ConvergeCodex {
147
148
  const retainedConfig = config;
148
149
  return async request => {
@@ -167,6 +168,8 @@ export function createManagementConvergeCodex(
167
168
  const snapshot = captureCatalogAdmissionSnapshot(retainedConfig);
168
169
  const result = await convergeCodexCatalog(snapshot, request, {
169
170
  onCommitBegin: () => { commitBegan = true; },
171
+ beforeCommit: options.beforeCommit,
172
+ expectedCatalogPath: options.expectedCatalogPath,
170
173
  });
171
174
  return projectCatalogOnlyOutcome(result);
172
175
  } catch (error) {
@@ -1,8 +1,22 @@
1
1
  /** Diagnostic only: never use this outcome as quota, entitlement, or admission evidence. */
2
2
  export type CodexQuotaRefreshOutcome =
3
- | { status: "http_error"; httpStatus: number }
3
+ /** `code` is set only for a provider code that requires a new sign-in (fixed vocabulary). */
4
+ | { status: "http_error"; httpStatus: number; code?: CodexTerminalAuthCode }
4
5
  | { status: "ok" | "not_reported" | "timeout" | "network_error" | "invalid_response" | "internal_error" };
5
6
 
7
+ /** Provider auth codes that only a new sign-in can recover. */
8
+ export const CODEX_TERMINAL_AUTH_CODES = [
9
+ "invalid_workspace_selected",
10
+ "invalid_refresh_token",
11
+ // ChatGPT revokes every session of an account whose plan changes (for example Pro to Free),
12
+ // while the access token's `exp` still lies in the future.
13
+ "token_invalidated",
14
+ ] as const;
15
+ export type CodexTerminalAuthCode = typeof CODEX_TERMINAL_AUTH_CODES[number];
16
+ export function isCodexTerminalAuthCode(code: unknown): code is CodexTerminalAuthCode {
17
+ return (CODEX_TERMINAL_AUTH_CODES as readonly unknown[]).includes(code);
18
+ }
19
+
6
20
  /** The management response is untrusted at the CLI boundary; copy only the fixed vocabulary. */
7
21
  export function projectCodexQuotaRefreshOutcome(value: unknown): CodexQuotaRefreshOutcome | undefined {
8
22
  if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
@@ -10,7 +24,8 @@ export function projectCodexQuotaRefreshOutcome(value: unknown): CodexQuotaRefre
10
24
  if (row.status === "http_error") {
11
25
  return typeof row.httpStatus === "number" && Number.isInteger(row.httpStatus)
12
26
  && row.httpStatus >= 100 && row.httpStatus <= 599
13
- ? { status: "http_error", httpStatus: row.httpStatus }
27
+ ? { status: "http_error", httpStatus: row.httpStatus,
28
+ ...(isCodexTerminalAuthCode(row.code) ? { code: row.code } : {}) }
14
29
  : undefined;
15
30
  }
16
31
  switch (row.status) {
@@ -22,6 +22,32 @@ export const TERMINAL_SHORT_WINDOW_FRESHNESS_MS = 5 * 60_000;
22
22
  */
23
23
  export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
24
24
 
25
+ /** Credits have a separate clock: ordinary usage headers do not re-observe the balance. */
26
+ export const CODEX_CREDITS_FRESHNESS_MS = 5 * 60_000;
27
+
28
+ export interface CodexSpendableCredits {
29
+ hasCredits?: boolean;
30
+ unlimited?: boolean;
31
+ balance?: number;
32
+ overageLimitReached?: boolean;
33
+ /** Credit spending control, never the included-plan rate_limit.allowed verdict. */
34
+ allowed?: boolean;
35
+ observedAt: number;
36
+ }
37
+
38
+ export function hasSpendableCodexCredits(
39
+ quota: Pick<StoredAccountQuota, "credits"> | null,
40
+ now = Date.now(),
41
+ ): boolean {
42
+ const credits = quota?.credits;
43
+ if (!credits || !Number.isFinite(credits.observedAt)) return false;
44
+ const age = now - credits.observedAt;
45
+ if (age < 0 || age > CODEX_CREDITS_FRESHNESS_MS
46
+ || credits.allowed === false || credits.overageLimitReached === true || credits.hasCredits === false) return false;
47
+ return credits.unlimited === true || (credits.hasCredits === true
48
+ && typeof credits.balance === "number" && Number.isFinite(credits.balance) && credits.balance > 0);
49
+ }
50
+
25
51
  /**
26
52
  * Above this a value is already milliseconds; at or below it, it is Unix seconds.
27
53
  *
@@ -80,6 +106,8 @@ export type StoredAccountQuota = {
80
106
  shortWindowSeconds?: number;
81
107
  customWindows?: Array<{ label: string; percent: number; resetAt?: number }>;
82
108
  resetCredits?: number;
109
+ /** Spendable usage credits; resetCredits are separate, manually redeemed reset tickets. */
110
+ credits?: CodexSpendableCredits | null;
83
111
  /** Monthly usage came from an explicitly monthly PRIMARY, not supplementary tertiary, window. */
84
112
  monthlyIsPrimaryWindow?: boolean;
85
113
  updatedAt: number;
@@ -114,7 +142,14 @@ export type WhamUsageResponse = {
114
142
  secondary_window?: WhamUsageWindow | null;
115
143
  tertiary_window?: WhamUsageWindow | null;
116
144
  };
117
- credits?: unknown;
145
+ credits?: {
146
+ has_credits?: unknown;
147
+ unlimited?: unknown;
148
+ balance?: unknown;
149
+ overage_limit_reached?: unknown;
150
+ } | null;
151
+ /** Absent/null imposes no veto; present raw controls require an object with reached:false. */
152
+ spend_control?: unknown;
118
153
  rate_limit_reset_credits?: { available_count: number } | null;
119
154
  additional_rate_limits?: WhamAdditionalRateLimit[] | null;
120
155
  };