@bitkyc08/opencodex 2.23.0-preview.20260816 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/gui/dist/assets/index-CFqJKF2L.js +102 -0
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -2
  4. package/src/adapters/anthropic.ts +39 -7
  5. package/src/adapters/cursor/tool-definitions.ts +48 -0
  6. package/src/adapters/google.ts +18 -12
  7. package/src/adapters/openai-chat.ts +106 -13
  8. package/src/adapters/tool-call-id.ts +119 -0
  9. package/src/adapters/tool-catalog-nudge.ts +3 -0
  10. package/src/bridge.ts +16 -5
  11. package/src/chat/inbound.ts +5 -11
  12. package/src/claude/context-windows.ts +20 -4
  13. package/src/claude/desktop-3p.ts +11 -6
  14. package/src/claude/inbound.ts +39 -1
  15. package/src/claude/model-info.ts +28 -8
  16. package/src/cli/account-api.ts +5 -1
  17. package/src/cli/claude-desktop.ts +3 -0
  18. package/src/cli/config-command.ts +37 -14
  19. package/src/codex/app-server-restart-service.ts +1 -1
  20. package/src/codex/auth-api.ts +5 -0
  21. package/src/codex/auth-context.ts +43 -2
  22. package/src/codex/catalog/metadata.ts +135 -12
  23. package/src/codex/catalog/native-models.ts +32 -2
  24. package/src/codex/catalog/parsing.ts +61 -13
  25. package/src/codex/catalog/provider-fetch.ts +37 -7
  26. package/src/codex/catalog/sync.ts +49 -25
  27. package/src/codex/catalog-refresh-status.ts +21 -3
  28. package/src/codex/catalog.ts +1 -1
  29. package/src/codex/convergence-types.ts +23 -2
  30. package/src/codex/desired-state.ts +1 -1
  31. package/src/codex/inject.ts +38 -7
  32. package/src/codex/injected-marker.ts +28 -0
  33. package/src/codex/journal.ts +40 -1
  34. package/src/codex/management-convergence.ts +55 -2
  35. package/src/codex/quota-rejection.ts +61 -1
  36. package/src/codex/quota.ts +60 -6
  37. package/src/codex/routing.ts +30 -3
  38. package/src/combos/failover.ts +20 -0
  39. package/src/config.ts +271 -4
  40. package/src/generated/compatibility-version.json +90 -78
  41. package/src/grok/sync.ts +3 -1
  42. package/src/lab/artifacts/sanitize.ts +1 -1
  43. package/src/lab/live/manifest.ts +1 -1
  44. package/src/lib/codex-restart-contract.ts +1 -1
  45. package/src/lib/config-ownership.ts +1 -0
  46. package/src/lib/errors.ts +9 -0
  47. package/src/lib/lab-activation.ts +1 -1
  48. package/src/lib/optional-shutdown-hooks.ts +1 -1
  49. package/src/lib/pinned-http.ts +7 -2
  50. package/src/lib/windows-elevation.ts +3 -3
  51. package/src/providers/quota.ts +10 -4
  52. package/src/providers/registry.ts +2 -2
  53. package/src/responses/parser.ts +42 -7
  54. package/src/responses/provider-opaque-metadata.ts +1 -1
  55. package/src/responses/thought-signature-replay.ts +261 -0
  56. package/src/router.ts +6 -1
  57. package/src/routing/compatibility/provider-slot.ts +1 -1
  58. package/src/routing/evaluator.ts +12 -2
  59. package/src/routing/health.ts +16 -5
  60. package/src/routing/history/schema.ts +1 -1
  61. package/src/routing/trace.ts +1 -1
  62. package/src/server/auth-cors.ts +96 -21
  63. package/src/server/chat-completions.ts +6 -2
  64. package/src/server/chat-native.ts +32 -6
  65. package/src/server/index.ts +5 -3
  66. package/src/server/management/agent-settings-routes.ts +26 -4
  67. package/src/server/management/config-routes.ts +79 -2
  68. package/src/server/management/context.ts +1 -1
  69. package/src/server/management/model-rows.ts +5 -0
  70. package/src/server/management/native-integration-routes.ts +4 -1
  71. package/src/server/management/provider-routes.ts +19 -0
  72. package/src/server/management/shared.ts +3 -3
  73. package/src/server/management-api.ts +13 -6
  74. package/src/server/passive-route-linker.ts +1 -1
  75. package/src/server/relay.ts +16 -0
  76. package/src/server/responses/compact.ts +10 -3
  77. package/src/server/responses/core.ts +160 -33
  78. package/src/server/responses/fetch-helpers.ts +34 -2
  79. package/src/server/responses/input-admission.ts +17 -9
  80. package/src/server/responses-undeclared-tool-guard.ts +153 -0
  81. package/src/server/system-env.ts +4 -2
  82. package/src/service.ts +22 -7
  83. package/src/types.ts +35 -1
  84. package/gui/dist/assets/index-Ch-YtWdA.js +0 -102
