@bitkyc08/opencodex 2.41.0 → 2.43.0-preview.20260906

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 (260) hide show
  1. package/AGENTS_INSTALL.md +2 -2
  2. package/README.md +31 -0
  3. package/bin/ocx.mjs +10 -1
  4. package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
  5. package/gui/dist/assets/index-VGeQEZ_v.js +112 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-image-codec.ts +304 -0
  9. package/src/adapters/anthropic-image-normalize.ts +8 -298
  10. package/src/adapters/anthropic.ts +30 -7
  11. package/src/adapters/command-code.ts +7 -5
  12. package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
  13. package/src/adapters/cursor/images.ts +36 -6
  14. package/src/adapters/cursor/live-transport.ts +7 -2
  15. package/src/adapters/cursor/native-exec-desktop.ts +2 -15
  16. package/src/adapters/cursor/protobuf-request.ts +54 -24
  17. package/src/adapters/cursor/tool-definitions.ts +5 -670
  18. package/src/adapters/cursor/tool-guidance.ts +236 -0
  19. package/src/adapters/cursor/tool-naming.ts +252 -0
  20. package/src/adapters/cursor/tool-schemas.ts +195 -0
  21. package/src/adapters/cursor/types.ts +6 -3
  22. package/src/adapters/exec-tool-result-normalize.ts +1 -1
  23. package/src/adapters/google-errors.ts +9 -1
  24. package/src/adapters/google.ts +1 -0
  25. package/src/adapters/identity.ts +8 -2
  26. package/src/adapters/kiro-calibration.ts +181 -0
  27. package/src/adapters/kiro.ts +135 -3
  28. package/src/adapters/openai-responses.ts +259 -29
  29. package/src/adapters/responses-code-mode.ts +59 -0
  30. package/src/adapters/tool-catalog-nudge.ts +1 -1
  31. package/src/adapters/xai-schema-analysis.ts +86 -0
  32. package/src/adapters/xai-tool-schema.ts +2 -87
  33. package/src/adapters/xai-web-search.ts +1 -1
  34. package/src/bridge.ts +47 -13
  35. package/src/chat/inbound.ts +11 -3
  36. package/src/claude/inbound-content-options.ts +60 -0
  37. package/src/claude/inbound-model-options.ts +142 -0
  38. package/src/claude/inbound-records.ts +7 -0
  39. package/src/claude/inbound.ts +10 -202
  40. package/src/claude/model-info.ts +45 -0
  41. package/src/cli/account-auth.ts +49 -9
  42. package/src/cli/account-extended.ts +7 -1
  43. package/src/cli/capabilities.ts +15 -4
  44. package/src/cli/claude.ts +232 -39
  45. package/src/cli/config-command.ts +9 -1
  46. package/src/cli/dispatch.ts +5 -1
  47. package/src/cli/doctor.ts +10 -0
  48. package/src/cli/effort.ts +372 -0
  49. package/src/cli/export-command.ts +3 -9
  50. package/src/cli/help.ts +1 -0
  51. package/src/cli/index.ts +13 -0
  52. package/src/cli/init.ts +4 -0
  53. package/src/cli/model-selection-guidance.ts +30 -0
  54. package/src/cli/models-runtime.ts +3 -2
  55. package/src/cli/models.ts +8 -3
  56. package/src/cli/observe.ts +3 -1
  57. package/src/cli/opencode.ts +4 -1
  58. package/src/cli/provider-runtime.ts +65 -0
  59. package/src/cli/provider.ts +8 -0
  60. package/src/cli/registry.ts +16 -2
  61. package/src/cli/runtime-api.ts +3 -1
  62. package/src/cli/star-prompt.ts +22 -6
  63. package/src/cli/status-probes.ts +168 -0
  64. package/src/cli/status.ts +5 -168
  65. package/src/clients/config-export/constants.ts +69 -0
  66. package/src/clients/config-export/contracts.ts +154 -0
  67. package/src/clients/config-export/dsh.ts +132 -0
  68. package/src/clients/config-export/fast-models.ts +29 -0
  69. package/src/clients/config-export/mcode.ts +83 -0
  70. package/src/clients/config-export/model-metadata.ts +108 -0
  71. package/src/clients/config-export/omp.ts +104 -0
  72. package/src/clients/config-export/zcode.ts +92 -0
  73. package/src/clients/config-export.ts +18 -710
  74. package/src/codex/account-lifecycle.ts +20 -3
  75. package/src/codex/account-usability.ts +2 -0
  76. package/src/codex/auth-api.ts +272 -32
  77. package/src/codex/auth-context.ts +328 -24
  78. package/src/codex/catalog/effort.ts +44 -5
  79. package/src/codex/catalog/metadata.ts +149 -14
  80. package/src/codex/catalog/native-models.ts +116 -4
  81. package/src/codex/catalog/parsing.ts +122 -8
  82. package/src/codex/catalog/provider-fetch.ts +154 -23
  83. package/src/codex/catalog/reserve.ts +52 -0
  84. package/src/codex/catalog/sync.ts +89 -16
  85. package/src/codex/catalog.ts +1 -1
  86. package/src/codex/convergence-types.ts +1 -0
  87. package/src/codex/convergence.ts +2 -0
  88. package/src/codex/data/upstream-models.json +169 -0
  89. package/src/codex/forward-transport-headers.ts +25 -0
  90. package/src/codex/inject.ts +99 -34
  91. package/src/codex/injected-marker.ts +30 -4
  92. package/src/codex/journal.ts +14 -0
  93. package/src/codex/legacy-config-keys.ts +68 -0
  94. package/src/codex/log-guard/inspect-schema.ts +137 -0
  95. package/src/codex/log-guard/inspect.ts +2 -134
  96. package/src/codex/loopback-target.ts +54 -0
  97. package/src/codex/main-account-cache.ts +63 -1
  98. package/src/codex/main-account-hard-lock.ts +52 -0
  99. package/src/codex/main-account.ts +3 -1
  100. package/src/codex/management-convergence.ts +3 -0
  101. package/src/codex/model-entitlements.ts +54 -4
  102. package/src/codex/project-config-warnings.ts +92 -2
  103. package/src/codex/prompt-layers/encoding.ts +80 -0
  104. package/src/codex/prompt-layers/paths.ts +54 -0
  105. package/src/codex/prompt-layers/revision.ts +55 -0
  106. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  107. package/src/codex/prompt-layers/toml-read.ts +181 -0
  108. package/src/codex/prompt-layers.ts +14 -520
  109. package/src/codex/quota-auto-refresh-state.ts +16 -0
  110. package/src/codex/quota-auto-refresh.ts +219 -0
  111. package/src/codex/quota-types.ts +51 -0
  112. package/src/codex/quota.ts +252 -93
  113. package/src/codex/reserve-availability.ts +177 -0
  114. package/src/codex/routing.ts +28 -9
  115. package/src/codex/shim.ts +53 -11
  116. package/src/codex/subagent-model-fallback.ts +23 -3
  117. package/src/combos/failover.ts +125 -7
  118. package/src/combos/identifiers.ts +89 -0
  119. package/src/combos/index.ts +4 -0
  120. package/src/combos/resolve.ts +80 -9
  121. package/src/combos/types.ts +20 -93
  122. package/src/config/subagent-models.ts +24 -0
  123. package/src/config.ts +156 -13
  124. package/src/generated/compatibility-version.json +474 -178
  125. package/src/generated/model-metadata.ts +1 -1
  126. package/src/integrations/journal.ts +65 -4
  127. package/src/integrations/store.ts +5 -0
  128. package/src/lab/events/limits.ts +4 -0
  129. package/src/lib/destination-policy.ts +31 -2
  130. package/src/lib/errors.ts +39 -0
  131. package/src/lib/provider-outbound.ts +69 -3
  132. package/src/lib/proxy-env.ts +22 -0
  133. package/src/lib/redact-folding.ts +176 -0
  134. package/src/lib/redact.ts +2 -175
  135. package/src/lib/state-store-sweeper.ts +20 -6
  136. package/src/lib/token-estimate.ts +94 -27
  137. package/src/lib/windows-user-principal.ts +53 -5
  138. package/src/oauth/account-quota-rank.ts +40 -1
  139. package/src/oauth/anthropic-routing.ts +99 -3
  140. package/src/oauth/chatgpt-device.ts +187 -0
  141. package/src/oauth/chatgpt.ts +31 -4
  142. package/src/oauth/generic-account-failover.ts +36 -13
  143. package/src/oauth/index.ts +140 -29
  144. package/src/oauth/log.ts +3 -0
  145. package/src/oauth/login-cli.ts +5 -0
  146. package/src/oauth/meta-muse.ts +117 -15
  147. package/src/oauth/pool-settings-capability.ts +15 -4
  148. package/src/providers/api-keys.ts +8 -10
  149. package/src/providers/default-aliases.ts +39 -0
  150. package/src/providers/derive.ts +10 -2
  151. package/src/providers/fastwire.ts +36 -7
  152. package/src/providers/initial-model-selection-runtime.ts +90 -0
  153. package/src/providers/initial-model-selection.ts +120 -0
  154. package/src/providers/key-failover.ts +134 -54
  155. package/src/providers/key-store.ts +11 -1
  156. package/src/providers/label.ts +1 -1
  157. package/src/providers/model-discovery.ts +76 -0
  158. package/src/providers/model-rename-startup.ts +72 -8
  159. package/src/providers/muse-subscription-usage.ts +95 -0
  160. package/src/providers/openai-sidecar.ts +17 -5
  161. package/src/providers/openai-tiers-destination.ts +102 -0
  162. package/src/providers/openai-tiers.ts +2 -99
  163. package/src/providers/opencode-go-transport.ts +41 -0
  164. package/src/providers/quota-key-accounts.ts +141 -0
  165. package/src/providers/quota-types.ts +9 -0
  166. package/src/providers/quota.ts +625 -98
  167. package/src/providers/registry.ts +60 -17
  168. package/src/providers/xai-responses-opt-in.ts +31 -5
  169. package/src/quota/reset-activation.ts +81 -0
  170. package/src/quota/reset-detector.ts +305 -0
  171. package/src/quota/reset-notify-config.ts +162 -0
  172. package/src/quota/reset-observer.ts +125 -0
  173. package/src/quota/reset-poller.ts +160 -0
  174. package/src/quota/reset-seen-store.ts +385 -0
  175. package/src/quota/reset-sinks.ts +199 -0
  176. package/src/quota/window-mapping.ts +106 -0
  177. package/src/responses/apply-patch-envelope.ts +46 -0
  178. package/src/responses/code-mode-helper-compat.ts +39 -1
  179. package/src/responses/custom-tool-compat.ts +10 -4
  180. package/src/responses/hosted-tool-policy.ts +12 -4
  181. package/src/responses/parser-content.ts +133 -0
  182. package/src/responses/parser-text-format.ts +24 -0
  183. package/src/responses/parser-tools.ts +188 -0
  184. package/src/responses/parser.ts +3 -326
  185. package/src/responses/state.ts +124 -28
  186. package/src/router.ts +48 -13
  187. package/src/routing/analytics.ts +1 -0
  188. package/src/routing/capability.ts +17 -4
  189. package/src/server/auth-cors.ts +7 -1
  190. package/src/server/background-lifecycle.ts +23 -1
  191. package/src/server/chat-completions.ts +25 -3
  192. package/src/server/claude-messages.ts +62 -5
  193. package/src/server/effort-row.ts +1 -1
  194. package/src/server/fast-row.ts +295 -0
  195. package/src/server/gui-static.ts +30 -4
  196. package/src/server/index.ts +122 -28
  197. package/src/server/live.ts +18 -4
  198. package/src/server/management/agent-settings-routes.ts +2 -2
  199. package/src/server/management/combo-routes.ts +37 -9
  200. package/src/server/management/config-routes.ts +93 -2
  201. package/src/server/management/integration-routes.ts +108 -0
  202. package/src/server/management/model-routes.ts +13 -3
  203. package/src/server/management/model-rows.ts +20 -1
  204. package/src/server/management/native-integration-routes.ts +4 -1
  205. package/src/server/management/oauth-account-routes.ts +45 -10
  206. package/src/server/management/provider-routes.ts +34 -3
  207. package/src/server/management/quota-reset-routes.ts +57 -0
  208. package/src/server/management/route-registry.ts +7 -4
  209. package/src/server/management/shared.ts +19 -5
  210. package/src/server/management/system-routes.ts +3 -2
  211. package/src/server/management-api.ts +14 -2
  212. package/src/server/ports.ts +12 -2
  213. package/src/server/relay-eager.ts +38 -23
  214. package/src/server/relay.ts +4 -0
  215. package/src/server/request-log.ts +6 -0
  216. package/src/server/responses/codex-ws-correlation.ts +65 -0
  217. package/src/server/responses/codex-ws-exchange.ts +261 -0
  218. package/src/server/responses/codex-ws-metadata.ts +134 -0
  219. package/src/server/responses/codex-ws-pool.ts +162 -0
  220. package/src/server/responses/codex-ws-request.ts +87 -0
  221. package/src/server/responses/codex-ws-session.ts +93 -0
  222. package/src/server/responses/codex-ws-wire.ts +144 -0
  223. package/src/server/responses/collaboration.ts +41 -1
  224. package/src/server/responses/compact.ts +105 -12
  225. package/src/server/responses/core.ts +510 -57
  226. package/src/server/responses/empty-completion-guard.ts +4 -0
  227. package/src/server/responses/fetch-helpers.ts +10 -3
  228. package/src/server/responses/input-admission.ts +16 -9
  229. package/src/server/responses/responses-field-backfill.ts +1 -1
  230. package/src/server/responses/ws-upstream.ts +34 -318
  231. package/src/server/responses-custom-tool-repair.ts +20 -4
  232. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  233. package/src/server/safe-response-headers.ts +23 -0
  234. package/src/server/search.ts +9 -0
  235. package/src/server/subagent-models-startup.ts +27 -0
  236. package/src/server/system-env-shell.ts +238 -0
  237. package/src/server/system-env.ts +7 -234
  238. package/src/server/ws-bridge.ts +3 -25
  239. package/src/server/xai-responses-startup.ts +21 -0
  240. package/src/service-manager-probe.ts +1 -1
  241. package/src/service.ts +55 -16
  242. package/src/types/config.ts +108 -12
  243. package/src/types/provider.ts +36 -7
  244. package/src/types/request.ts +8 -0
  245. package/src/types/tools.ts +26 -1
  246. package/src/types.ts +2 -0
  247. package/src/update/notify.ts +8 -2
  248. package/src/usage/cost.ts +38 -28
  249. package/src/usage/expected-prices.ts +34 -15
  250. package/src/usage/log.ts +2 -0
  251. package/src/usage/model-identity.ts +26 -0
  252. package/src/usage/summary.ts +15 -1
  253. package/src/vision/describe.ts +6 -0
  254. package/src/vision/image-rewrite.ts +108 -0
  255. package/src/vision/index.ts +19 -306
  256. package/src/vision/plan.ts +205 -0
  257. package/src/web-search/executor.ts +6 -0
  258. package/src/web-search/index.ts +8 -1
  259. package/gui/dist/assets/index-B2YjLA-i.css +0 -1
  260. package/gui/dist/assets/index-aPup8CKb.js +0 -112