@@ -1,4 +1,4 @@
1
- import type { CatalogDisposition, CatalogNotice } from "./convergence-types";
1
+ import type { CatalogDisposition, CatalogFailureCause, CatalogNotice } from "./convergence-types";
2
2
 
3
3
  const INVALID_CATALOG_DISPOSITION_FIELD = Symbol("invalid-catalog-disposition-field");
4
4
 
@@ -69,11 +69,15 @@ export function normalizeCatalogDisposition(value: unknown): CatalogDisposition
69
69
  const phase = ownDataProperty(value, "phase");
70
70
  const retryable = ownDataProperty(value, "retryable");
71
71
  const partialWrite = ownDataProperty(value, "partialWrite");
72
- if ((reason !== "provider-auth" && reason !== "provider-network" && reason !== "disk")
72
+ if ((reason !== "provider-auth" && reason !== "provider-network" && reason !== "disk"
73
+ && reason !== "request-invalid" && reason !== "admission" && reason !== "internal")
73
74
  || (phase !== "gather" && phase !== "commit")
74
75
  || typeof retryable !== "boolean"
75
76
  || typeof partialWrite !== "boolean") return null;
76
- return { status, reason, phase, retryable, partialWrite };
77
+ // The cause is rebuilt from closed vocabularies, never copied through: this is the
78
+ // boundary that keeps a message, path or account id from riding out on a failure.
79
+ const cause = normalizeCatalogFailureCause(ownDataProperty(value, "cause"));
80
+ return { status, reason, phase, retryable, partialWrite, ...(cause ? { cause } : {}) };
77
81
  }
78
82
  return null;
79
83
  } catch {
@@ -81,6 +85,20 @@ export function normalizeCatalogDisposition(value: unknown): CatalogDisposition
81
85
  }
82
86
  }
83
87
 
88
+ const FAILURE_CAUSE_KINDS: ReadonlySet<string> = new Set(["invalid-request", "lock-busy", "io", "unknown"]);
89
+ const FAILURE_CAUSE_CODES: ReadonlySet<string> = new Set([
90
+ "ENOSPC", "EACCES", "EPERM", "EROFS", "ENOENT", "SQLITE_BUSY",
91
+ ]);
92
+
93
+ function normalizeCatalogFailureCause(value: unknown): CatalogFailureCause | undefined {
94
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
95
+ const kind = ownDataProperty(value, "kind");
96
+ if (typeof kind !== "string" || !FAILURE_CAUSE_KINDS.has(kind)) return undefined;
97
+ const code = ownDataProperty(value, "code");
98
+ const safeCode = typeof code === "string" && FAILURE_CAUSE_CODES.has(code) ? code : undefined;
99
+ return { kind, ...(safeCode ? { code: safeCode } : {}) } as CatalogFailureCause;
100
+ }
101
+
84
102
  /** Whether a persisted mutation still needs a successful catalog commit. */