@@ -15,6 +15,20 @@ export function isRootOpenaiBaseUrlLine(line: string): boolean {
15
15
  return /^\s*openai_base_url\s*=/.test(line);
16
16
  }
17
17
 
18
+ /**
19
+ * codex-rs root key that redirects the realtime sideband WebSocket (WebRTC voice
20
+ * join + standalone realtime WS) without touching ordinary provider HTTP. Since
21
+ * openai/codex 438c9e98d (#35830) the sideband ignores the provider base URL and
22
+ * dials `https://api.openai.com/v1` unless this key is set, so a Pool-routed
23
+ * call-create and a directly-joined sideband end up on different accounts (404).
24
+ * Injected next to `openai_base_url` with the same value.
25
+ */
26
+ export const REALTIME_WS_BASE_URL_KEY = "experimental_realtime_ws_base_url";
27
+
28
+ export function isRootRealtimeWsBaseUrlLine(line: string): boolean {
29
+ return /^\s*experimental_realtime_ws_base_url\s*=/.test(line);
30
+ }
31
+
18
32
  export function tomlStringPattern(key: string): RegExp {
19
33
  const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
34
  const keyToken = `(?:${escaped}|"${escaped}"|'${escaped}')`;
@@ -65,16 +79,28 @@ export function providerTableString(content: string, provider: string, key: stri
65
79
  * EXACT value match against what we recorded writing: a user gateway we never wrote
66
80
  * cannot match, so restore can never delete a URL that was not ours.
67
81
  */
68
- export function stripJournaledOpenaiBaseUrl(content: string, injectedUrl: string | null): string {
69
- if (!injectedUrl) return content;
82
+ export function stripJournaledOpenaiBaseUrl(
83
+ content: string,
84
+ injectedUrl: string | null,
85
+ injectedRealtimeWsUrl: string | null = null,
86
+ ): string {
87
+ if (!injectedUrl && !injectedRealtimeWsUrl) return content;
70
88
  const lines = content.split(String.fromCharCode(10));
71
89
  const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
72
90
  const rootEnd = firstTable === -1 ? lines.length : firstTable;
73
91
  const drop = new Set<number>();
74
92
  for (let i = 0; i < rootEnd; i++) {
75
93
  const line = lines[i]!;
76
- if (!isRootOpenaiBaseUrlLine(line)) continue;
77
- if (rootTomlString(line, "openai_base_url") !== injectedUrl) continue;
94
+ // Each key is matched against ITS OWN recorded value. The realtime override is
95
+ // journaled separately so a user-owned override that happens to equal the proxy
96
+ // URL is never mistaken for ours.
97
+ if (isRootOpenaiBaseUrlLine(line)) {
98
+ if (!injectedUrl || rootTomlString(line, "openai_base_url") !== injectedUrl) continue;
99
+ } else if (isRootRealtimeWsBaseUrlLine(line)) {
100
+ if (!injectedRealtimeWsUrl || rootTomlString(line, REALTIME_WS_BASE_URL_KEY) !== injectedRealtimeWsUrl) continue;
101
+ } else {
102
+ continue;
103
+ }
78
104
  drop.add(i);
79
105
  // Take an ownership marker directly above it too, so repeated cycles cannot
80
106
  // accumulate orphaned comments.
@@ -35,6 +35,13 @@ interface Journal {
35
35
  * and it is what lets restore tell OUR loopback URL apart from a gateway the user set.
36
36
  */
37
37
  injectedOpenaiBaseUrl?: string | null;
38
+ /**
39
+ * The root `experimental_realtime_ws_base_url` this injection wrote, when it wrote one.
40
+ * Recorded on its own rather than inferred from `injectedOpenaiBaseUrl`: a user can own a
41
+ * realtime override whose value happens to equal the proxy URL, and restore must not treat
42
+ * that as ours. Null when the key was preserved or not injected.
43
+ */
44
+ injectedRealtimeWsBaseUrl?: string | null;
38
45
  /**
39
46
  * The catalog path this injection actually wrote to.
40
47
  *
@@ -119,6 +126,7 @@ export function writeJournal(options: WriteJournalOptions = {}): void {
119
126
 
120
127
  export interface InjectedJournalOwnership {
121
128
  injectedOpenaiBaseUrl: string | null;
129
+ injectedRealtimeWsBaseUrl: string | null;
122
130
  injectedCatalogPath: string | null;
123
131
  }
124
132
 
@@ -140,6 +148,7 @@ export function markJournalInjectedState(
140
148
  // Only the caller knows which values it actually owns. Deriving these from the final TOML
141
149
  // would mistake a preserved user override for injected routing.
142
150
  journal.injectedOpenaiBaseUrl = ownership.injectedOpenaiBaseUrl;
151
+ journal.injectedRealtimeWsBaseUrl = ownership.injectedRealtimeWsBaseUrl;
143
152
  journal.injectedCatalogPath = ownership.injectedCatalogPath;
144
153
  atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
145
154
  }
@@ -156,6 +165,11 @@ export function journaledInjectedOpenaiBaseUrl(): string | null {
156
165
  return readJournal()?.injectedOpenaiBaseUrl ?? null;
157
166
  }
158
167
 
168
+ /** The root `experimental_realtime_ws_base_url` the last injection wrote, or null. */
169
+ export function journaledInjectedRealtimeWsBaseUrl(): string | null {
170
+ return readJournal()?.injectedRealtimeWsBaseUrl ?? null;
171
+ }
172
+
159
173
  /** The catalog path the last injection wrote to, or null when none was recorded. */
160
174
  export function journaledInjectedCatalogPath(): string | null {
161
175
  return readJournal()?.injectedCatalogPath ?? null;
@@ -0,0 +1,68 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { resolveCodexHomeDir } from "./home";
4
+ import { parseTomlDocument } from "./project-config-warnings";
5
+ import { redactUserPath } from "../lib/redact";
6
+
7
+ /**
8
+ * Keys that are valid in model catalog data but not at the top level of the
9
+ * user config. Codex rejects them when strict config parsing is enabled.
10
+ */
11
+ const LEGACY_KEYS = ["persistent_instructions"] as const;
12
+
13
+ export type LegacyCodexConfigKey = (typeof LEGACY_KEYS)[number];
14
+
15
+ export interface LegacyCodexConfigKeyDiagnostic {
16
+ readonly path: string;
17
+ readonly code: LegacyCodexConfigKey;
18
+ readonly detail: string;
19
+ }
20
+
21
+ export type LegacyCodexConfigKeyDiagnosticsResult =
22
+ | { readonly status: "available"; readonly path: string; readonly diagnostics: readonly LegacyCodexConfigKeyDiagnostic[] }
23
+ | { readonly status: "unavailable"; readonly path: string; readonly reason: "read_failed" | "not_a_file" };
24
+
25
+ function resolveCodexConfigPath(codexConfigPath?: string): string {
26
+ if (codexConfigPath) return codexConfigPath;
27
+ return join(resolveCodexHomeDir(), "config.toml");
28
+ }
29
+
30
+ export function collectLegacyCodexConfigKeyDiagnostics(
31
+ options: { codexConfigPath?: string } = {},
32
+ ): LegacyCodexConfigKeyDiagnosticsResult {
33
+ const path = resolveCodexConfigPath(options.codexConfigPath);
34
+ if (!existsSync(path)) return { status: "available", path, diagnostics: [] };
35
+ let content: string;
36
+ try {
37
+ if (!statSync(path).isFile()) return { status: "unavailable", path, reason: "not_a_file" };
38
+ content = readFileSync(path, "utf-8");
39
+ } catch { // no-excuse-ok: catch -- optional doctor diagnostics must not fail on an unreadable file.
40
+ return { status: "unavailable", path, reason: "read_failed" };
41
+ }
42
+ const { root } = parseTomlDocument(content);
43
+ const found: LegacyCodexConfigKeyDiagnostic[] = [];
44
+ for (const key of LEGACY_KEYS) {
45
+ if (key in root) {
46
+ found.push({
47
+ path,
48
+ code: key,
49
+ detail: `top-level '${key}' is not a valid Codex config key. `
50
+ + "codex --strict-config rejects the whole file. Remove the key and put durable guidance in AGENTS.md.",
51
+ });
52
+ }
53
+ }
54
+ return { status: "available", path, diagnostics: found };
55
+ }
56
+
57
+ export function formatLegacyCodexConfigKeyDiagnosticsForDoctor(
58
+ result: LegacyCodexConfigKeyDiagnosticsResult,
59
+ ): string[] {
60
+ const displayPath = redactUserPath(result.path);
61
+ if (result.status === "unavailable") {
62
+ return [` -- Codex config at ${displayPath} could not be read (${result.reason}); legacy-key check skipped`];
63
+ }
64
+ if (result.diagnostics.length === 0) {
65
+ return [" ok no unsupported legacy top-level keys in the Codex config"];
66
+ }
67
+ return result.diagnostics.map(diagnostic => `[WARN] ${redactUserPath(diagnostic.path)}: ${diagnostic.detail}`);
68
+ }
@@ -0,0 +1,137 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ interface CurrentLogColumn {
4
+ name: string;
5
+ type: string;
6
+ notnull: number;
7
+ defaultValue: string | null;
8
+ pk: number;
9
+ }
10
+
11
+ // Pinned to Codex logs migration 0002. Keep this schema private: inspection reports
12
+ // compatibility, not column names, so sensitive payload-bearing fields never leak through
13
+ // the management API. Any additive/rebuilt future schema is monitor-only until reviewed.
14
+ const CURRENT_LOG_SCHEMA: readonly CurrentLogColumn[] = [
15
+ { name: "id", type: "INTEGER", notnull: 0, defaultValue: null, pk: 1 },
16
+ { name: "ts", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 },
17
+ { name: "ts_nanos", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 },
18
+ { name: "level", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
19
+ { name: "target", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
20
+ { name: "feedback_log_body", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
21
+ { name: "module_path", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
22
+ { name: "file", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
23
+ { name: "line", type: "INTEGER", notnull: 0, defaultValue: null, pk: 0 },
24
+ { name: "thread_id", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
25
+ { name: "process_uuid", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
26
+ { name: "estimated_bytes", type: "INTEGER", notnull: 1, defaultValue: "0", pk: 0 },
27
+ ] as const;
28
+
29
+ const CURRENT_LOG_TABLE_SQL = `CREATE TABLE logs (
30
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
31
+ ts INTEGER NOT NULL,
32
+ ts_nanos INTEGER NOT NULL,
33
+ level TEXT NOT NULL,
34
+ target TEXT NOT NULL,
35
+ feedback_log_body TEXT,
36
+ module_path TEXT,
37
+ file TEXT,
38
+ line INTEGER,
39
+ thread_id TEXT,
40
+ process_uuid TEXT,
41
+ estimated_bytes INTEGER NOT NULL DEFAULT 0
42
+ )`;
43
+
44
+ const CURRENT_LOG_INDEX_SQL = {
45
+ idx_logs_ts: "CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC)",
46
+ idx_logs_thread_id: "CREATE INDEX idx_logs_thread_id ON logs(thread_id)",
47
+ idx_logs_thread_id_ts: "CREATE INDEX idx_logs_thread_id_ts ON logs(thread_id, ts DESC, ts_nanos DESC, id DESC)",
48
+ idx_logs_process_uuid_threadless_ts: `CREATE INDEX idx_logs_process_uuid_threadless_ts
49
+ ON logs(process_uuid, ts DESC, ts_nanos DESC, id DESC)
50
+ WHERE thread_id IS NULL`,
51
+ } as const;
52
+
53
+
54
+ export interface ColumnRow {
55
+ cid: number;
56
+ name: string;
57
+ type: string;
58
+ notnull: number;
59
+ dflt_value: string | null;
60
+ pk: number;
61
+ }
62
+ interface SchemaObjectRow { name: string; type: string; sql: string | null }
63
+
64
+ function normalizeDeclaredType(type: string): string {
65
+ return String(type ?? "").trim().toUpperCase();
66
+ }
67
+
68
+ function normalizeDefault(value: string | null): string | null {
69
+ return value === null ? null : String(value).trim();
70
+ }
71
+
72
+ function normalizeSchemaSql(sql: string | null | undefined): string {
73
+ return (sql ?? "").trim().replace(/;\s*$/, "").replace(/\s+/g, " ");
74
+ }
75
+
76
+ function sameColumns(columns: ColumnRow[]): boolean {
77
+ if (columns.length !== CURRENT_LOG_SCHEMA.length) return false;
78
+ return columns.every((column, index) => {
79
+ const expected = CURRENT_LOG_SCHEMA[index];
80
+ return column.cid === index
81
+ && column.name === expected.name
82
+ && normalizeDeclaredType(column.type) === expected.type
83
+ && Number(column.notnull) === expected.notnull
84
+ && normalizeDefault(column.dflt_value) === expected.defaultValue
85
+ && Number(column.pk) === expected.pk;
86
+ });
87
+ }
88
+
89
+ /**
90
+ * The authoritative compatibility predicate: exact table SQL, exact column
91
+ * metadata, and every canonical index.
92
+ *
93
+ * Exported because the mutation paths must apply the SAME test inside their
94
+ * write transaction. They used to check column NAMES only, which is strictly
95
+ * weaker than what the inspector reports, so a schema change landing between
96
+ * the outer inspection and the locked write let Protect install a row-dropping
97
+ * trigger and let Reclaim vacuum pages on a database the inspector classifies
98
+ * as monitor-only. The lock serializes OpenCodex against itself; it does not
99
+ * stop Codex or another SQLite writer, so that TOCTOU window is real.
100
+ */
101
+ export function hasCurrentLogsSchema(db: Database): boolean {
102
+ const columns = db.query<ColumnRow, []>("PRAGMA table_info(logs)").all();
103
+ return hasCurrentLogsTable(db, columns);
104
+ }
105
+
106
+ export function hasCurrentLogsTable(db: Database, columns: ColumnRow[]): boolean {
107
+ const table = db.query<SchemaObjectRow, []>(
108
+ "SELECT name, type, sql FROM sqlite_schema WHERE name = 'logs' LIMIT 1",
109
+ ).get();
110
+ if (table?.type !== "table"
111
+ || !sameColumns(columns)
112
+ || normalizeSchemaSql(table.sql) !== normalizeSchemaSql(CURRENT_LOG_TABLE_SQL)) {
113
+ return false;
114
+ }
115
+
116
+ const indexes = db.query<SchemaObjectRow, []>(`
117
+ SELECT name, type, sql FROM sqlite_schema
118
+ WHERE name IN (
119
+ 'idx_logs_ts',
120
+ 'idx_logs_thread_id',
121
+ 'idx_logs_thread_id_ts',
122
+ 'idx_logs_process_uuid_threadless_ts'
123
+ )
124
+ `).all();
125
+ const byName = new Map(indexes.map(row => [row.name, row]));
126
+ for (const [name, expectedSql] of Object.entries(CURRENT_LOG_INDEX_SQL)) {
127
+ const row = byName.get(name);
128
+ if (row?.type !== "index" || normalizeSchemaSql(row.sql) !== normalizeSchemaSql(expectedSql)) {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ // Extra indexes and triggers do not redefine the table contract. In particular,
134
+ // Protect intentionally installs OpenCodex-owned triggers and unrelated user triggers
135
+ // are supported, so compatibility is based on the canonical table plus required indexes.
136
+ return true;
137
+ }
@@ -8,6 +8,8 @@ import {
8
8
  resolveCodexSqliteHome,
9
9
  type CodexSqliteHomeDeps,
10
10
  } from "../paths";
11
+ export { hasCurrentLogsSchema } from "./inspect-schema";
12
+ import { hasCurrentLogsTable, type ColumnRow } from "./inspect-schema";
11
13
 
12
14
  const IMMUTABLE_READONLY_FLAGS = constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI;
13
15
  const KNOWN_LOG_LEVELS = new Set(["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]);
@@ -15,56 +17,6 @@ const KNOWN_LOG_LEVELS = new Set(["TRACE", "DEBUG", "INFO", "WARN", "ERROR"]);
15
17
  // alone; skipping all row aggregates above 64 MiB reduced /api/storage to 628ms.
16
18
  const MAX_SYNCHRONOUS_METRICS_DATABASE_BYTES = 64 * 1024 * 1024;
17
19
 
18
- interface CurrentLogColumn {
19
- name: string;
20
- type: string;
21
- notnull: number;
22
- defaultValue: string | null;
23
- pk: number;
24
- }
25
-
26
- // Pinned to Codex logs migration 0002. Keep this schema private: inspection reports
27
- // compatibility, not column names, so sensitive payload-bearing fields never leak through
28
- // the management API. Any additive/rebuilt future schema is monitor-only until reviewed.
29
- const CURRENT_LOG_SCHEMA: readonly CurrentLogColumn[] = [
30
- { name: "id", type: "INTEGER", notnull: 0, defaultValue: null, pk: 1 },
31
- { name: "ts", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 },
32
- { name: "ts_nanos", type: "INTEGER", notnull: 1, defaultValue: null, pk: 0 },
33
- { name: "level", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
34
- { name: "target", type: "TEXT", notnull: 1, defaultValue: null, pk: 0 },
35
- { name: "feedback_log_body", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
36
- { name: "module_path", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
37
- { name: "file", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
38
- { name: "line", type: "INTEGER", notnull: 0, defaultValue: null, pk: 0 },
39
- { name: "thread_id", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
40
- { name: "process_uuid", type: "TEXT", notnull: 0, defaultValue: null, pk: 0 },
41
- { name: "estimated_bytes", type: "INTEGER", notnull: 1, defaultValue: "0", pk: 0 },
42
- ] as const;
43
-
44
- const CURRENT_LOG_TABLE_SQL = `CREATE TABLE logs (
45
- id INTEGER PRIMARY KEY AUTOINCREMENT,
46
- ts INTEGER NOT NULL,
47
- ts_nanos INTEGER NOT NULL,
48
- level TEXT NOT NULL,
49
- target TEXT NOT NULL,
50
- feedback_log_body TEXT,
51
- module_path TEXT,
52
- file TEXT,
53
- line INTEGER,
54
- thread_id TEXT,
55
- process_uuid TEXT,
56
- estimated_bytes INTEGER NOT NULL DEFAULT 0
57
- )`;
58
-
59
- const CURRENT_LOG_INDEX_SQL = {
60
- idx_logs_ts: "CREATE INDEX idx_logs_ts ON logs(ts DESC, ts_nanos DESC, id DESC)",
61
- idx_logs_thread_id: "CREATE INDEX idx_logs_thread_id ON logs(thread_id)",
62
- idx_logs_thread_id_ts: "CREATE INDEX idx_logs_thread_id_ts ON logs(thread_id, ts DESC, ts_nanos DESC, id DESC)",
63
- idx_logs_process_uuid_threadless_ts: `CREATE INDEX idx_logs_process_uuid_threadless_ts
64
- ON logs(process_uuid, ts DESC, ts_nanos DESC, id DESC)
65
- WHERE thread_id IS NULL`,
66
- } as const;
67
-
68
20
  export type CodexLogGuardCapabilityReason =
69
21
  | "database_missing"
70
22
  | "database_unreadable"
@@ -124,15 +76,6 @@ export interface CodexLogGuardInspection {
124
76
  };
125
77
  }
126
78
 
127
- interface ColumnRow {
128
- cid: number;
129
- name: string;
130
- type: string;
131
- notnull: number;
132
- dflt_value: string | null;
133
- pk: number;
134
- }
135
- interface SchemaObjectRow { name: string; type: string; sql: string | null }
136
79
  interface CountRow { n: number }
137
80
  interface LevelRow { level: string; rows: number }
138
81
  interface TargetCountRow { rows: number }
@@ -243,81 +186,6 @@ function unavailableInspection(): CodexLogGuardInspection {
243
186
  };
244
187
  }
245
188
 
246
- function normalizeDeclaredType(type: string): string {
247
- return String(type ?? "").trim().toUpperCase();
248
- }
249
-
250
- function normalizeDefault(value: string | null): string | null {
251
- return value === null ? null : String(value).trim();
252
- }
253
-
254
- function normalizeSchemaSql(sql: string | null | undefined): string {
255
- return (sql ?? "").trim().replace(/;\s*$/, "").replace(/\s+/g, " ");
256
- }
257
-
258
- function sameColumns(columns: ColumnRow[]): boolean {
259
- if (columns.length !== CURRENT_LOG_SCHEMA.length) return false;
260
- return columns.every((column, index) => {
261
- const expected = CURRENT_LOG_SCHEMA[index];
262
- return column.cid === index
263
- && column.name === expected.name
264
- && normalizeDeclaredType(column.type) === expected.type
265
- && Number(column.notnull) === expected.notnull
266
- && normalizeDefault(column.dflt_value) === expected.defaultValue
267
- && Number(column.pk) === expected.pk;
268
- });
269
- }
270
-
271
- /**
272
- * The authoritative compatibility predicate: exact table SQL, exact column
273
- * metadata, and every canonical index.
274
- *
275
- * Exported because the mutation paths must apply the SAME test inside their
276
- * write transaction. They used to check column NAMES only, which is strictly
277
- * weaker than what the inspector reports, so a schema change landing between
278
- * the outer inspection and the locked write let Protect install a row-dropping
279
- * trigger and let Reclaim vacuum pages on a database the inspector classifies
280
- * as monitor-only. The lock serializes OpenCodex against itself; it does not
281
- * stop Codex or another SQLite writer, so that TOCTOU window is real.
282
- */
283
- export function hasCurrentLogsSchema(db: Database): boolean {
284
- const columns = db.query<ColumnRow, []>("PRAGMA table_info(logs)").all();
285
- return hasCurrentLogsTable(db, columns);
286
- }
287
-
288
- function hasCurrentLogsTable(db: Database, columns: ColumnRow[]): boolean {
289
- const table = db.query<SchemaObjectRow, []>(
290
- "SELECT name, type, sql FROM sqlite_schema WHERE name = 'logs' LIMIT 1",
291
- ).get();
292
- if (table?.type !== "table"
293
- || !sameColumns(columns)
294
- || normalizeSchemaSql(table.sql) !== normalizeSchemaSql(CURRENT_LOG_TABLE_SQL)) {
295
- return false;
296
- }
297
-
298
- const indexes = db.query<SchemaObjectRow, []>(`
299
- SELECT name, type, sql FROM sqlite_schema
300
- WHERE name IN (
301
- 'idx_logs_ts',
302
- 'idx_logs_thread_id',
303
- 'idx_logs_thread_id_ts',
304
- 'idx_logs_process_uuid_threadless_ts'
305
- )
306
- `).all();
307
- const byName = new Map(indexes.map(row => [row.name, row]));
308
- for (const [name, expectedSql] of Object.entries(CURRENT_LOG_INDEX_SQL)) {
309
- const row = byName.get(name);
310
- if (row?.type !== "index" || normalizeSchemaSql(row.sql) !== normalizeSchemaSql(expectedSql)) {
311
- return false;
312
- }
313
- }
314
-
315
- // Extra indexes and triggers do not redefine the table contract. In particular,
316
- // Protect intentionally installs OpenCodex-owned triggers and unrelated user triggers
317
- // are supported, so compatibility is based on the canonical table plus required indexes.
318
- return true;
319
- }
320
-
321
189
  function pragmaNumber(db: Database, pragma: "page_size" | "page_count" | "freelist_count"): number {
322
190
  const row = db.query<Record<string, number>, []>(`PRAGMA ${pragma}`).get();
323
191
  return Number(row?.[pragma] ?? 0);
@@ -0,0 +1,54 @@
1
+ import type { OcxConfig } from "../types";
2
+ import type { DataPlaneAdmission } from "../server/auth-cors";
3
+ import { NATIVE_RESERVE_MODEL } from "./catalog/native-models";
4
+
5
+ export const CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE =
6
+ "Luna Reserve compatibility is only available as a conversation model, not a vision helper. Choose another vision model.";
7
+
8
+ /** Callers classify the concrete destination as canonical forward before using this predicate. */
9
+ export function isCodexReserveHelperUnsupported(
10
+ config: Pick<OcxConfig, "codexDesktopAuthless" | "runtimeRole">,
11
+ modelId: string,
12
+ admission: Pick<DataPlaneAdmission, "source"> | undefined,
13
+ terminalHelper: boolean,
14
+ ): boolean {
15
+ return terminalHelper && modelId === NATIVE_RESERVE_MODEL && isCodexReserveRequestEligible(config, admission);
16
+ }
17
+
18
+ /** Runtime authority comes from the receiving listener, not the catalog's injection target. */
19
+ export function isCodexReserveRequestEligible(
20
+ config: Pick<OcxConfig, "codexDesktopAuthless" | "runtimeRole">,
21
+ admission: Pick<DataPlaneAdmission, "source"> | undefined,
22
+ ): boolean {
23
+ return config.codexDesktopAuthless === true && config.runtimeRole !== "client"
24
+ && admission?.source === "loopback";
25
+ }
26
+
27
+ /** Bind scope, not the dial address: wildcard listeners are never loopback-only. */
28
+ export function isLoopbackHostname(hostname: string | undefined): boolean {
29
+ const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase().replace(/\.$/, "");
30
+ return (
31
+ normalized === "" ||
32
+ normalized === "localhost" ||
33
+ normalized === "127.0.0.1" ||
34
+ normalized === "::1" ||
35
+ normalized === "[::1]"
36
+ );
37
+ }
38
+
39
+ export function shouldInjectApiAuthHeader(
40
+ config: Pick<OcxConfig, "hostname" | "unauthenticatedLoopbackListener"> | undefined,
41
+ ): boolean {
42
+ // The dedicated listener binds loopback and does not require an admission credential.
43
+ if (config?.unauthenticatedLoopbackListener?.enabled) return false;
44
+ return !isLoopbackHostname(config?.hostname);
45
+ }
46
+
47
+ /** Match standalone injection, never a remote client's independently supplied routing target. */
48
+ export function isEffectiveCodexDesktopAuthless(
49
+ config: Pick<OcxConfig, "runtimeRole" | "hostname" | "unauthenticatedLoopbackListener" | "codexDesktopAuthless"> | undefined,
50
+ ): boolean {
51
+ return config?.codexDesktopAuthless === true
52
+ && config.runtimeRole !== "client"
53
+ && !shouldInjectApiAuthHeader(config);
54
+ }
@@ -1,4 +1,5 @@
1
- import type { StoredAccountQuota } from "./quota";
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import type { StoredAccountQuota } from "./quota-types";
2
3
  import { truncateRetainedUtf8 } from "../lib/admission";
3
4
 
4
5
  const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024;
@@ -16,6 +17,65 @@ export interface CachedMainAccountInfo extends MainAccountInfo {
16
17
  let cachedMainAccountInfo: CachedMainAccountInfo | null = null;
17
18
  let cachedMainCredentialPresence: boolean | null = null;
18
19
  let mainAccountIdentityGeneration = 0;
20
+ let observedMainQuotaIdentityKey: string | undefined;
21
+ const mainQuotaCredentialKey = randomBytes(32);
22
+ let mainQuotaCredential: { bearerHmac: Buffer; writer: MainQuotaWriter } | undefined;
23
+ let mainQuotaCredentialGeneration = 0;
24
+
25
+ /** Process-local transition fence; no credential material or persisted identity. */
26
+ export function getMainQuotaCredentialGeneration(): number { return mainQuotaCredentialGeneration; }
27
+
28
+ export type MainQuotaWriter = Readonly<{ identityKey: string; identityGeneration: number }>;
29
+
30
+ function mainQuotaIdentityKey(accountId: string): string {
31
+ return createHash("sha256").update("opencodex-main-quota-v1\0").update(accountId).digest("hex");
32
+ }
33
+
34
+ /** Only an existing owned physical-identity read may publish this observation. */
35
+ export function observeMainQuotaIdentity(accountId: string): void {
36
+ if (!accountId) return;
37
+ const identityKey = mainQuotaIdentityKey(accountId);
38
+ if (identityKey === observedMainQuotaIdentityKey) return;
39
+ observedMainQuotaIdentityKey = identityKey;
40
+ mainAccountIdentityGeneration += 1;
41
+ mainQuotaCredential = undefined;
42
+ mainQuotaCredentialGeneration += 1;
43
+ }
44
+
45
+ export function captureMainQuotaWriter(accountId: string): MainQuotaWriter | undefined {
46
+ if (!accountId) return undefined;
47
+ const identityKey = mainQuotaIdentityKey(accountId);
48
+ if (identityKey !== observedMainQuotaIdentityKey) return undefined;
49
+ return { identityKey, identityGeneration: mainAccountIdentityGeneration };
50
+ }
51
+
52
+ /** Credential material must come from an already-owned read, never an incoming request. */
53
+ export function observeMainQuotaCredential(accessToken: string, accountId: string): MainQuotaWriter | undefined {
54
+ const writer = captureMainQuotaWriter(accountId);
55
+ if (!accessToken || !writer) return undefined;
56
+ const bearerHmac = createHmac("sha256", mainQuotaCredentialKey).update(accessToken).digest();
57
+ if (!mainQuotaCredential || !isMainQuotaWriterLive(mainQuotaCredential.writer)
58
+ || !timingSafeEqual(bearerHmac, mainQuotaCredential.bearerHmac)) mainQuotaCredentialGeneration += 1;
59
+ mainQuotaCredential = { bearerHmac, writer };
60
+ return { ...writer };
61
+ }
62
+
63
+ export function matchesMainQuotaCredential(accessToken: string, effectiveAccountId: string | undefined): boolean {
64
+ const observed = mainQuotaCredential;
65
+ if (!accessToken || !effectiveAccountId || !observed || !isMainQuotaWriterLive(observed.writer)) return false;
66
+ if (mainQuotaIdentityKey(effectiveAccountId) !== observed.writer.identityKey) return false;
67
+ const candidate = createHmac("sha256", mainQuotaCredentialKey).update(accessToken).digest();
68
+ return timingSafeEqual(candidate, observed.bearerHmac);
69
+ }
70
+
71
+ export function isMainQuotaWriterLive(writer: MainQuotaWriter): boolean {
72
+ return writer.identityKey === observedMainQuotaIdentityKey
73
+ && writer.identityGeneration === mainAccountIdentityGeneration;
74
+ }
75
+
76
+ export function getObservedMainQuotaIdentityKey(): string | undefined {
77
+ return observedMainQuotaIdentityKey;
78
+ }
19
79
 
20
80
  export function captureMainAccountIdentityGeneration(): number {
21
81
  return mainAccountIdentityGeneration;
@@ -40,6 +100,8 @@ export function setMainAccountInfoCache(value: CachedMainAccountInfo): void {
40
100
  export function clearMainAccountInfoCache(): void {
41
101
  cachedMainAccountInfo = null;
42
102
  mainAccountIdentityGeneration += 1;
103
+ mainQuotaCredential = undefined;
104
+ mainQuotaCredentialGeneration += 1;
43
105
  }
44
106
 
45
107
  /** Last physical credential presence observed while native-main ownership was held. */
@@ -0,0 +1,52 @@
1
+ import type { OcxConfig } from "../types";
2
+ import { getMainPolicyQuota } from "./quota";
3
+
4
+ export const MAIN_ACCOUNT_HARD_LOCK_PERCENT = 99;
5
+
6
+ export interface MainAccountHardLockStatus {
7
+ enabled: boolean;
8
+ state: "off" | "unknown" | "ready" | "blocked";
9
+ /** Unix milliseconds; absent when a blocking observation has no future reset. */
10
+ resetAt?: number;
11
+ }
12
+
13
+ type PolicyConfig = Pick<OcxConfig, "codexMainAccountHardLock">;
14
+
15
+ function resetTimestamp(value: number | undefined): number | undefined {
16
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
17
+ return value < 10_000_000_000 ? value * 1000 : value;
18
+ }
19
+
20
+ /** Observed admission policy, not a reservation of the account's remaining quota. */
21
+ export function getMainAccountHardLockStatus(
22
+ config: PolicyConfig,
23
+ now = Date.now(),
24
+ ): MainAccountHardLockStatus {
25
+ if (config.codexMainAccountHardLock !== true) return { enabled: false, state: "off" };
26
+ const quota = getMainPolicyQuota();
27
+ if (!quota) return { enabled: true, state: "unknown" };
28
+ // Account window priority is deliberate: a 5h account uses that window, even if
29
+ // its weekly bar is higher. An unknown/expired selected window does not change the choice.
30
+ const hasShort = quota.shortPercent !== undefined || quota.shortResetAt !== undefined
31
+ || quota.shortWindowSeconds !== undefined;
32
+ const hasWeekly = quota.weeklyPercent !== undefined || quota.weeklyResetAt !== undefined;
33
+ const [percent, rawReset] = hasShort
34
+ ? [quota.shortPercent, quota.shortResetAt]
35
+ : hasWeekly ? [quota.weeklyPercent, quota.weeklyResetAt] : [quota.monthlyPercent, quota.monthlyResetAt];
36
+ const resetAt = resetTimestamp(rawReset);
37
+ // The routing score's unknown sentinel is 101. It is never a raw quota observation.
38
+ if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) {
39
+ return { enabled: true, state: "unknown" };
40
+ }
41
+ if (percent < MAIN_ACCOUNT_HARD_LOCK_PERCENT) return { enabled: true, state: "ready" };
42
+ return {
43
+ enabled: true,
44
+ state: "blocked",
45
+ // A predicted reset is not evidence of recovery. Only a fresh lower reading releases.
46
+ ...(resetAt !== undefined && resetAt > now ? { resetAt } : {}),
47
+ };
48
+ }
49
+
50
+ export function isMainAccountHardLocked(config: PolicyConfig, now = Date.now()): boolean {
51
+ return getMainAccountHardLockStatus(config, now).state === "blocked";
52
+ }
@@ -57,6 +57,8 @@ type MainAuthJsonCredential = {
57
57
  export interface NativeMainRefreshDependencies {
58
58
  refreshToken?: (refreshToken: string, options: { signal: AbortSignal }) => Promise<OAuthCredentials>;
59
59
  signal?: AbortSignal;
60
+ /** Metadata-only refresh must not retract a concurrent traffic reauth quarantine. */
61
+ preserveReauth?: boolean;
60
62
  }
61
63
 
62
64
  export class MainAuthJsonChangedDuringRefreshError extends Error {
@@ -283,7 +285,7 @@ async function resolveMainAccountToken(
283
285
  throw new MainAccountTokenRefreshError(reason, { cause });
284
286
  }
285
287
  const result = persistRefreshedMainAuthJson(locked, refreshed);
286
- clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
288
+ if (dependencies.preserveReauth !== true) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
287
289
  return result;
288
290
  }),
289
291
  { waitMs: 30_000, signal },