85
103
  export function catalogRefreshIsPending(disposition: CatalogDisposition): boolean {
86
104
  return disposition.status !== "committed";
@@ -2,7 +2,7 @@
2
2
  // Public surface preserved exactly; importers keep using "src/codex/catalog".
3
3
  export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
4
4
  export type { CatalogModel, MultiAgentMode } from "./catalog/parsing";
5
- export { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_DAYBREAK_BLUE_MODEL, NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, isNativeOpenAiCapabilityAliasModel, nativeOpenAiCapabilitySourceSlug, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, observedAccountBoundNativeEntries, observedAccountBoundNativeOpenAiSlugs, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata";
5
+ export { accountBoundNativeOpenAiSlugs, accountBoundNativeOpenAiSlugsBySelector, CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_DAYBREAK_BLUE_MODEL, NATIVE_GPT56_MAX_INPUT_TOKENS, NATIVE_OPENAI_CAPABILITY_ALIAS_MODELS, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, isNativeOpenAiCapabilityAliasModel, nativeContextLimits, nativeOpenAiCapabilitySourceSlug, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, observedAccountBoundNativeEntries, observedAccountBoundNativeOpenAiSlugs, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, type NativeContextLimits, type NativeContextLimitsInput } from "./catalog/metadata";
6
6
  export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
7
7
  export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort";
8
8
  export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch";
@@ -161,8 +161,29 @@ export type CatalogDisposition =
161
161
  | { status: "skipped";
162
162
  reason: "not-requested" | "catalog-unavailable" | "busy" | "stale" | "refused";
163
163
  retryable: boolean }
164
- | { status: "failed"; reason: "provider-auth" | "provider-network" | "disk";
165
- phase: "gather" | "commit"; retryable: boolean; partialWrite: boolean };
164
+ | { status: "failed";
165
+ /**
166
+ * `disk` used to absorb every unclassified failure, so a malformed request and a
167
+ * genuine ENOSPC were indistinguishable and both reported non-retryable (#1784).
168
+ */
169
+ reason: "provider-auth" | "provider-network" | "disk" | "request-invalid" | "admission" | "internal";
170
+ phase: "gather" | "commit"; retryable: boolean; partialWrite: boolean;
171
+ /** Allowlisted cause summary. Closed vocabularies only -- never message text. */
172
+ cause?: CatalogFailureCause };
173
+
174
+ /**
175
+ * Why a catalog operation failed, in terms safe to return from the management plane.
176
+ *
177
+ * Both fields are closed sets on purpose. An `Error.constructor.name` is dependency- or
178
+ * input-influenced (any thrown custom class names itself) and an `Error.message` routinely
179
+ * carries paths, home directories and account identifiers, none of which may cross this
180
+ * boundary.
181
+ */
182
+ export type CatalogFailureCause = {
183
+ kind: "invalid-request" | "lock-busy" | "io" | "unknown";
184
+ /** Recognized errno/code token, when the underlying error carried one. */
185
+ code?: "ENOSPC" | "EACCES" | "EPERM" | "EROFS" | "ENOENT" | "SQLITE_BUSY";
186
+ };
166
187
 
167
188
  /**
168
189
  * The ONLY way Codex-owned bytes are written. Startup, ensure, /api/sync, the
@@ -18,7 +18,7 @@
18
18
  * depending on the temp root. Convergence takes the write lock; this module only
19
19
  * records intent through the config coordinator.
20
20
  *
21
- * Design record: devlog/_plan/260803_codex_desktop_toggle/030_desired_state.md.
21
+ * Design record: devlog/_fin/260803_codex_desktop_toggle/030_desired_state.md.
22
22
  */
23
23
  import { loadConfig, mutatePersistedConfig } from "../config";
24
24
  import type { OcxClientIntegrationsConfig, OcxConfig } from "../types";
@@ -30,6 +30,8 @@ import {
30
30
  } from "./user-identity";
31
31
  import {
32
32
  markJournalInjectedState,
33
+ journaledInjectedOpenaiBaseUrl,
34
+ journaledInjectedCatalogPath,
33
35
  removeJournal,
34
36
  restoreJournalState,
35
37
  writeJournal,
@@ -52,6 +54,7 @@ import {
52
54
  providerTableStart,
53
55
  providerTableString,
54
56
  rootTomlString,
57
+ stripJournaledOpenaiBaseUrl,
55
58
  tomlStringPattern,
56
59
  } from "./injected-marker";
57
60
  import {
@@ -1138,12 +1141,18 @@ interface StripOpencodexConfigResult {
1138
1141
  */
1139
1142
  function stripOpencodexConfigResult(
1140
1143
  content: string,
1144
+ journaledBaseUrl: string | null = null,
1141
1145
  ): StripOpencodexConfigResult {
1142
1146
  let out = content;
1143
1147
  const hadRootOcxProvider =
1144
1148
  readRootTomlString(out, "model_provider") === "opencodex";
1145
- const hadInjectedBaseUrl = hasInjectedOpenaiBaseUrl(out);
1149
+ // #1798: marker adjacency is FORMATTING evidence, and a Codex app rewrite keeps values
1150
+ // while dropping comments. Fall back to VALUE evidence -- the exact URL we recorded
1151
+ // writing -- so an app-rewritten config is still recognized as ours.
1152
+ const hadInjectedBaseUrl = hasInjectedOpenaiBaseUrl(out)
1153
+ || (journaledBaseUrl !== null && rootTomlString(out, "openai_base_url") === journaledBaseUrl);
1146
1154
  out = stripInjectedOpenaiBaseUrl(out); // before removeOcxSection — it keys on the marker line too
1155
+ out = stripJournaledOpenaiBaseUrl(out, journaledBaseUrl);
1147
1156
  if (out.includes("[model_providers.opencodex]")) {
1148
1157
  out = removeOcxSection(out);
1149
1158
  }
@@ -1195,8 +1204,12 @@ export function removeCodexConfig(
1195
1204
  // The unchanged fast path compares in LF space so an untouched file is never rewritten.
1196
1205
  const eol = dominantEol(rawContent);
1197
1206
  const content = applyEol(rawContent, "\n");
1198
- const had = hasOpencodexRouting(content);
1199
- const stripped = stripOpencodexConfigResult(content);
1207
+ // Read the recorded injection once: the strip below consumes it, and so does the
1208
+ // ownership verdict, which must agree with what was actually removed.
1209
+ const journaledBaseUrl = journaledInjectedOpenaiBaseUrl();
1210
+ const had = hasOpencodexRouting(content)
1211
+ || (journaledBaseUrl !== null && rootTomlString(content, "openai_base_url") === journaledBaseUrl);
1212
+ const stripped = stripOpencodexConfigResult(content, journaledBaseUrl);
1200
1213
  if (had || stripped.content !== content) {
1201
1214
  atomicWriteFile(CODEX_CONFIG_PATH, applyEol(stripped.content, eol));
1202
1215
  }
@@ -1371,13 +1384,23 @@ function restoreCodexConfigInline(): CodexRestoreConfigResult {
1371
1384
  }
1372
1385
 
1373
1386
  /** The catalog half, always inside its own K acquisition. */
1374
- function restoreCodexCatalogArtifact(revalidateDesiredState: boolean): CodexRestoreCatalogResult {
1387
+ /**
1388
+ * The catalog half, always inside its own K acquisition.
1389
+ *
1390
+ * `journaledCatalogPath` must be captured by the CALLER, before the config half runs: a
1391
+ * successful journal restore deletes the journal, and a config restore can remove
1392
+ * `model_catalog_json`. Reading it here would be too late in both cases (#1798).
1393
+ */
1394
+ function restoreCodexCatalogArtifact(
1395
+ revalidateDesiredState: boolean,
1396
+ journaledCatalogPath: string | null,
1397
+ ): CodexRestoreCatalogResult {
1375
1398
  const owningCodexHome = getCodexHome();
1376
1399
  try {
1377
1400
  const restored = withCatalogWriteSerialization(owningCodexHome, permit =>
1378
1401
  revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())
1379
1402
  ? null
1380
- : restoreCodexCatalogWithPermit(permit, owningCodexHome));
1403
+ : restoreCodexCatalogWithPermit(permit, owningCodexHome, journaledCatalogPath));
1381
1404
  return restored.kind === "completed" && restored.value !== null
1382
1405
  ? { state: "ok", changed: restored.value.removed > 0, ...restored.value, message: "Codex catalog restored." }
1383
1406
  : restored.kind === "completed"
@@ -1435,6 +1458,10 @@ export async function restoreNativeCodexAsync(
1435
1458
  integrationRecord: () => readIntegrationRecord(),
1436
1459
  });
1437
1460
 
1461
+ // Captured before the config half: a successful journal restore DELETES the journal, and
1462
+ // restoring the config can drop `model_catalog_json`. Either one would hide the routed
1463
+ // catalog we actually wrote (#1798).
1464
+ const journaledCatalogPath = journaledInjectedCatalogPath();
1438
1465
  let config: CodexRestoreConfigResult;
1439
1466
  let transitionReceipt: { nativeGeneration: number; currentTxId: string } | undefined;
1440
1467
 
@@ -1511,7 +1538,7 @@ export async function restoreNativeCodexAsync(
1511
1538
  config = restoreCodexConfigInline();
1512
1539
  }
1513
1540
 
1514
- const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true);
1541
+ const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath);
1515
1542
  const outcome = await runCodexHistoryJob({
1516
1543
  ...resolveCodexHistoryJobTarget(),
1517
1544
  ...(options.revalidateDesiredState ? { expectedDesiredEnabled: false } : {}),
@@ -1561,8 +1588,12 @@ export function restoreNativeCodex(options: { skipHistory?: boolean; revalidateD
1561
1588
  if (options.revalidateDesiredState && shouldSyncCodexOnStart(loadConfig())) {
1562
1589
  return desiredEnabledRestoreSkip();
1563
1590
  }
1591
+ // Captured before the config half: a successful journal restore DELETES the journal, and
1592
+ // restoring the config can drop `model_catalog_json`. Either one would hide the routed
1593
+ // catalog we actually wrote (#1798).
1594
+ const journaledCatalogPath = journaledInjectedCatalogPath();
1564
1595
  const config = restoreCodexConfigInline();
1565
- const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true);
1596
+ const catalog = restoreCodexCatalogArtifact(options.revalidateDesiredState === true, journaledCatalogPath);
1566
1597
  // Design B (loopback) steady state: threads are already tagged openai, so prove the
1567
1598
  // no-op with a readonly probe instead of write-opening a DB the Codex app may hold
1568
1599
  // (Windows: WAL writer lock -> seconds of stalling + a false warning on every stop).
@@ -50,6 +50,34 @@ export function providerTableString(content: string, provider: string, key: stri
50
50
  return null;
51
51
  }
52
52
 
53
+ /**
54
+ * Drop a root `openai_base_url` whose VALUE is the one a recorded injection wrote.
55
+ *
56
+ * #1798: the marker-adjacency rule below is formatting evidence, and the Codex app
57
+ * reserializes the file -- values kept, comments dropped. This rule is value evidence
58
+ * instead, so it still recognizes our URL after that rewrite. It is deliberately an
59
+ * EXACT value match against what we recorded writing: a user gateway we never wrote
60
+ * cannot match, so restore can never delete a URL that was not ours.
61
+ */
62
+ export function stripJournaledOpenaiBaseUrl(content: string, injectedUrl: string | null): string {
63
+ if (!injectedUrl) return content;
64
+ const lines = content.split(String.fromCharCode(10));
65
+ const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
66
+ const rootEnd = firstTable === -1 ? lines.length : firstTable;
67
+ const drop = new Set<number>();
68
+ for (let i = 0; i < rootEnd; i++) {
69
+ const line = lines[i]!;
70
+ if (!isRootOpenaiBaseUrlLine(line)) continue;
71
+ if (rootTomlString(line, "openai_base_url") !== injectedUrl) continue;
72
+ drop.add(i);
73
+ // Take an ownership marker directly above it too, so repeated cycles cannot
74
+ // accumulate orphaned comments.
75
+ if (i > 0 && lines[i - 1]!.includes(OCX_SECTION_MARKER)) drop.add(i - 1);
76
+ }
77
+ if (drop.size === 0) return content;
78
+ return lines.filter((_, i) => !drop.has(i)).join(String.fromCharCode(10));
79
+ }
80
+
53
81
  export function hasInjectedOpenaiBaseUrl(content: string): boolean {
54
82
  const lines = content.split("\n");
55
83
  const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
@@ -2,7 +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 { hasInjectedCodexRouting } from "./injected-marker";
5
+ import { hasInjectedCodexRouting, rootTomlString } from "./injected-marker";
6
6
  import { CODEX_HOME, CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "./paths";
7
7
 
8
8
  /**
@@ -22,6 +22,24 @@ interface Journal {
22
22
  originalProfile: string | null;
23
23
  injectedConfigHash?: string;
24
24
  injectedProfileHash?: string | null;
25
+ /**
26
+ * The exact root `openai_base_url` this injection wrote, when it wrote one.
27
+ *
28
+ * #1798: ownership used to be inferred from a marker COMMENT on the preceding line,
29
+ * which a reserializing Codex app deletes while keeping the value. Recording the value
30
+ * we actually wrote makes ownership provable from evidence rather than from formatting,
31
+ * and it is what lets restore tell OUR loopback URL apart from a gateway the user set.
32
+ */
33
+ injectedOpenaiBaseUrl?: string | null;
34
+ /**
35
+ * The catalog path this injection actually wrote to.
36
+ *
37
+ * #1798: restore re-resolves the catalog from the CURRENT config, so a Codex app rewrite
38
+ * that dropped `model_catalog_json` sends restore to the default catalog while the
39
+ * proxy-written one is left routed. The injected path is the only durable record of which
40
+ * file we actually touched.
41
+ */
42
+ injectedCatalogPath?: string | null;
25
43
  pid: number;
26
44
  timestamp: string;
27
45
  }
@@ -96,9 +114,30 @@ export function markJournalInjectedState(config: string, profile: string | null)
96
114
  if (journal.injectedConfigHash) return;
97
115
  journal.injectedConfigHash = sha256(config) ?? undefined;
98
116
  journal.injectedProfileHash = sha256(profile);
117
+ // Read from the bytes we are about to install, not from the file: another writer may
118
+ // already have rewritten it, and then the recorded value would describe their config.
119
+ journal.injectedOpenaiBaseUrl = rootTomlString(config, "openai_base_url");
120
+ journal.injectedCatalogPath = rootTomlString(config, "model_catalog_json");
99
121
  atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
100
122
  }
101
123
 
124
+ /**
125
+ * The root `openai_base_url` the last injection wrote, or null when it wrote none.
126
+ *
127
+ * #1798: the fallback strip recognizes an injected URL by the marker COMMENT above it,
128
+ * and a Codex app rewrite keeps values while dropping comments. This is the evidence that
129
+ * survives such a rewrite, so restore can still prove the URL is ours -- and, just as
130
+ * importantly, prove that a DIFFERENT URL is not.
131
+ */
132
+ export function journaledInjectedOpenaiBaseUrl(): string | null {
133
+ return readJournal()?.injectedOpenaiBaseUrl ?? null;
134
+ }
135
+
136
+ /** The catalog path the last injection wrote to, or null when none was recorded. */
137
+ export function journaledInjectedCatalogPath(): string | null {
138
+ return readJournal()?.injectedCatalogPath ?? null;
139
+ }
140
+
102
141
  export function removeJournal(): void {
103
142
  try { unlinkSync(JOURNAL_PATH); } catch { /* ignore */ }
104
143
  }
@@ -3,6 +3,7 @@ import { captureCatalogAdmissionSnapshot } from "./catalog-admission";
3
3
  import { convergeCodexCatalog } from "./convergence";
4
4
  import type {
5
5
  CatalogDisposition,
6
+ CatalogFailureCause,
6
7
  CatalogOnlyOutcome,
7
8
  CodexHistoryState,
8
9
  CodexObservedState,
@@ -49,6 +50,58 @@ function notEvaluatedObserved(history: CodexHistoryState): CodexObservedState {
49
50
  };
50
51
  }
51
52
 
53
+ /** Recognized errno/code tokens. Anything else is dropped rather than echoed. */
54
+ const RECOGNIZED_FAILURE_CODES: ReadonlySet<string> = new Set([
55
+ "ENOSPC", "EACCES", "EPERM", "EROFS", "ENOENT", "SQLITE_BUSY",
56
+ ]);
57
+
58
+ /**
59
+ * Reduce a caught error to an allowlisted cause (#1784).
60
+ *
61
+ * Nothing from the error text reaches the caller: `kind` is chosen from a closed set and
62
+ * `code` is only emitted when it is a recognized token. An `Error.message` routinely carries
63
+ * paths, home directories and account ids, and `redactSecretString` masks token shapes but
64
+ * none of those, so the message is never a safe thing to forward from here.
65
+ */
66
+ function catalogFailureCause(error: unknown): CatalogFailureCause {
67
+ const raw = (error as { code?: unknown } | null)?.code;
68
+ const code = typeof raw === "string" && RECOGNIZED_FAILURE_CODES.has(raw)
69
+ ? raw as CatalogFailureCause["code"]
70
+ : undefined;
71
+ if (error instanceof TypeError || error instanceof RangeError || error instanceof SyntaxError) {
72
+ return { kind: "invalid-request", ...(code ? { code } : {}) };
73
+ }
74
+ if (code === "SQLITE_BUSY") return { kind: "lock-busy", code };
75
+ if (code !== undefined) return { kind: "io", code };
76
+ return { kind: "unknown" };
77
+ }
78
+
79
+ /**
80
+ * Classify a failure that is NOT a filesystem problem.
81
+ *
82
+ * `disk` used to be the catch-all, so an operator saw "non-retryable disk failure" for a
83
+ * malformed request. Reserve `disk` for real IO and route the rest to honest reasons.
84
+ */
85
+ function classifiedCatalogFailure(error: unknown, commitBegan: boolean): CatalogDisposition {
86
+ const cause = catalogFailureCause(error);
87
+ const reason = cause.kind === "invalid-request"
88
+ ? "request-invalid" as const
89
+ : cause.kind === "lock-busy"
90
+ ? "admission" as const
91
+ : cause.kind === "io"
92
+ ? "disk" as const
93
+ : "internal" as const;
94
+ return {
95
+ status: "failed",
96
+ reason,
97
+ phase: commitBegan ? "commit" : "gather",
98
+ // Contention is the one class worth retrying unchanged.
99
+ retryable: reason === "admission",
100
+ partialWrite: commitBegan,
101
+ cause,
102
+ };
103
+ }
104
+
52
105
  function unexpectedCatalogFailure(commitBegan: boolean): CatalogDisposition {
53
106
  return {
54
107
  status: "failed",
@@ -64,7 +117,7 @@ function admissionFailure(error: unknown): CatalogDisposition {
64
117
  if (message.includes("config generation is busy") || message.includes("config generation is database")) {
65
118
  return { status: "skipped", reason: "busy", retryable: true };
66
119
  }
67
- return unexpectedCatalogFailure(false);
120
+ return classifiedCatalogFailure(error, false);
68
121
  }
69
122
 
70
123
  /** Project catalog work into the shared no-change/not-evaluated outcome shape. */
@@ -107,7 +160,7 @@ export function createManagementConvergeCodex(
107
160
  } catch (error) {
108
161
  return projectCatalogOnlyOutcome({
109
162
  changed: false,
110
- catalogRefresh: commitBegan ? unexpectedCatalogFailure(true) : admissionFailure(error),
163
+ catalogRefresh: commitBegan ? classifiedCatalogFailure(error, true) : admissionFailure(error),
111
164
  });
112
165
  }
113
166
  };
@@ -23,6 +23,63 @@ export interface CodexPreStreamRejection {
23
23
  alternateRetryEligible: boolean;
24
24
  resetCreditEligible: boolean;
25
25
  semanticCode?: CodexResetEligibleExhaustionCode;
26
+ /**
27
+ * Structured denial evidence for a 403. Present only when the upstream body names a
28
+ * workspace/entitlement denial, which proves the CREDENTIAL is valid and the account
29
+ * simply lacks access here (#1789). Status alone can never set this.
30
+ */
31
+ denial?: "workspace" | "entitlement";
32
+ }
33
+
34
+ /**
35
+ * Upstream codes that identify a WORKSPACE denial rather than a bad credential.
36
+ *
37
+ * #1789: a K12 account whose credential validates and whose WHAM usage returns 200 still
38
+ * gets 403 `codex_workspace_access_denied` on a routed prompt. Treating that as a credential
39
+ * failure tells the user to re-authenticate a credential that is already valid, and the loop
40
+ * repeats forever.
41
+ */
42
+ const WORKSPACE_DENIAL_CODES: ReadonlySet<string> = new Set([
43
+ "codex_workspace_access_denied",
44
+ "workspace_access_denied",
45
+ ]);
46
+
47
+ const ENTITLEMENT_DENIAL_CODES: ReadonlySet<string> = new Set([
48
+ "codex_entitlement_missing",
49
+ "entitlement_missing",
50
+ ]);
51
+
52
+ /** Read a structured denial code out of a 403 body. Fails closed to undefined. */
53
+ async function denialFromResponse(
54
+ response: Response,
55
+ signal?: AbortSignal,
56
+ ): Promise<"workspace" | "entitlement" | undefined> {
57
+ try {
58
+ const body = await readBoundedResponseBody(response.clone(), { signal, fatalUtf8: true });
59
+ if (!body.displaySafe || body.truncated || !body.text.trim()) return undefined;
60
+ if (isUnsafeJsonDocument(body.text)) return undefined;
61
+ const payload = JSON.parse(body.text) as unknown;
62
+ const code = structuredDenialCode(payload);
63
+ if (code === undefined) return undefined;
64
+ if (WORKSPACE_DENIAL_CODES.has(code)) return "workspace";
65
+ if (ENTITLEMENT_DENIAL_CODES.has(code)) return "entitlement";
66
+ return undefined;
67
+ } catch {
68
+ // Same fail-closed rule as the exhaustion classifier: an unreadable body must not
69
+ // downgrade a credential failure into a workspace one.
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ /** Own-property `code` lookup at the top level or under `error`. No coercion, no accessors. */
75
+ function structuredDenialCode(payload: unknown): string | undefined {
76
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) return undefined;
77
+ const direct = (payload as Record<string, unknown>).code;
78
+ if (typeof direct === "string") return direct;
79
+ const error = (payload as Record<string, unknown>).error;
80
+ if (error === null || typeof error !== "object" || Array.isArray(error)) return undefined;
81
+ const nested = (error as Record<string, unknown>).code;
82
+ return typeof nested === "string" ? nested : undefined;
26
83
  }
27
84
 
28
85
  const RESET_ELIGIBLE_CODES: ReadonlySet<string> = new Set(RESET_ELIGIBLE_CODE_VALUES);
@@ -205,7 +262,10 @@ export async function classifyCodexPreStreamRejection(
205
262
  ): Promise<CodexPreStreamRejection> {
206
263
  const status = response.status;
207
264
  if (status === 401) return rejection(status, "authentication-error");
208
- if (status === 403) return rejection(status, "permission-error");
265
+ if (status === 403) {
266
+ const denial = await denialFromResponse(response, options.signal);
267
+ return { ...rejection(status, "permission-error"), ...(denial ? { denial } : {}) };
268
+ }
209
269
  if (TRANSIENT_SERVER_STATUSES.has(status)) return rejection(status, "transient-server-error");
210
270
  if (status !== 429 && status !== 402) return rejection(status, "other");
211
271
 
@@ -9,6 +9,20 @@ export type StoredAccountQuota = {
9
9
  monthlyPercent?: number;
10
10
  weeklyResetAt?: number;
11
11
  monthlyResetAt?: number;
12
+ /**
13
+ * A sub-day burst window, when upstream declares one (#1791).
14
+ *
15
+ * K12 and similar plans enforce a rolling 5-hour limit ALONGSIDE the weekly one.
16
+ * Not folding it into `weeklyPercent` stopped the mislabeling, but dropping it
17
+ * entirely hides a limit that genuinely blocks the account: a 429 at 100% here is
18
+ * real even while the weekly quota is untouched.
19
+ *
20
+ * `shortWindowSeconds` is retained because the duration is the only thing that makes
21
+ * this window self-describing; the slot it arrived in is not stable across plans.
22
+ */
23
+ shortPercent?: number;
24
+ shortResetAt?: number;
25
+ shortWindowSeconds?: number;
12
26
  resetCredits?: number;
13
27
  /**
14
28
  * True when `monthlyPercent` came from an explicitly-monthly PRIMARY window —
@@ -56,6 +70,19 @@ type WhamUsageWindow = {
56
70
  };
57
71
 
58
72
  const MONTHLY_WINDOW_MIN_SECONDS = 28 * 24 * 60 * 60;
73
+ /**
74
+ * Shortest window still plausibly the WEEKLY quota (#1791).
75
+ *
76
+ * K12 and similar plans send a 5-hour primary window plus a 7-day secondary. Folding the
77
+ * primary into `weeklyPercent` reported the 5-hour bar as the weekly one and discarded the
78
+ * real weekly reading entirely, so the dashboard showed a window that reset every few hours
79
+ * and routing never saw the limit that actually gates the account.
80
+ *
81
+ * 24h is the discriminator: anything shorter is a burst window, not a weekly one. A window
82
+ * with no declared duration is unchanged, because older payloads omit `limit_window_seconds`
83
+ * and guessing there would break every legacy account.
84
+ */
85
+ const WEEKLY_WINDOW_MIN_SECONDS = 24 * 60 * 60;
59
86
  const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60;
60
87
 
61
88
  const accountQuota = new Map<string, StoredAccountQuota>();
@@ -72,13 +99,16 @@ export const CODEX_UNKNOWN_USAGE_SCORE = 101;
72
99
  export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
73
100
 
74
101
  export function isCodexQuotaExhausted(
75
- quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent"> | null,
102
+ quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "shortPercent"> | null,
76
103
  plan?: unknown,
77
104
  ): boolean {
78
105
  if (!quota) return false;
106
+ // The burst window counts on EVERY plan. It is upstream-enforced independently, so an
107
+ // account at 100% there is blocked regardless of which longer window governs its plan;
108
+ // omitting it would route traffic straight into a 429 (#1791).
79
109
  const values = codexQuotaWindowForPlan(plan) === "monthly"
80
- ? [quota.monthlyPercent]
81
- : [quota.weeklyPercent, quota.monthlyPercent];
110
+ ? [quota.monthlyPercent, quota.shortPercent]
111
+ : [quota.weeklyPercent, quota.monthlyPercent, quota.shortPercent];
82
112
  return values.some(value => typeof value === "number"
83
113
  && Number.isFinite(value)
84
114
  && value >= CODEX_EXHAUSTED_USAGE_PERCENT);
@@ -104,7 +134,7 @@ export function codexQuotaWindowForPlan(plan?: unknown): "monthly" | "weekly" {
104
134
  }
105
135
 
106
136
  export function isCompleteCodexQuotaRecoverySnapshot(
107
- quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow"> | null,
137
+ quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow" | "shortPercent"> | null,
108
138
  plan?: unknown,
109
139
  ): boolean {
110
140
  if (!quota || isCodexQuotaExhausted(quota, plan)) return false;
@@ -160,6 +190,15 @@ function hasKnownQuotaValue(quota: Omit<StoredAccountQuota, "updatedAt">): boole
160
190
  .some(value => typeof value === "number" && Number.isFinite(value));
161
191
  }
162
192
 
193
+ /** True only for a window that DECLARES a duration shorter than a day. */
194
+ function isExplicitShortWindow(window: WhamUsageWindow | null | undefined): boolean {
195
+ const seconds = window?.limit_window_seconds;
196
+ return typeof seconds === "number"
197
+ && Number.isFinite(seconds)
198
+ && seconds > 0
199
+ && seconds < WEEKLY_WINDOW_MIN_SECONDS;
200
+ }
201
+
163
202
  function isExplicitMonthlyWindow(window: WhamUsageWindow | null | undefined): boolean {
164
203
  const seconds = window?.limit_window_seconds;
165
204
  return typeof seconds === "number"
@@ -465,10 +504,25 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
465
504
  // - 선택한 방식: only an explicit primary duration of at least 28 days changes it to monthly.
466
505
  // - 다른 대안 대신 이 방식을 선택한 이유: it accepts calendar-month variance and preserves legacy payloads.
467
506
  // - 장점, 단점 및 영향: Team monthly quotas classify correctly; unknown durations remain weekly by design.
468
- const weeklyPercent = primaryIsMonthly ? secondaryPercent : primaryPercent ?? secondaryPercent;
507
+ // #1791: a primary window that declares a sub-day duration is a burst window, not the
508
+ // weekly one. Skip it so the secondary (the real 7-day window) is what lands in
509
+ // `weeklyPercent`; without this the 5-hour bar was reported as weekly and the actual
510
+ // weekly reading was dropped on the floor.
511
+ const primaryIsShort = isExplicitShortWindow(primaryWindow);
512
+ const weeklyCandidatePercent = primaryIsShort ? undefined : primaryPercent;
513
+ const weeklyCandidateResetAt = primaryIsShort ? undefined : primaryResetAt;
514
+ // Keep the burst reading instead of dropping it on the floor: it is a real limit, and
515
+ // the account is blocked when it fills even though the weekly window is fine (#1791).
516
+ if (primaryIsShort && primaryPercent !== undefined) {
517
+ quota.shortPercent = primaryPercent;
518
+ if (primaryResetAt !== undefined) quota.shortResetAt = primaryResetAt;
519
+ const seconds = primaryWindow?.limit_window_seconds;
520
+ if (typeof seconds === "number" && Number.isFinite(seconds)) quota.shortWindowSeconds = seconds;
521
+ }
522
+ const weeklyPercent = primaryIsMonthly ? secondaryPercent : weeklyCandidatePercent ?? secondaryPercent;
469
523
  const weeklyResetAt = primaryIsMonthly
470
524
  ? secondaryResetAt
471
- : primaryPercent !== undefined ? primaryResetAt : secondaryResetAt;
525
+ : weeklyCandidatePercent !== undefined ? weeklyCandidateResetAt : secondaryResetAt;
472
526
  const monthlyPercent = primaryIsMonthly ? primaryPercent ?? tertiaryPercent : tertiaryPercent;
473
527
  const monthlyResetAt = primaryIsMonthly && primaryPercent !== undefined ? primaryResetAt : tertiaryResetAt;
474
528
  if (thirtyDayOnly) {