@bitkyc08/opencodex 2.55.0 → 2.56.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 (167) hide show
  1. package/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -1
  4. package/src/adapters/base.ts +21 -0
  5. package/src/adapters/cursor/transport-retry.ts +46 -1
  6. package/src/adapters/cursor.ts +4 -0
  7. package/src/adapters/kiro/adapter.ts +42 -1
  8. package/src/adapters/kiro-retry.ts +23 -4
  9. package/src/adapters/openai-chat/errors.ts +116 -0
  10. package/src/adapters/openai-chat/messages.ts +346 -0
  11. package/src/adapters/openai-chat/passthrough.ts +146 -0
  12. package/src/adapters/openai-chat/response-events.ts +117 -0
  13. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  14. package/src/adapters/openai-chat/tool-schema.ts +477 -0
  15. package/src/adapters/openai-chat/wire.ts +50 -0
  16. package/src/adapters/openai-chat.ts +33 -1445
  17. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  18. package/src/adapters/openai-responses/image-gen.ts +406 -0
  19. package/src/adapters/openai-responses/internal.ts +3 -0
  20. package/src/adapters/openai-responses/passthrough.ts +611 -0
  21. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  22. package/src/adapters/openai-responses/reasoning.ts +220 -0
  23. package/src/adapters/openai-responses/request-strips.ts +185 -0
  24. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  25. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  26. package/src/adapters/openai-responses/web-search.ts +156 -0
  27. package/src/adapters/openai-responses.ts +4 -2625
  28. package/src/bridge/errors.ts +34 -0
  29. package/src/bridge/internal.ts +174 -0
  30. package/src/bridge/response-json.ts +624 -0
  31. package/src/bridge/sse.ts +1444 -0
  32. package/src/bridge.ts +5 -2204
  33. package/src/chat/inbound.ts +12 -1
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-store.ts +71 -9
  36. package/src/codex/auth-api/account-list.ts +507 -0
  37. package/src/codex/auth-api/http.ts +32 -0
  38. package/src/codex/auth-api/login-flow.ts +554 -0
  39. package/src/codex/auth-api/login-state.ts +64 -0
  40. package/src/codex/auth-api/main-account-probe.ts +331 -0
  41. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  42. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  43. package/src/codex/auth-api/reset-credit-service.ts +422 -0
  44. package/src/codex/auth-api/routes.ts +425 -0
  45. package/src/codex/auth-api/runtime-config.ts +48 -0
  46. package/src/codex/auth-api.ts +27 -3118
  47. package/src/codex/auth-context.ts +95 -28
  48. package/src/codex/catalog/auto-review.ts +507 -0
  49. package/src/codex/catalog/build-entries.ts +981 -0
  50. package/src/codex/catalog/combo-member.ts +375 -0
  51. package/src/codex/catalog/derive-entry.ts +229 -0
  52. package/src/codex/catalog/effort.ts +0 -1
  53. package/src/codex/catalog/gated-native-warn.ts +63 -0
  54. package/src/codex/catalog/gather-capture.ts +533 -0
  55. package/src/codex/catalog/model-hints.ts +691 -0
  56. package/src/codex/catalog/model-visibility.ts +304 -0
  57. package/src/codex/catalog/provider-fetch.ts +52 -2942
  58. package/src/codex/catalog/provider-models.ts +685 -0
  59. package/src/codex/catalog/restore.ts +132 -0
  60. package/src/codex/catalog/retained-sync.ts +706 -0
  61. package/src/codex/catalog/routed-gather.ts +858 -0
  62. package/src/codex/catalog/subagent-roster.ts +176 -0
  63. package/src/codex/catalog/sync.ts +52 -2698
  64. package/src/codex/inject/config-toml.ts +563 -0
  65. package/src/codex/inject/remove.ts +192 -0
  66. package/src/codex/inject/restore.ts +540 -0
  67. package/src/codex/inject/routing-classify.ts +109 -0
  68. package/src/codex/inject/routing-target.ts +125 -0
  69. package/src/codex/inject.ts +81 -1436
  70. package/src/codex/lineage.ts +458 -0
  71. package/src/codex/pool-refresh-backoff.ts +152 -0
  72. package/src/codex/routing/active-account.ts +194 -0
  73. package/src/codex/routing/cooldown-math.ts +275 -0
  74. package/src/codex/routing/health-store.ts +402 -0
  75. package/src/codex/routing/probe-lease.ts +358 -0
  76. package/src/codex/routing/selection.ts +703 -0
  77. package/src/codex/routing/thread-affinity.ts +538 -0
  78. package/src/codex/routing.ts +353 -2234
  79. package/src/codex/shim-fingerprint.ts +223 -0
  80. package/src/codex/shim-inspect.ts +175 -0
  81. package/src/codex/shim-probe.ts +367 -0
  82. package/src/codex/shim-restore-lock.ts +169 -0
  83. package/src/codex/shim-state-file.ts +151 -0
  84. package/src/codex/shim-templates.ts +265 -0
  85. package/src/codex/shim.ts +48 -1268
  86. package/src/config/diagnostics.ts +705 -0
  87. package/src/config/feature-flags.ts +55 -0
  88. package/src/config/live-reconcile.ts +403 -0
  89. package/src/config/load-degrade.ts +880 -0
  90. package/src/config/mutation-lock.ts +244 -0
  91. package/src/config/openai-tier-backup.ts +268 -0
  92. package/src/config/persist-unlocked.ts +92 -0
  93. package/src/config/proxy-env.ts +188 -0
  94. package/src/config/salvage.ts +244 -0
  95. package/src/config/schema/config-schema.ts +640 -0
  96. package/src/config/schema/leaf-validators.ts +855 -0
  97. package/src/config/warn-memo.ts +28 -0
  98. package/src/config.ts +234 -4481
  99. package/src/generated/compatibility-version.json +539 -39
  100. package/src/lib/request-execution-budget.ts +69 -20
  101. package/src/lib/spend-reservation-ledger.ts +940 -0
  102. package/src/lib/upstream-retry.ts +55 -11
  103. package/src/lib/workflow-budget.ts +553 -30
  104. package/src/providers/quota/account-cache.ts +441 -0
  105. package/src/providers/quota/antigravity.ts +295 -0
  106. package/src/providers/quota/report-cache.ts +320 -0
  107. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  108. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  109. package/src/providers/quota.ts +324 -3079
  110. package/src/providers/registry/entries-core.ts +1221 -0
  111. package/src/providers/registry/entries-extended.ts +1204 -0
  112. package/src/providers/registry/model-seeds.ts +908 -0
  113. package/src/providers/registry/types.ts +352 -0
  114. package/src/providers/registry.ts +24 -3536
  115. package/src/responses/continuation-ownership.ts +29 -0
  116. package/src/responses/state/replay-fingerprint.ts +80 -0
  117. package/src/responses/state/snapshot-codec.ts +104 -0
  118. package/src/responses/state/spill-failure.ts +118 -0
  119. package/src/responses/state/spill-queue.ts +665 -0
  120. package/src/responses/state/temp-recovery.ts +257 -0
  121. package/src/responses/state.ts +82 -1143
  122. package/src/routing/identity-domains.ts +449 -0
  123. package/src/routing/probe-lease.ts +511 -0
  124. package/src/server/index/bounded-request.ts +88 -0
  125. package/src/server/index/live-sideband.ts +565 -0
  126. package/src/server/index/serve-options.ts +1766 -0
  127. package/src/server/index/startup-warnings.ts +213 -0
  128. package/src/server/index/websocket-handler.ts +335 -0
  129. package/src/server/index.ts +40 -2547
  130. package/src/server/management/route-registry.ts +26 -23
  131. package/src/server/management/shared.ts +8 -5
  132. package/src/server/management/workflow-budget-routes.ts +133 -0
  133. package/src/server/management-api.ts +12 -0
  134. package/src/server/request-log-conversation.ts +9 -7
  135. package/src/server/request-log.ts +245 -1
  136. package/src/server/responses/account-change-state.ts +233 -0
  137. package/src/server/responses/adapter-continuation.ts +514 -0
  138. package/src/server/responses/adapter-delivery.ts +214 -0
  139. package/src/server/responses/adapter-dispatch.ts +971 -0
  140. package/src/server/responses/compact.ts +59 -4
  141. package/src/server/responses/completion-policy.ts +33 -0
  142. package/src/server/responses/core-auth.ts +527 -0
  143. package/src/server/responses/core-codex-account.ts +859 -0
  144. package/src/server/responses/core-combo-failure.ts +210 -0
  145. package/src/server/responses/core-combo.ts +707 -0
  146. package/src/server/responses/core-errors.ts +152 -0
  147. package/src/server/responses/core-lifetime.ts +95 -0
  148. package/src/server/responses/core-normalize.ts +350 -0
  149. package/src/server/responses/core-opaque-recovery.ts +380 -0
  150. package/src/server/responses/core-options.ts +159 -0
  151. package/src/server/responses/core-replay.ts +225 -0
  152. package/src/server/responses/core.ts +192 -8893
  153. package/src/server/responses/passthrough-delivery.ts +856 -0
  154. package/src/server/responses/passthrough-dispatch.ts +1476 -0
  155. package/src/server/responses/passthrough-execution.ts +54 -0
  156. package/src/server/responses/request-prepare.ts +970 -0
  157. package/src/server/responses/request-send-budget.ts +164 -0
  158. package/src/server/responses/request-sidecar-auth.ts +149 -0
  159. package/src/server/responses/request-transport.ts +744 -0
  160. package/src/server/responses/response-effects.ts +157 -0
  161. package/src/server/responses/run-turn-execution.ts +448 -0
  162. package/src/server/responses/sidecar-execution.ts +469 -0
  163. package/src/server/responses-image-gen-repair.ts +1 -1
  164. package/src/server/workflow-refusal.ts +84 -0
  165. package/src/types/config.ts +30 -0
  166. package/src/usage/log.ts +146 -0
  167. package/src/usage/summary.ts +171 -21
package/src/config.ts CHANGED
@@ -1,125 +1,20 @@
1
- import { modelCapabilitiesConfigError, mergeModelCapabilities, sanitizeModelCapabilitiesForLoad } from "./config/provider-validation";
2
- import { createHash } from "node:crypto";
3
- import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs";
4
- import { dirname, join } from "node:path";
5
- import { Database } from "bun:sqlite";
6
- import * as z from "zod/v4";
7
- import { isValidProviderName, hasOwnProvider } from "./config/provider-name";
8
- import { MULTI_AGENT_SURFACE_ADVISORY_VERSION } from "./config/multi-agent-surface";
9
- import { DEFAULT_SUBAGENT_MODELS, SUBAGENT_MODELS_VERSION } from "./config/subagent-models";
10
- export { DEFAULT_SUBAGENT_MODELS } from "./config/subagent-models";
11
- import {
12
- apiKeyTransportConfigError,
13
- booleanRecordConfigError,
14
- configReasoningPinsConfigError,
15
- modelPinnedEffortsConfigError,
16
- pinnedReasoningEffortConfigError,
17
- modelAdapterRecordConfigError,
18
- modelDisplayNamesConfigError,
19
- autoReviewModelOverridesConfigError,
20
- autoReviewModelTargetConfigError,
21
- nonBlankStringArrayConfigError,
22
- normalizeNonBlankStringArray,
23
- normalizeAutoReviewModelOverrides,
24
- positiveIntegerConfigError,
25
- positiveIntegerRecordConfigError,
26
- providerBaseUrlConfigError,
27
- providerHeadersConfigError,
28
- reasoningSummaryDeliveryRecordConfigError,
29
- upstreamHttpVersionConfigError,
30
- } from "./config/provider-validation";
31
- import {
32
- bumpConfigGenerationAtPath,
33
- bumpCurrentConfigGeneration,
34
- initializeConfigGeneration,
35
- observeConfigGenerationAtPath,
36
- readConfigGenerationAtPath,
37
- readConfigGenerationInTransaction,
38
- type ConfigGenerationObservation,
39
- } from "./codex/generation";
40
- import type {
41
- BumpConfigGeneration,
42
- ConfigGeneration,
43
- ReadConfigGeneration,
44
- WithExpectedConfigGenerationSync,
45
- } from "./codex/convergence-types";
46
- import {
47
- CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
48
- codexAccountNamespaceForModel,
49
- codexProviderNamespaceKey,
50
- isValidCodexAccountNamespaceTarget,
51
- MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET,
52
- } from "./codex/account-namespace-match";
53
- import { isCodexAccountPriorityKey } from "./codex/account-priority";
54
- import { loopbackCompanionAllowed } from "./codex/loopback-target";
55
- import { UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD } from "./codex/upstream-host-health";
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { OcxConfig } from "./types";
4
+ import { configReasoningPinsConfigError } from "./config/provider-validation";
5
+ import { recordOwnedConfigPath } from "./lib/config-ownership";
6
+ import { assertNotRealHomeUnderTest } from "./lib/test-home-guard";
56
7
  import {
57
8
  adoptCustomModelCatalogMigration,
58
9
  projectCustomModelCatalogMigration,
59
10
  } from "./codex/custom-model-catalog-migration";
60
- import { parseAccountPriority } from "./codex/pool-rotation";
61
- import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types";
62
- import { routingProfileIssues } from "./routing/profile";
63
- import { POLICY_NAMESPACE } from "./routing/profile-namespace";
64
- import {
65
- forgetEphemeralSecretPath,
66
- hardenSecretDir,
67
- hardenSecretPath,
68
- windowsSecretAclApplies,
69
- } from "./lib/windows-secret-acl";
70
- import { recordOwnedConfigPath } from "./lib/config-ownership";
71
- import { assertNotRealHomeUnderTest } from "./lib/test-home-guard";
72
- import { providerDestinationConfigError } from "./lib/destination-policy";
73
- import { redactSecretString } from "./lib/redact";
74
- import { openRouterRoutingConfigError } from "./providers/openrouter-routing";
75
- import { MODEL_ALIAS_PATTERN } from "./providers/default-aliases";
76
- import { MODEL_DISCOVERY_MAX_MODELS } from "./providers/model-discovery-limits";
77
- import { vercelGatewayRoutingConfigError } from "./providers/vercel-gateway-routing";
78
- import {
79
- MODEL_ADAPTER_OVERRIDE_ALLOWED,
80
- OPENAI_PROVIDER_TIER_VERSION,
81
- pinnedWireAdapter,
82
- PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS,
83
- UPSTREAM_HTTP_VERSION_VALUES,
84
- type OcxClaudeCodeConfig,
85
- type OcxConfig,
86
- type OcxApiKeyEntry,
87
- type OcxProviderConfig,
88
- type FastWire,
89
- type ProviderCostOverlay,
90
- } from "./types";
91
- import type { OcxRuntimeRole } from "./types/config";
92
- import { OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
93
- import { modelAutoCompactTokenLimitsConfigError } from "./providers/auto-compact-budget";
94
- import { fastWireDeclarationError, hasFastWireCapabilityConflict } from "./providers/fastwire";
95
- import {
96
- getProviderRegistryEntry,
97
- providerMatchesRegistryTransport,
98
- providerModelWireDefault,
99
- registryModelServiceTierCapabilityApplies,
100
- } from "./providers/registry";
101
- import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models";
102
- import { parseDesktopProfile } from "./claude/desktop-profile";
103
- import { isCodexReasoningEffort } from "./reasoning-effort";
104
- import {
105
- COST4_RATE_KEYS,
106
- isValidCost4Rate,
107
- refreshPreservedProviderOwner,
108
- refreshUserCostOverlays,
109
- withPreservedDiskOnlyProviders,
110
- } from "./usage/user-cost-overlays";
111
- import { MAX_COST4_RATE } from "./usage/expected-prices";
11
+ import { refreshUserCostOverlays } from "./usage/user-cost-overlays";
112
12
  import {
113
- DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES,
114
- MAX_APP_OWNED_MEMORY_BUDGET_MB,
115
- MIN_APP_OWNED_MEMORY_BUDGET_MB,
116
- } from "./lib/app-owned-memory";
117
- import { isHostedToolUnsupportedForModel } from "./responses/hosted-tool-policy";
118
- import {
119
- atomicWriteFile,
120
- isMissingPathError,
121
- nextAtomicTempSequence,
122
- } from "./config/atomic-write";
13
+ clearPendingConfigTopLevelDeletions,
14
+ projectConfigRebaseProvenance,
15
+ } from "./config/rebase-provenance";
16
+ import { getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths";
17
+ export { DEFAULT_SUBAGENT_MODELS } from "./config/subagent-models";
123
18
  export {
124
19
  AtomicWriteResidualTempError,
125
20
  AtomicWriteSecretResidualError,
@@ -132,13 +27,6 @@ export {
132
27
  type AtomicWriteAsyncTestSeam,
133
28
  type AtomicWriteIO,
134
29
  } from "./config/atomic-write";
135
- import { getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths";
136
- import { InitialConfigPublicationError, publishInitialConfigNoReplace, type InitialConfigPublicationIO } from "./config/initialize";
137
- import {
138
- describeProxyForLog,
139
- readWindowsSystemProxy,
140
- type WindowsProxyRegistryReader,
141
- } from "./lib/windows-system-proxy";
142
30
  export { expandUserPath, getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths";
143
31
  export {
144
32
  getPidPath,
@@ -164,558 +52,15 @@ export {
164
52
  writeRuntimePort,
165
53
  type RuntimePortState,
166
54
  } from "./config/process-state";
167
- import {
168
- clearPendingConfigTopLevelDeletions,
169
- configHasRebaseProvenance,
170
- configRebaseDeletionKeys,
171
- CONFIG_REBASE_PROVENANCE_KEY,
172
- deleteConfigTopLevelKey,
173
- projectConfigRebaseProvenance,
174
- } from "./config/rebase-provenance";
175
55
  export { deleteConfigTopLevelKey } from "./config/rebase-provenance";
176
-
177
- export class OpenAiTierBackupCleanupError extends Error {
178
- constructor() { super("OpenAI tier backup temporary cleanup failed"); this.name = "OpenAiTierBackupCleanupError"; }
179
- }
180
-
181
- export class OpenAiTierBackupRollbackError extends Error {
182
- constructor() { super("OpenAI tier backup rollback failed"); this.name = "OpenAiTierBackupRollbackError"; }
183
- }
184
-
185
- export class OpenAiTierBackupCollisionError extends Error {
186
- readonly configPath?: string;
187
- constructor(configPath?: string) {
188
- super("Existing OpenAI tier backup differs from the current config");
189
- this.name = "OpenAiTierBackupCollisionError";
190
- this.configPath = configPath;
191
- }
192
- }
193
-
194
- export class OpenAiTierRollbackPreserveError extends Error {
195
- readonly code?: "missing" | "not-rollback" | "mismatch" | "exhausted";
196
- constructor(message: string, options?: ErrorOptions & { code?: OpenAiTierRollbackPreserveError["code"] }) {
197
- super(message, options);
198
- this.name = "OpenAiTierRollbackPreserveError";
199
- this.code = options?.code;
200
- }
201
- }
202
-
203
- export class OpenAiTierBackupSecretResidualError extends Error {
204
- constructor(readonly tempPath: string, options?: ErrorOptions) {
205
- super("OpenAI tier backup could not scrub or remove a secret-bearing temporary file", options);
206
- this.name = "OpenAiTierBackupSecretResidualError";
207
- }
208
- }
209
-
210
- export interface OpenAiTierBackupIO {
211
- exists(path: string): boolean;
212
- read(path: string): Uint8Array;
213
- createExclusive(path: string): void;
214
- write(path: string, bytes: Uint8Array): void;
215
- harden(path: string): void;
216
- publishNoReplace(temp: string, backup: string): void;
217
- truncate(path: string): void;
218
- unlink(path: string): void;
219
- }
220
-
221
- function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
222
- return left.byteLength === right.byteLength && left.every((value, index) => value === right[index]);
223
- }
224
-
225
- function isAlreadyExistsError(error: unknown): boolean {
226
- return (error as NodeJS.ErrnoException | undefined)?.code === "EEXIST";
227
- }
228
-
229
- /**
230
- * Classify an existing `.pre-openai-tiers-v2.bak` snapshot.
231
- *
232
- * - `"stale"`: unparseable JSON (not written by us / truncated) or already a
233
- * post-migration (tier v2) snapshot — safe to delete or replace.
234
- * - `"rollback"`: parses as a valid pre-migration (v1) config — a
235
- * user-intentional rollback point that must never be silently destroyed.
236
- *
237
- * Shared by the startup migration backup path and `ocx init` cleanup so both
238
- * apply the same preservation policy (issue #257 / sol review 260722).
239
- */
240
- export function classifyOpenAiTierBackup(backupBytes: Uint8Array): "stale" | "rollback" {
241
- try {
242
- // Use Buffer.from to ensure proper UTF-8 decoding from Uint8Array/Buffer.
243
- const parsed = JSON.parse(Buffer.from(backupBytes).toString("utf8")) as Record<string, unknown>;
244
- return parsed.openaiProviderTierVersion === 2 ? "stale" : "rollback";
245
- } catch {
246
- // Unparseable: not a config file we created, treat as stale.
247
- return "stale";
248
- }
249
- }
250
-
251
- export function backupConfigBeforeOpenAiTierMigration(
252
- configPath = getConfigPath(),
253
- io: OpenAiTierBackupIO = {
254
- exists: existsSync,
255
- read: target => readFileSync(target),
256
- createExclusive: target => { writeFileSync(target, new Uint8Array(), { flag: "wx", mode: 0o600 }); },
257
- write: (target, bytes) => writeFileSync(target, bytes),
258
- harden: target => {
259
- try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
260
- // Soft-fail: a wedged/failed icacls on CI temp volumes must not abort
261
- // startServer mid-suite (timeout + EBUSY cascade on shared TEST_DIR).
262
- // chmod above still applies; live credential writes keep required:true.
263
- if (process.platform === "win32") hardenSecretPath(target, { required: false });
264
- },
265
- publishNoReplace: (temp, backup) => linkSync(temp, backup),
266
- truncate: target => truncateSync(target, 0),
267
- unlink: unlinkSync,
268
- },
269
- ): "absent" | "created" | "reused" {
270
- const source = configPath;
271
- if (!io.exists(source)) return "absent";
272
- const original = io.read(source);
273
- // v2 snapshot path. The historical `.pre-openai-tiers-v1.bak` is read only by restore
274
- // docs/fixtures and is never reused or overwritten as the v2 snapshot.
275
- const backup = `${source}.pre-openai-tiers-v2.bak`;
276
- if (io.exists(backup)) {
277
- if (!sameBytes(original, io.read(backup))) {
278
- // The backup differs from the current config. Only treat it as stale when it is
279
- // clearly not a user-intentional rollback point:
280
- // - unparseable JSON: written by a different tool or truncated
281
- // - already at tier version 2: the backup is from a post-migration config (e.g.
282
- // ocx init wrote a fresh v2 config, making the old backup obsolete)
283
- // A backup that parses as a valid pre-migration (v1) config is kept as-is and
284
- // we throw a collision error, because silently replacing a user-created rollback
285
- // point would be surprising and potentially destructive.
286
- const backupBytes = io.read(backup);
287
- if (classifyOpenAiTierBackup(backupBytes) === "rollback") {
288
- throw new OpenAiTierBackupCollisionError(source);
289
- }
290
- console.warn("[openai-provider-migration] Replacing stale pre-migration backup (post-migration config was rewritten since last migration).");
291
- io.unlink(backup);
292
- } else {
293
- return "reused";
294
- }
295
- }
296
- const temp = `${backup}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`;
297
- let published = false;
298
- let cleanupAttempted = false;
299
-
300
- const scrubUnpublishedTemp = (): void => {
301
- cleanupAttempted = true;
302
- let scrubbed = false;
303
- try {
304
- io.truncate(temp);
305
- scrubbed = true;
306
- } catch (error) {
307
- if (isMissingPathError(error)) scrubbed = true;
308
- else {
309
- try { io.write(temp, new Uint8Array()); scrubbed = true; } catch { /* removal may still succeed */ }
310
- }
311
- }
312
- let removed = false;
313
- try {
314
- io.unlink(temp);
315
- removed = true;
316
- } catch (error) {
317
- if (isMissingPathError(error)) {
318
- removed = true;
319
- }
320
- else {
321
- try { io.unlink(temp); removed = true; }
322
- catch (retryError) {
323
- if (isMissingPathError(retryError)) {
324
- removed = true;
325
- }
326
- }
327
- }
328
- }
329
- if (removed) forgetEphemeralSecretPath(temp);
330
- if (!removed && !scrubbed) throw new OpenAiTierBackupSecretResidualError(temp);
331
- if (!removed) throw new OpenAiTierBackupCleanupError();
332
- };
333
-
334
- try {
335
- io.createExclusive(temp);
336
- io.write(temp, original);
337
- io.harden(temp);
338
- try {
339
- io.publishNoReplace(temp, backup);
340
- } catch (cause) {
341
- if (!isAlreadyExistsError(cause)) throw cause;
342
- const winner = io.read(backup);
343
- if (!sameBytes(original, winner)) throw new OpenAiTierBackupCollisionError(source);
344
- scrubUnpublishedTemp();
345
- return "reused";
346
- }
347
- published = true;
348
- try {
349
- io.unlink(temp);
350
- forgetEphemeralSecretPath(temp);
351
- } catch (firstError) {
352
- if (isMissingPathError(firstError)) {
353
- forgetEphemeralSecretPath(temp);
354
- } else try {
355
- io.unlink(temp);
356
- forgetEphemeralSecretPath(temp);
357
- } catch (secondError) {
358
- if (isMissingPathError(secondError)) {
359
- forgetEphemeralSecretPath(temp);
360
- return "created";
361
- }
362
- // temp and backup are hard links to the same inode. Roll back the backup
363
- // link before any truncation so the downgrade snapshot is never zeroed.
364
- try { io.unlink(backup); } catch { throw new OpenAiTierBackupRollbackError(); }
365
- published = false;
366
- scrubUnpublishedTemp();
367
- throw new OpenAiTierBackupCleanupError();
368
- }
369
- }
370
- return "created";
371
- } catch (cause) {
372
- if (!published && !cleanupAttempted) {
373
- scrubUnpublishedTemp();
374
- }
375
- throw cause;
376
- }
377
- }
378
-
379
- export interface OpenAiTierRollbackPreserveIO {
380
- exists(path: string): boolean;
381
- read(path: string): Uint8Array;
382
- copyExclusive(source: string, destination: string): void;
383
- unlink(path: string): void;
384
- }
385
-
386
- const DEFAULT_ROLLBACK_PRESERVE_IO: OpenAiTierRollbackPreserveIO = {
387
- exists: existsSync,
388
- read: target => readFileSync(target),
389
- copyExclusive: (source, destination) => {
390
- copyFileSync(source, destination, fsConstants.COPYFILE_EXCL);
391
- },
392
- unlink: unlinkSync,
393
- };
394
-
395
- const OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS = 16;
396
-
397
- /**
398
- * Copy a rollback-classified `.pre-openai-tiers-v2.bak` to a unique
399
- * `.pre-openai-tiers-v1-rollback.<timestamp>[suffix].bak` path, then unlink the
400
- * blocking v2 name. The original bytes are copied with no-replace publication;
401
- * the v2 path is removed only after the copy is verified. Shared by startup
402
- * migration recovery and `ocx init` cleanup so the two paths cannot drift.
403
- */
404
- export function preserveOpenAiTierRollbackSnapshot(
405
- configPath = getConfigPath(),
406
- io: OpenAiTierRollbackPreserveIO = DEFAULT_ROLLBACK_PRESERVE_IO,
407
- ): string {
408
- const backup = `${configPath}.pre-openai-tiers-v2.bak`;
409
- if (!io.exists(backup)) {
410
- throw new OpenAiTierRollbackPreserveError("OpenAI tier rollback backup is missing", { code: "missing" });
411
- }
412
- const original = io.read(backup);
413
- if (classifyOpenAiTierBackup(original) !== "rollback") {
414
- throw new OpenAiTierRollbackPreserveError("OpenAI tier backup is not a rollback snapshot", { code: "not-rollback" });
415
- }
416
- for (let attempt = 0; attempt < OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS; attempt++) {
417
- const preserved = `${configPath}.pre-openai-tiers-v1-rollback.${Date.now()}${attempt ? `-${attempt}` : ""}.bak`;
418
- try {
419
- io.copyExclusive(backup, preserved);
420
- } catch (error) {
421
- if (isAlreadyExistsError(error)) continue;
422
- throw error;
423
- }
424
- let copied: Uint8Array;
425
- try {
426
- copied = io.read(preserved);
427
- } catch (error) {
428
- throw new OpenAiTierRollbackPreserveError("Failed to read preserved rollback snapshot", { cause: error, code: "mismatch" });
429
- }
430
- if (!sameBytes(original, copied)) {
431
- try { io.unlink(preserved); } catch { /* keep the original backup; incomplete copy is best-effort */ }
432
- throw new OpenAiTierRollbackPreserveError("Preserved rollback snapshot does not match source bytes", { code: "mismatch" });
433
- }
434
- io.unlink(backup);
435
- return preserved;
436
- }
437
- throw new OpenAiTierRollbackPreserveError("Unable to find a unique rollback snapshot path", { code: "exhausted" });
438
- }
439
-
440
- const warnedConfigFallbacks = new Set<string>();
441
- const warnedInheritedFastWireConflicts = new Set<string>();
442
- let lastWarningReconciledGeneration = 0;
443
-
444
- export function reconcileConfigWarningMemos(generation: number): number {
445
- if (generation <= lastWarningReconciledGeneration) return 0;
446
- const removed = warnedConfigFallbacks.size + warnedInheritedFastWireConflicts.size;
447
- warnedConfigFallbacks.clear();
448
- warnedInheritedFastWireConflicts.clear();
449
- lastWarningReconciledGeneration = generation;
450
- return removed;
451
- }
452
-
453
- /**
454
- * Bounds for the opt-in same-target 429 wait-and-retry policy. Single source of truth
455
- * shared by the config schema, the load-time sanitizer, and the management write
456
- * boundary. Strict, so an unknown key is rejected at every validation boundary instead
457
- * of being silently ignored (the load-time sanitizer still degrades unknown keys with a
458
- * warning before schema validation, so hand-edited configs keep loading).
459
- */
460
- const retryOn429PolicySchema = z.object({
461
- enabled: z.boolean().optional(),
462
- attempts: z.number().int().min(1).max(20).optional(),
463
- intervalMs: z.number().int().min(100).max(600_000).optional(),
464
- // The effective cap for a single wait is MAX_COOLDOWN_MS (10 min) in key-failover.ts;
465
- // larger configured values would be dead config.
466
- maxIntervalMs: z.number().int().min(100).max(600_000).optional(),
467
- respectRetryAfter: z.boolean().optional(),
468
- }).strict();
469
-
470
- /**
471
- * `transientRetryOn5xx` accepts only these keys. `attempts` is a TOTAL send budget shared by
472
- * both retry layers, so the ceiling is deliberately lower than `retryOn429`'s: 10 total sends
473
- * against an already-failing provider is already generous.
474
- */
475
- const transientRetryOn5xxPolicySchema = z.object({
476
- enabled: z.boolean().optional(),
477
- attempts: z.number().int().min(1).max(10).optional(),
478
- }).strict();
479
-
480
- const requestPacingRuleSchema = z.object({
481
- // Keep the RPM-derived timer within the same one-hour bound as minIntervalMs.
482
- requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(),
483
- minIntervalMs: z.number().int().min(1).max(3_600_000).optional(),
484
- }).strict().refine(value => value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined, {
485
- message: "request pacing rules need requestsPerMinute or minIntervalMs",
486
- });
487
-
488
- const requestPacingSchema = z.object({
489
- enabled: z.boolean(),
490
- requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(),
491
- minIntervalMs: z.number().int().min(1).max(3_600_000).optional(),
492
- models: z.record(z.string().trim().min(1), requestPacingRuleSchema).optional(),
493
- }).strict().refine(value => value.enabled === false
494
- || value.requestsPerMinute !== undefined
495
- || value.minIntervalMs !== undefined
496
- || (value.models !== undefined && Object.keys(value.models).length > 0), {
497
- message: "enabled request pacing needs a provider rule or model override",
498
- });
499
-
500
- export function requestPacingConfigError(value: unknown): string | null {
501
- if (value === undefined) return null;
502
- const parsed = requestPacingSchema.safeParse(value);
503
- if (parsed.success) return null;
504
- return "requestPacing must contain enabled and a valid requestsPerMinute/minIntervalMs provider rule or model overrides";
505
- }
506
-
507
- /**
508
- * Bounds for the opt-in passthrough web-search bridge (`providers.<name>.webSearchBridge`,
509
- * #3761). Strict for the same reason `retryOn429` is: a misspelled key here would silently
510
- * leave the bridge disarmed while the operator believes they enabled it.
511
- *
512
- * `endpoint` names the destination that receives this provider's API key, so it gets the same
513
- * literal destination assessment `baseUrl` gets (#4519) — see `providerWebSearchBridgeConfigError`
514
- * below. This schema itself still only shape-checks: it is `.catch(undefined)` at the provider
515
- * row, and a hand-edited config file never reaches the error function at all. The authorization
516
- * boundary is therefore `resolveOllamaWebSearchEndpoint`, which runs the same assessment and is
517
- * the only reader of this field in the tree; config validation is where an operator is told why,
518
- * not what makes the value safe.
519
- */
520
- const providerWebSearchBridgeSchema = z.object({
521
- enabled: z.boolean().optional(),
522
- backend: z.enum(PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS).optional(),
523
- maxSearches: z.number().int().min(1).max(10).optional(),
524
- timeoutMs: z.number().int().min(1_000).max(600_000).optional(),
525
- endpoint: z.string().min(1).optional(),
526
- }).strict();
527
-
528
- export function providerWebSearchBridgeConfigError(
529
- value: unknown,
530
- providerName: string,
531
- provider: Pick<OcxProviderConfig, "allowPrivateNetwork">,
532
- ): string | null {
533
- if (value === undefined) return null;
534
- if (!value || typeof value !== "object" || Array.isArray(value)) {
535
- return "webSearchBridge must be a plain object";
536
- }
537
- const parsed = providerWebSearchBridgeSchema.safeParse(value);
538
- if (!parsed.success) {
539
- return "webSearchBridge accepts only enabled (boolean), backend "
540
- + `(${PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS.join("|")}), maxSearches (1..10), `
541
- + "timeoutMs (1000..600000), and endpoint (absolute http(s) URL)";
542
- }
543
- const endpoint = parsed.data.endpoint;
544
- if (endpoint !== undefined) {
545
- let url: URL;
546
- try {
547
- url = new URL(endpoint);
548
- } catch {
549
- return "webSearchBridge.endpoint must be an absolute http(s) URL";
550
- }
551
- if (url.protocol !== "https:" && url.protocol !== "http:") {
552
- return "webSearchBridge.endpoint must be an absolute http(s) URL";
553
- }
554
- // Same classifier baseUrl uses, so a metadata address is refused outright and loopback or
555
- // private space needs the provider's allowPrivateNetwork opt-in (or a registry entry that is
556
- // local by definition, which is what keeps a self-hosted Ollama working). Literal-only and
557
- // synchronous, exactly as at the baseUrl boundary: no DNS is resolved here.
558
- const destinationError = providerDestinationConfigError(providerName, {
559
- baseUrl: endpoint,
560
- allowPrivateNetwork: provider.allowPrivateNetwork,
561
- });
562
- if (destinationError) {
563
- return destinationError.replace(/^baseUrl/, "webSearchBridge.endpoint");
564
- }
565
- }
566
- return null;
567
- }
568
-
569
- const fastWireSchema = z.object({
570
- kind: z.string(),
571
- canonicalToWire: z.record(z.string().trim(), z.string().trim()),
572
- foreignCallerTiers: z.string(),
573
- betas: z.array(z.string().trim()).optional(),
574
- }).strict().superRefine((fastWire, ctx) => {
575
- const error = fastWireDeclarationError({ fastWire });
576
- if (error) ctx.addIssue({ code: "custom", message: error });
577
- }).transform(fastWire => fastWire as FastWire);
578
-
579
- const modelDisplayNamesSchema = z.unknown().superRefine((value, ctx) => {
580
- const error = modelDisplayNamesConfigError(value);
581
- if (error) ctx.addIssue({ code: "custom", message: error });
582
- }).transform(value => {
583
- const labels = Object.create(null) as Record<string, string>;
584
- for (const [modelId, displayName] of Object.entries(value as Record<string, string>)) {
585
- labels[modelId] = displayName;
586
- }
587
- return labels;
588
- });
589
-
590
- const pinnedReasoningEffortSchema = z.unknown().superRefine((value, ctx) => {
591
- const error = pinnedReasoningEffortConfigError(value);
592
- if (error) ctx.addIssue({ code: "custom", message: error });
593
- }).transform(value => value as string);
594
-
595
- const modelPinnedEffortsSchema = z.unknown().superRefine((value, ctx) => {
596
- const error = modelPinnedEffortsConfigError(value);
597
- if (error) ctx.addIssue({ code: "custom", message: error });
598
- }).transform(value => Object.fromEntries(
599
- Object.entries(value as Record<string, string>).map(([key, effort]) => [key.trim(), effort]),
600
- ));
601
-
602
- const autoReviewModelSchema = z.unknown().superRefine((value, ctx) => {
603
- const error = autoReviewModelTargetConfigError(value, "autoReviewModel", true);
604
- if (error) ctx.addIssue({ code: "custom", message: error });
605
- }).transform(value => {
606
- if (typeof value !== "string") return undefined;
607
- const trimmed = value.trim();
608
- return trimmed ? trimmed : undefined;
609
- });
610
-
611
- const autoReviewModelOverridesSchema = z.unknown().superRefine((value, ctx) => {
612
- const error = autoReviewModelOverridesConfigError(value, "autoReviewModelOverrides", true);
613
- if (error) ctx.addIssue({ code: "custom", message: error });
614
- }).transform(value => normalizeAutoReviewModelOverrides(value));
615
-
616
- const modelCapabilitiesSchema = z.unknown().superRefine((value, ctx) => {
617
- const error = modelCapabilitiesConfigError(value);
618
- if (error) ctx.addIssue({ code: "custom", message: error });
619
- }).transform(value => mergeModelCapabilities(undefined, value));
620
-
621
- /**
622
- * Zod schema for one provider entry: known fields are validated strictly while unknown
623
- * fields pass through (preserved for runtime extensions).
624
- */
625
- const providerConfigSchema = z.object({
626
- modelCapabilities: modelCapabilitiesSchema.optional(),
627
- pinnedReasoningEffort: pinnedReasoningEffortSchema.optional(),
628
- modelPinnedReasoningEfforts: modelPinnedEffortsSchema.optional(),
629
- // Validated rather than left to passthrough: an unrecognized strategy would otherwise
630
- // load silently and then be ignored at selection time, which reads as a broken feature
631
- // rather than a rejected setting.
632
- apiKeyPoolStrategy: z.enum(["round-robin", "fill-first", "quota"]).optional(),
633
- autoReviewModel: autoReviewModelSchema.optional(),
634
- autoReviewModelOverrides: autoReviewModelOverridesSchema.optional(),
635
- adapter: z.string().min(1),
636
- baseUrl: z.string().min(1),
637
- alias: z.string().optional(),
638
- modelAliases: z.record(z.string(), z.string()).optional(),
639
- modelDisplayNames: modelDisplayNamesSchema.optional(),
640
- defaultAliases: z.boolean().optional(),
641
- initialModelSelection: z.object({
642
- version: z.literal(1),
643
- registrationId: z.uuid(),
644
- status: z.enum(["pending", "ready", "all-off"]),
645
- modelCount: z.number().int().nonnegative().optional(),
646
- }).optional().catch(undefined),
647
- requestPacing: requestPacingSchema.optional().catch(undefined),
648
- mcpMaxTools: z.number().int().positive().optional(),
649
- mcpMaxSchemaBytes: z.number().int().positive().optional(),
650
- mcpMaxResultBytes: z.number().int().positive().optional(),
651
- apiKeyTransport: z.enum(["x-api-key", "bearer"]).optional(),
652
- responsesPath: z.string().min(1).optional(),
653
- chatCompletionsPath: z.string().min(1).optional(),
654
- statelessResponses: z.boolean().optional(),
655
- requiresAdjacentResponsesToolResults: z.boolean().optional(),
656
- annotateEmptyToolOutputs: z.boolean().optional(),
657
- fastWire: fastWireSchema.nullable().optional(),
658
- supportsServiceTier: z.boolean().optional(),
659
- modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(),
660
- preserveResponsesReasoningContent: z.boolean().optional(),
661
- decodesNativeCompactionBlobs: z.boolean().optional(),
662
- allowEncryptedV2AgentTasks: z.boolean().optional(),
663
- allowPrivateNetwork: z.boolean().optional(),
664
- // The management API accepts `null` as "clear this", so a config written before the POST
665
- // canonicalization below can hold one on disk. Rejecting it here would send the operator
666
- // through invalid-config recovery for a value the API told them was fine.
667
- upstreamHttpVersion: z.enum(UPSTREAM_HTTP_VERSION_VALUES)
668
- .nullish()
669
- .transform(value => value ?? undefined),
670
- // Opt-in upstream Responses WebSocket for OpenAI-compatible providers (e.g.
671
- // aggregators whose WebSocket ingress is measurably faster than SSE). The
672
- // canonical ChatGPT backend WS selection is independent of this flag.
673
- upstreamWebsocket: z.boolean().optional(),
674
- directGeminiWireRenames: z.boolean().optional(),
675
- noStructuredOutputModels: z.array(z.string().min(1))
676
- .transform(normalizeNonBlankStringArray)
677
- .optional(),
678
- noJsonSchemaModels: z.array(z.string().min(1))
679
- .transform(normalizeNonBlankStringArray)
680
- .optional(),
681
- retainModels: z.array(z.string().min(1))
682
- .transform(normalizeNonBlankStringArray)
683
- .optional(),
684
- omitReasoningEffortWithToolsModels: z.array(z.string().min(1))
685
- .transform(normalizeNonBlankStringArray)
686
- .optional(),
687
- retryOn429: retryOn429PolicySchema.optional(),
688
- transientRetryOn5xx: transientRetryOn5xxPolicySchema.optional(),
689
- codexAccountMode: z.enum(["pool", "direct"]).optional(),
690
- // Validated rather than passed through: this schema ends in `.passthrough()`, so an
691
- // undeclared key survives verbatim. A misspelled `codexToolMode` therefore used to be
692
- // accepted, persisted, and then silently resolved to the `code_mode_only` default — the
693
- // operator asked for shell mode, got code mode, and was told nothing (#2106).
694
- codexToolMode: z.enum(["code_mode_only", "shell"]).optional(),
695
- responsesItemIdRepair: z.object({
696
- message: z.array(z.string().min(1)).optional(),
697
- reasoning: z.array(z.string().min(1)).optional(),
698
- repairMissingTerminalIds: z.boolean().optional(),
699
- repairInvalidIds: z.boolean().optional(),
700
- }).strict().optional(),
701
- responsesSnapshotRepair: z.boolean().optional(),
702
- // Invalid blocks degrade to "absent" rather than failing the whole config load: an unusable
703
- // bridge block must never send an operator through invalid-config recovery for an opt-in
704
- // feature that is off by default. The management write boundary still rejects it loudly.
705
- webSearchBridge: providerWebSearchBridgeSchema.optional().catch(undefined),
706
- xaiResponsesXSearch: z.boolean().optional(),
707
- xaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined),
708
- zaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined),
709
- }).passthrough();
710
-
711
56
  export { isValidProviderName, hasOwnProvider } from "./config/provider-name";
712
57
  export {
713
58
  apiKeyTransportConfigError,
714
59
  booleanRecordConfigError,
715
60
  modelAdapterRecordConfigError,
61
+ modelDisplayNamesConfigError,
716
62
  autoReviewModelOverridesConfigError,
717
63
  autoReviewModelTargetConfigError,
718
- modelDisplayNamesConfigError,
719
64
  nonBlankStringArrayConfigError,
720
65
  normalizeNonBlankStringArray,
721
66
  normalizeAutoReviewModelOverrides,
@@ -726,2555 +71,153 @@ export {
726
71
  reasoningSummaryDeliveryRecordConfigError,
727
72
  upstreamHttpVersionConfigError,
728
73
  } from "./config/provider-validation";
74
+ export { reconcileConfigWarningMemos } from "./config/warn-memo";
75
+ export {
76
+ OpenAiTierBackupCleanupError,
77
+ OpenAiTierBackupRollbackError,
78
+ OpenAiTierBackupCollisionError,
79
+ OpenAiTierRollbackPreserveError,
80
+ OpenAiTierBackupSecretResidualError,
81
+ classifyOpenAiTierBackup,
82
+ backupConfigBeforeOpenAiTierMigration,
83
+ preserveOpenAiTierRollbackSnapshot,
84
+ type OpenAiTierBackupIO,
85
+ type OpenAiTierRollbackPreserveIO,
86
+ } from "./config/openai-tier-backup";
87
+ export {
88
+ websocketsEnabled,
89
+ ultraFastTierEnabled,
90
+ CATALOG_AUTO_REFRESH_DEFAULT_INTERVAL_MS,
91
+ CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS,
92
+ isCatalogAutoRefreshEnabled,
93
+ resolveCatalogAutoRefreshIntervalMs,
94
+ } from "./config/feature-flags";
95
+ export {
96
+ codexAutoStartEnabled,
97
+ CODEX_SHIM_AUTO_RESTORE_ENV,
98
+ codexShimAutoRestoreEnabled,
99
+ multiAgentGuidanceEnabled,
100
+ runtimeRole,
101
+ getDefaultConfig,
102
+ resolveEnvValue,
103
+ applyProxyEnv,
104
+ applyProxyEnvWith,
105
+ } from "./config/proxy-env";
106
+ export {
107
+ requestPacingConfigError,
108
+ providerWebSearchBridgeConfigError,
109
+ providerModelCostsConfigError,
110
+ sanitizeModelCostsForDisplay,
111
+ modelPreferHostedToolsConfigError,
112
+ } from "./config/schema/leaf-validators";
113
+ export { hardenExistingSecret, retryOn429PolicyConfigError } from "./config/load-degrade";
114
+ export { backupInvalidConfig } from "./config/salvage";
115
+ export type { ConfigDiagnostics, ConfigAdmissionSnapshot } from "./config/diagnostics";
116
+ export {
117
+ subagentDefaultSyncEffective,
118
+ loopbackCompanionBindError,
119
+ validateConfigCandidate,
120
+ readConfigDiagnostics,
121
+ observeInitialConfigState,
122
+ readConfigAdmissionSnapshot,
123
+ } from "./config/diagnostics";
124
+ export {
125
+ ConfigMutationLockError,
126
+ NestedConfigMutationError,
127
+ prepareConfigMutationDatabasePathForWrite,
128
+ withConfigMutationLockSync,
129
+ readConfigGeneration,
130
+ observeConfigGeneration,
131
+ readConfigGenerationInCurrentMutationTransaction,
132
+ bumpConfigGeneration,
133
+ withExpectedConfigGenerationSync,
134
+ } from "./config/mutation-lock";
135
+ export {
136
+ armClaudeCodeBaseline,
137
+ adoptPersistedProviderIntoLiveConfig,
138
+ claudeCodeBaselineArmed,
139
+ reconcileLiveConfigFromDisk,
140
+ saveConfigPreservingClaudeCode,
141
+ } from "./config/live-reconcile";
142
+
143
+ // create-only path — never persist-unlocked / atomicWriteFile
144
+ import { InitialConfigPublicationError, publishInitialConfigNoReplace, type InitialConfigPublicationIO } from "./config/initialize";
145
+ import { observeInitialConfigState } from "./config/diagnostics";
146
+ import {
147
+ configDiagnosticsFromRaw,
148
+ mergeConfigDefaults,
149
+ readConfigFileSnapshot,
150
+ validateConfigCandidate,
151
+ type ConfigFileSnapshot,
152
+ } from "./config/diagnostics";
153
+
154
+ // replace path — never publishInitialConfigNoReplace
155
+ import { persistConfigUnlocked, readRawConfigJson } from "./config/persist-unlocked";
156
+
157
+ import { withConfigMutationLockSync, bumpGenerationForCooperatingConfigWrite } from "./config/mutation-lock";
158
+ import { getDefaultConfig } from "./config/proxy-env";
159
+ import { configSchema } from "./config/schema/config-schema";
160
+ import {
161
+ hardenExistingSecret,
162
+ normalizeApiKeyIds,
163
+ normalizeClaudeSubagentEffort,
164
+ normalizeNativeSubagentSync,
165
+ sanitizeAliasesForLoad,
166
+ sanitizeReasoningPinsForLoad,
167
+ sanitizeModelDisplayNamesForLoad,
168
+ sanitizeAutoReviewForLoad,
169
+ sanitizeRetryOn429ForLoad,
170
+ sanitizeModelCostsForLoad,
171
+ sanitizeCapabilityDeclarationsForLoad,
172
+ warnInheritedFastWireConflicts,
173
+ warnDegradedStreamMode,
174
+ warnDegradedHostname,
175
+ warnDegradedListeners,
176
+ warnDegradedApiKeys,
177
+ warnDegradedCodexAccountPriorities,
178
+ warnDegradedCodexQuotaAutoRefresh,
179
+ warnDegradedClaudeSubagentEffort,
180
+ warnDegradedNativeSubagentConfig,
181
+ warnDegradedCodexAccountPicker,
182
+ warnDegradedUpstreamHostCircuitThreshold,
183
+ warnDegradedPlaintextV2AgentMessages,
184
+ warnDegradedAgentTaskRecovery,
185
+ warnDegradedRuntimeRole,
186
+ warnDegradedOptionalRemoteBlocks,
187
+ warnDegradedQuotaResetNotify,
188
+ warnDegradedCatalogAutoRefresh,
189
+ warnDegradedCodexPool,
190
+ warnDegradedCredentialGroups,
191
+ withRefreshedCostOverlays,
192
+ } from "./config/load-degrade";
193
+ import {
194
+ salvageConfigCandidate,
195
+ warnConfigRepaired,
196
+ warnDroppedConfigSections,
197
+ warnAndBackupInvalidConfig,
198
+ } from "./config/salvage";
729
199
 
730
200
  /**
731
- * Shared shape check for the two relative send-path overrides. `field` names the
732
- * offending key so the message stays specific to what the user actually wrote.
733
- */
734
- function providerRelativeSendPathConfigError(field: string, value: string | undefined): string | null {
735
- if (value === undefined) return null;
736
- if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) || value.includes("://")) {
737
- return `${field} must be a relative path without a URL scheme`;
738
- }
739
- if (!value.startsWith("/")) return `${field} must start with /`;
740
- if (value.includes("?") || value.includes("#")) {
741
- return `${field} must not include query strings or fragments`;
742
- }
743
- return null;
744
- }
745
-
746
- /**
747
- * Validate `providers.<name>.modelCosts`: a plain object keyed by exact model
748
- * id, each value a 4-tuple of non-negative finite USD-per-1M-token rates.
749
- * Returns null when valid/absent, else a human-readable error.
750
- */
751
- export function providerModelCostsConfigError(value: unknown, field = "modelCosts"): string | null {
752
- if (value === undefined) return null;
753
- if (!value || typeof value !== "object" || Array.isArray(value)) {
754
- return `${field} must be a plain object keyed by model id`;
755
- }
756
- for (const [modelId, entry] of Object.entries(value)) {
757
- if (!modelId.trim()) return `${field} keys must be nonblank model ids`;
758
- // Redact secret-shaped model ids and JSON-escape control characters so a
759
- // malformed write cannot echo a pasted key/secret back through the
760
- // management API response.
761
- const safeModelId = JSON.stringify(redactSecretString(modelId));
762
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
763
- return `${field}.${safeModelId} must be an object with input, output, cacheRead, and cacheWrite (USD per 1M tokens)`;
764
- }
765
- const rates = entry as Record<string, unknown>;
766
- for (const key of COST4_RATE_KEYS) {
767
- const rate = rates[key];
768
- if (!isValidCost4Rate(rate)) {
769
- return `${field}.${safeModelId}.${key} must be a non-negative finite number at most ${MAX_COST4_RATE} (USD per 1M tokens)`;
770
- }
771
- }
772
- // Reject unknown fields: a misplaced apiKey/apiKeyPool under a cost row
773
- // would otherwise be persisted and echoed verbatim by display paths that
774
- // mask only top-level provider secrets.
775
- const extraKeys = Object.keys(rates)
776
- .filter((key) => !(COST4_RATE_KEYS as readonly string[]).includes(key));
777
- if (extraKeys.length > 0) {
778
- return `${field}.${safeModelId} has unexpected fields ${JSON.stringify(extraKeys.map(redactSecretString).join(", "))} — only input, output, cacheRead, and cacheWrite are allowed (USD per 1M tokens)`;
779
- }
780
- }
781
- return null;
782
- }
783
-
784
- /**
785
- * Serialize `providers.<name>.modelCosts` for display: copy ONLY the four
786
- * numeric rate fields per model and DROP secret-shaped model ids, so a pasted
787
- * API key in a key position cannot be echoed back by CLI/DTO display paths.
788
- * The result uses a null prototype so "__proto__" remains an own row.
201
+ * Load and validate config.json into an OcxConfig. Missing files reset to
202
+ * defaults and clear stale overlays. Broken existing files also fall back to
203
+ * default routing (after backup), but keep the last-good cost-overlay registry
204
+ * until a valid config or a genuinely missing file is observed. A partially-
205
+ * invalid config is merged with defaults so providers and pool accounts survive.
789
206
  */
790
- export function sanitizeModelCostsForDisplay(costs: unknown): Record<string, ProviderCostOverlay> | undefined {
791
- if (!costs || typeof costs !== "object" || Array.isArray(costs)) return undefined;
792
- const out = Object.create(null) as Record<string, ProviderCostOverlay>;
793
- for (const [modelId, entry] of Object.entries(costs)) {
794
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
795
- const rates = entry as Record<string, unknown>;
796
- const input = rates.input;
797
- const output = rates.output;
798
- const cacheRead = rates.cacheRead;
799
- const cacheWrite = rates.cacheWrite;
800
- if (
801
- isValidCost4Rate(input)
802
- && isValidCost4Rate(output)
803
- && isValidCost4Rate(cacheRead)
804
- && isValidCost4Rate(cacheWrite)
805
- ) {
806
- // Secret-shaped ids are DROPPED rather than mapped to "[REDACTED]" so
807
- // distinct rows cannot collapse into one placeholder key.
808
- if (redactSecretString(modelId) !== modelId) continue;
809
- out[modelId] = { input, output, cacheRead, cacheWrite };
810
- }
811
- }
812
- return Object.keys(out).length > 0 ? out : undefined;
813
- }
814
-
815
- const SUPPORTED_PREFERRED_HOSTED_TOOLS = new Set(["image_generation"]);
816
-
817
- export function modelPreferHostedToolsConfigError(
818
- value: unknown,
819
- field: string,
820
- providerName: string,
821
- provider: { adapter?: unknown; authMode?: unknown; modelAdapters?: unknown; baseUrl?: unknown },
822
- ): string | null {
823
- if (value === undefined) return null;
824
- if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
825
- const prototype = Object.getPrototypeOf(value);
826
- if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
827
- const entries = Object.entries(value);
828
- const registry = getProviderRegistryEntry(providerName);
829
- // Effective transport: a `preserveCustomDestination` registry row reused under a
830
- // different endpoint keeps its own adapter AND its own auth at runtime, because
831
- // `routedProviderConfig()` honors `providerMatchesRegistryTransport()`. Both the
832
- // wire check below and the forward-auth check here have to start from the same
833
- // decision, or validation accepts a preference the adapter never applies —
834
- // `preferConfiguredHostedTools()` runs only on the non-forward branch.
835
- const registryTransportMatches = typeof provider.baseUrl === "string"
836
- && providerMatchesRegistryTransport(providerName, {
837
- baseUrl: provider.baseUrl,
838
- adapter: provider.adapter as OcxProviderConfig["adapter"],
839
- ...(typeof provider.authMode === "string" ? { authMode: provider.authMode as OcxProviderConfig["authMode"] } : {}),
840
- });
841
- const effectiveForwardAuth = registryTransportMatches
842
- ? registry?.authKind === "forward"
843
- : provider.authMode === "forward";
844
- if (entries.length > 0 && effectiveForwardAuth) {
845
- return `${field} is not supported on forward-auth Responses providers`;
846
- }
847
- const requestedWireFor = (modelId: string): unknown => provider.modelAdapters
848
- && typeof provider.modelAdapters === "object"
849
- && !Array.isArray(provider.modelAdapters)
850
- ? (provider.modelAdapters as Record<string, unknown>)[modelId]
851
- : undefined;
852
- const resolveEffectiveWire = (modelId: string, currentWire: unknown): unknown => {
853
- const pinned = pinnedWireAdapter(providerName, modelId);
854
- if (pinned) return pinned;
855
- const requestedWire = requestedWireFor(modelId);
856
- if (typeof requestedWire === "string" && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(requestedWire)) {
857
- return requestedWire;
858
- }
859
- // No explicit override: fall back to the registry's per-model wire default before
860
- // the provider-wide adapter, because that is the order `resolveModelAdapter()`
861
- // uses at request time (src/server/adapter-resolve.ts:38-48). Skipping it rejected
862
- // preferences the runtime would have honored — DeepSeek routes `deepseek-v4-flash`
863
- // over native Responses for a Responses inbound while the provider-wide wire stays
864
- // openai-chat. Hosted-tool preferences only apply to Responses traffic, so the
865
- // inbound to ask about is "responses".
866
- const registryDefault = typeof currentWire === "string" && typeof provider.baseUrl === "string"
867
- ? providerModelWireDefault(
868
- providerName,
869
- {
870
- baseUrl: provider.baseUrl,
871
- adapter: currentWire,
872
- ...(typeof provider.authMode === "string" ? { authMode: provider.authMode as OcxProviderConfig["authMode"] } : {}),
873
- },
874
- modelId,
875
- MODEL_ADAPTER_OVERRIDE_ALLOWED,
876
- "responses",
877
- )
878
- : undefined;
879
- return registryDefault ?? currentWire;
880
- };
881
- for (const [key, entry] of entries) {
882
- if (!key.trim()) return `${field} keys must be nonblank model ids`;
883
- if (!Array.isArray(entry)) return `${field}.${key} must be an array`;
884
- if (entry.length === 0) return `${field}.${key} must include image_generation`;
885
- for (const tool of entry) {
886
- if (typeof tool !== "string" || !SUPPORTED_PREFERRED_HOSTED_TOOLS.has(tool)) {
887
- return `${field}.${key} supports only image_generation`;
888
- }
889
- if (isHostedToolUnsupportedForModel(key, tool)) {
890
- return `${field}.${key} cannot prefer ${tool}: the model does not support it`;
891
- }
892
- }
893
- // Same `registryTransportMatches` decision the forward-auth check above uses:
894
- // start from the registry adapter only when this config still points at the
895
- // registry's documented transport.
896
- const baseWire = registryTransportMatches ? registry?.adapter ?? provider.adapter : provider.adapter;
897
- let effectiveWire = resolveEffectiveWire(key, baseWire);
898
- const virtualWireModel = resolveOpenAiVirtualModel(providerName, key)?.wireModelId;
899
- if (virtualWireModel && virtualWireModel !== key) {
900
- effectiveWire = resolveEffectiveWire(virtualWireModel, effectiveWire);
901
- }
902
- if (effectiveWire !== "openai-responses") {
903
- return `${field}.${key} requires the openai-responses wire`;
904
- }
905
- }
906
- return null;
907
- }
908
-
909
- const CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR =
910
- "codexAccountNamespaces must be a plain object mapping account selectors to Codex account ids";
911
- const CODEX_ACCOUNT_NAMESPACE_KEY_ERROR =
912
- "account selectors must use 1-64 letters, numbers, dots, underscores, or hyphens and cannot be reserved JavaScript object keys";
913
- const CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR =
914
- "account selector targets must be @main or valid Codex pool-account ids";
915
- const CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR =
916
- "account selectors must not collide with configured Codex pool-account ids or account selector targets";
917
-
918
- function configuredCodexPoolAccountIds(value: unknown): Set<string> {
919
- const accountIds = new Set<string>();
920
- if (!Array.isArray(value)) return accountIds;
921
- for (const account of value) {
922
- if (!account || typeof account !== "object" || Array.isArray(account)) continue;
923
- const { id, isMain } = account as { id?: unknown; isMain?: unknown };
924
- if (typeof id === "string" && isMain !== true) accountIds.add(id);
925
- }
926
- return accountIds;
927
- }
928
-
929
- const codexAccountNamespacesSchema = z.custom<Record<string, unknown>>(
930
- (value): value is Record<string, unknown> => !!value
931
- && typeof value === "object"
932
- && !Array.isArray(value)
933
- && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null),
934
- { error: CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR },
935
- ).superRefine((accountNamespaces, ctx) => {
936
- // Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys.
937
- for (const [namespace, accountId] of Object.entries(accountNamespaces)) {
938
- if (!isValidProviderName(namespace)) {
939
- ctx.addIssue({
940
- code: "custom",
941
- path: [namespace],
942
- message: CODEX_ACCOUNT_NAMESPACE_KEY_ERROR,
943
- });
944
- }
945
- if (!isValidCodexAccountNamespaceTarget(accountId)) {
946
- ctx.addIssue({
947
- code: "custom",
948
- path: [namespace],
949
- message: CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR,
950
- });
951
- }
952
- }
953
- }).pipe(z.record(z.string(), z.string()));
954
-
955
- const CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR =
956
- "codexAccountPriorities must be a plain object mapping Codex account ids to selection-order integers";
957
- const CODEX_ACCOUNT_PRIORITY_KEY_ERROR =
958
- "selection-order keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys";
959
- const CODEX_ACCOUNT_PRIORITY_VALUE_ERROR =
960
- "selection order must be an integer between -100 and 100";
961
-
962
- const CODEX_ACCOUNT_PIN_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/;
963
-
964
- const codexAccountPrioritiesSchema = z.custom<Record<string, unknown>>(
965
- (value): value is Record<string, unknown> => !!value
966
- && typeof value === "object"
967
- && !Array.isArray(value)
968
- && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null),
969
- { error: CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR },
970
- ).superRefine((priorities, ctx) => {
971
- // Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys.
972
- for (const [accountId, priority] of Object.entries(priorities)) {
973
- if (!isCodexAccountPriorityKey(accountId)) {
974
- ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_KEY_ERROR });
975
- }
976
- if (parseAccountPriority(priority) === null) {
977
- ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_VALUE_ERROR });
978
- }
979
- }
980
- }).pipe(z.record(z.string(), z.number().int()));
981
-
982
- const codexQuotaAutoRefreshEntrySchema = z.object({
983
- fiveHour: z.boolean().optional(),
984
- weekly: z.boolean().optional(),
985
- lastFiveHourResetAt: z.number().finite().nonnegative().optional(),
986
- lastWeeklyResetAt: z.number().finite().nonnegative().optional(),
987
- nextFiveHourResetAt: z.number().finite().nonnegative().optional(),
988
- nextWeeklyResetAt: z.number().finite().nonnegative().optional(),
989
- }).strict();
990
- const CODEX_QUOTA_AUTO_REFRESH_KEY_ERROR =
991
- "quota auto-refresh keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys";
992
-
993
- const codexQuotaAutoRefreshSchema = z.custom<Record<string, unknown>>(
994
- (value): value is Record<string, unknown> => !!value
995
- && typeof value === "object"
996
- && !Array.isArray(value)
997
- && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null),
998
- { error: "codexQuotaAutoRefresh must be a plain object" },
999
- ).superRefine((settings, ctx) => {
1000
- // Inspect own entries before z.record parses them; Zod omits __proto__ record keys.
1001
- for (const [accountId, setting] of Object.entries(settings)) {
1002
- if (!isCodexAccountPriorityKey(accountId)) {
1003
- ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_QUOTA_AUTO_REFRESH_KEY_ERROR });
1004
- }
1005
- const parsed = codexQuotaAutoRefreshEntrySchema.safeParse(setting);
1006
- if (!parsed.success) {
1007
- ctx.addIssue({ code: "custom", path: [accountId], message: "invalid quota auto-refresh setting" });
1008
- }
207
+ export function loadConfig(): OcxConfig {
208
+ const dir = getConfigDir();
209
+ const configPath = getConfigPath();
210
+ hardenConfigDir();
211
+ hardenExistingSecret(configPath);
212
+ hardenExistingSecret(join(dir, "auth.json"));
213
+ if (!existsSync(configPath)) {
214
+ return withRefreshedCostOverlays(getDefaultConfig());
1009
215
  }
1010
- }).pipe(z.record(z.string(), codexQuotaAutoRefreshEntrySchema));
1011
-
1012
- /**
1013
- * Deliberately permissive. A user's config is not ours to invalidate: a strict
1014
- * entry fails the whole parse, and loadConfig's fallback then backs the file up
1015
- * and returns defaults — losing providers and pool accounts because one key name
1016
- * was too long. Length and charset rules live at the POST/PATCH boundary, where
1017
- * rejecting produces a 400 instead. `.passthrough()` keeps unknown per-key
1018
- * properties across a load -> mutate -> save round trip.
1019
- *
1020
- * Only `key` is load-bearing: admission compares that string and nothing else
1021
- * (src/server/auth-cors.ts isDataPlaneAdmissionSecret). So the secret is the one
1022
- * field that must be a usable string, and every piece of metadata around it
1023
- * degrades instead of taking the credential down with it. Dropping a working key
1024
- * because its `name` was hand-edited to a number would be a silent revocation —
1025
- * and on a remote bind, potentially a server that refuses to start.
1026
- *
1027
- * "Usable" matches admission exactly. The presented token is trimmed before the
1028
- * comparison but the stored value is not, so a key with surrounding whitespace
1029
- * can never match either form of itself. Keeping one would be worse than dropping
1030
- * it: `system-env.ts` and `cli/claude.ts` hand `apiKeys[0].key` to launched
1031
- * clients, so a junk first entry would mask a valid later one.
1032
- */
1033
- const pendingApiKeyRotationSchema = z.object({
1034
- id: z.string().trim().min(1).max(256),
1035
- key: z.string().refine(isUsableApiKeySecret),
1036
- createdAt: z.string().datetime({ offset: true }),
1037
- expiresAt: z.string().datetime({ offset: true }),
1038
- }).strict();
1039
-
1040
- const apiKeyEntrySchema = z.object({
1041
- key: z.string().refine(isUsableApiKeySecret),
1042
- // Degrades to "" here; every schema consumer then runs `normalizeApiKeyIds`,
1043
- // which fills it deterministically so the id is stable across loads.
1044
- id: z.string().catch(""),
1045
- name: z.string().catch(""),
1046
- createdAt: z.string().catch(""),
1047
- // A damaged overlap record must never discard the still-authoritative key.
1048
- pendingRotation: pendingApiKeyRotationSchema.optional().catch(undefined),
1049
- }).passthrough();
1050
-
1051
- /**
1052
- * Durable per-client intent.
1053
- *
1054
- * `.passthrough()` is load-bearing: a binary that only knows `codex` must not
1055
- * erase a key a later version wrote during a field-scoped mutation. And each key
1056
- * degrades on its own — a hand edit of `{"codex": "false", "future": false}`
1057
- * drops `codex` to absent (which reads as ON) and keeps `future`, rather than
1058
- * invalidating the object or, worse, the whole config.
1059
- */
1060
- const clientIntegrationsSchema = z.object({
1061
- codex: z.boolean().optional().catch(undefined),
1062
- grok: z.boolean().optional().catch(undefined),
1063
- "claude-desktop": z.boolean().optional().catch(undefined),
1064
- }).passthrough();
1065
-
1066
- const asideProfileSyncSchema = z.object({
1067
- allProfiles: z.boolean().optional(),
1068
- profiles: z.record(
1069
- z.string().regex(/^(0|[1-9][0-9]*)$/).refine(value => Number.isSafeInteger(Number(value))),
1070
- z.boolean(),
1071
- ).optional(),
1072
- legacyProfileId: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable().optional(),
1073
- }).passthrough();
1074
-
1075
- const agentTaskRecoverySchema = z.object({
1076
- enabled: z.boolean().optional(),
1077
- model: z.string().trim().min(1).optional(),
1078
- timeoutMs: z.number().int().min(1_000).max(120_000).optional(),
1079
- cacheEntries: z.number().int().min(1).max(512).optional(),
1080
- }).strict();
1081
-
1082
- const runtimeRoleSchema = z.enum(["standalone", "hub", "client"]);
1083
-
1084
- function canonicalHttpOrigin(value: string): string | null {
1085
216
  try {
1086
- const parsed = new URL(value);
1087
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
1088
- if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return null;
1089
- return parsed.origin;
1090
- } catch {
1091
- return null;
1092
- }
1093
- }
1094
-
1095
- const managementIngressSchema = z.union([
1096
- z.object({ enabled: z.literal(false) }).strict(),
1097
- z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }).strict(),
1098
- ]);
1099
-
1100
- const hubConfigSchema = z.object({
1101
- managementPublicOrigin: z.string().transform((value, ctx) => {
1102
- const origin = canonicalHttpOrigin(value);
1103
- if (!origin) {
1104
- ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" });
1105
- return z.NEVER;
1106
- }
1107
- return origin;
1108
- }).optional(),
1109
- // Same canonical-origin rule as managementPublicOrigin, and deliberately NOT `.catch`ed:
1110
- // a mistyped data origin must be rejected at write time, because silently dropping it
1111
- // makes `ocx hub invite` print the `http://<hostname>:<port>` fallback that the operator
1112
- // set this field precisely to replace.
1113
- dataPublicOrigin: z.string().transform((value, ctx) => {
1114
- const origin = canonicalHttpOrigin(value);
1115
- if (!origin) {
1116
- ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" });
1117
- return z.NEVER;
1118
- }
1119
- return origin;
1120
- }).optional(),
1121
- // A malformed hand edit disables only the optional ingress. Live writes are rejected by
1122
- // managementIngressConfigError before this load-time degradation can hide the mistake.
1123
- managementIngress: managementIngressSchema.optional().catch(undefined),
1124
- }).strict();
1125
-
1126
- const tailscaleUserSchema = z.string().trim().min(1).superRefine((value, ctx) => {
1127
- if (new TextEncoder().encode(value).byteLength > 320) {
1128
- ctx.addIssue({ code: "custom", message: "must be at most 320 UTF-8 bytes" });
1129
- }
1130
- if (/[\x00-\x1f\x7f]/.test(value)) {
1131
- ctx.addIssue({ code: "custom", message: "must not contain ASCII control characters" });
1132
- }
1133
- });
1134
-
1135
- const remoteGuiConfigSchema = z.object({
1136
- allowedTailscaleUsers: z.array(tailscaleUserSchema).max(64).superRefine((users, ctx) => {
1137
- const seen = new Set<string>();
1138
- for (let index = 0; index < users.length; index++) {
1139
- const user = users[index]!;
1140
- if (seen.has(user)) {
1141
- ctx.addIssue({ code: "custom", path: [index], message: "must contain unique users after trimming" });
1142
- }
1143
- seen.add(user);
1144
- }
1145
- }).optional(),
1146
- // Retired (see OcxRemoteGuiConfig): accepted so an existing file still loads, ignored by
1147
- // the pairing path. Removing it from a strict schema would reject the whole config.
1148
- allowInsecureHttp: z.boolean().optional(),
1149
- }).strict();
1150
-
1151
- const connectedClientIdSchema = z.enum(["codex", "claude"]);
1152
- const clientTimestampSchema = z.string().datetime({ offset: true });
1153
- const clientOriginSchema = z.string().transform((value, ctx) => {
1154
- const origin = canonicalHttpOrigin(value);
1155
- if (!origin) {
1156
- ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" });
1157
- return z.NEVER;
1158
- }
1159
- return origin;
1160
- });
1161
- const clientConnectionSchema = z.object({
1162
- serverUrl: clientOriginSchema,
1163
- managementUrl: clientOriginSchema,
1164
- managementTransport: z.enum(["direct", "relay"]),
1165
- selectedClients: z.array(connectedClientIdSchema).min(1).max(2).superRefine((clients, ctx) => {
1166
- if (new Set(clients).size !== clients.length) {
1167
- ctx.addIssue({ code: "custom", message: "must contain unique client ids" });
1168
- }
1169
- }),
1170
- tokenEnv: z.literal("OPENCODEX_API_AUTH_TOKEN"),
1171
- apiKeyId: z.string().trim().min(1).max(256),
1172
- tokenFingerprint: z.string().regex(/^[a-f0-9]{64}$/),
1173
- protocolVersion: z.literal(1),
1174
- connectedAt: clientTimestampSchema,
1175
- catalogFingerprint: z.string().min(1).max(512).optional(),
1176
- // base64 of the pre-connect catalog, or "" for "there was none". Bounded above the
1177
- // catalog size cap so a legitimate snapshot round-trips.
1178
- priorCatalog: z.string().max(64 * 1024 * 1024).optional(),
1179
- catalogSyncedAt: clientTimestampSchema.optional(),
1180
- pendingOperation: z.object({
1181
- kind: z.literal("rotate"),
1182
- rotationId: z.string().trim().min(1).max(256),
1183
- newKeyIssuedAt: clientTimestampSchema,
1184
- oldKeyBackupPath: z.string().min(1),
1185
- }).strict().superRefine((operation, ctx) => {
1186
- const expected = join(getConfigDir(), "service-api-token.prev");
1187
- if (operation.oldKeyBackupPath !== expected) {
1188
- ctx.addIssue({ code: "custom", path: ["oldKeyBackupPath"], message: `must equal ${expected}` });
1189
- }
1190
- }).optional(),
1191
- }).strict();
1192
-
1193
- /**
1194
- * Codex pool selection policy section.
1195
- *
1196
- * `.strict()` like its neighbour: a typo in an optional feature section should surface as a
1197
- * rejected write rather than a silently ignored key that leaves the operator believing they
1198
- * excluded something.
1199
- */
1200
- const codexPoolSchema = z.object({
1201
- excludedPlans: z.array(z.string().trim().min(1)).optional(),
1202
- }).strict();
1203
-
1204
- /**
1205
- * Quota-reset notification section.
1206
- *
1207
- * `.strict()` like its neighbour: a typo in an optional feature section should surface as a
1208
- * rejected write rather than a silently ignored key that leaves the operator believing they
1209
- * enabled something.
1210
- *
1211
- * `pollSeconds` admits 0 (passive-only, no timer) and the resolver clamps anything between 1
1212
- * and the 60-second floor. Bounds live in the resolver rather than here so a hand-edited value
1213
- * degrades to a sane one instead of discarding the whole section.
1214
- */
1215
- const quotaResetNotifySchema = z.object({
1216
- enabled: z.boolean().optional(),
1217
- kinds: z.array(z.enum(["scheduled", "surprise"])).optional(),
1218
- pollSeconds: z.number().int().min(0).optional(),
1219
- // `z.string().url()` accepts any scheme. The payload carries account identity and the hook
1220
- // URL is frequently a bearer-equivalent secret, so an http: sink puts both in cleartext.
1221
- webhookUrl: z.string().url().refine(
1222
- value => { try { return new URL(value).protocol === "https:"; } catch { return false; } },
1223
- { message: "webhookUrl must use https" },
1224
- ).optional(),
1225
- allowPrivateNetwork: z.boolean().optional(),
1226
- timeoutMs: z.number().int().positive().optional(),
1227
- command: z.array(z.string()).optional(),
1228
- }).strict();
1229
-
1230
- /**
1231
- * Catalog auto-refresh section (issue #3630).
1232
- *
1233
- * `.strict()` like its neighbour: a typo in an optional feature section should surface as a
1234
- * rejected write rather than a silently ignored key that leaves the operator believing they
1235
- * enabled something.
1236
- *
1237
- * `intervalMinutes` admits 0 (configured but dormant, no timer) and the resolver clamps
1238
- * anything between 1 and the 15-minute floor. Bounds live in the resolver rather than here
1239
- * so a hand-edited value degrades to a sane one instead of discarding the whole section.
1240
- * The 1440 ceiling keeps a hand edit from scheduling the refresh further out than a day,
1241
- * which is operator error far more often than intent.
1242
- */
1243
- const catalogAutoRefreshSchema = z.object({
1244
- enabled: z.boolean().optional(),
1245
- intervalMinutes: z.number().int().min(0).max(1440).optional(),
1246
- }).strict();
1247
-
1248
- const configSchema = z.object({
1249
- port: z.number().int().min(0).max(65535).default(10100),
1250
- // A malformed hand edit must disable only remote-role behavior, not discard
1251
- // providers or data-plane keys. Live writes are rejected explicitly below.
1252
- runtimeRole: runtimeRoleSchema.optional().catch(undefined),
1253
- // Malformed optional remote blocks disable only remote GUI behavior. Live
1254
- // candidates are rejected explicitly by remoteGuiConfigError below.
1255
- hub: hubConfigSchema.optional().catch(undefined),
1256
- remoteGui: remoteGuiConfigSchema.optional().catch(undefined),
1257
- // A malformed privacy block must never be read as "unmask": .catch(undefined) drops it and
1258
- // emailMaskingEnabled then falls back to masked, which is also what an absent block means.
1259
- privacy: z.object({ maskEmails: z.boolean().optional() }).strict().optional().catch(undefined),
1260
- // A malformed present client block must remain diagnosable from raw config and
1261
- // fail closed through src/client/state.ts; unrelated provider state still loads.
1262
- client: clientConnectionSchema.optional().catch(undefined),
1263
- managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024).describe(
1264
- "Deprecated compatibility limit for bounded legacy usage readers; GET /api/usage always aggregates the complete ledger",
1265
- ),
1266
- // Invalid hand edits disable only this opt-in circuit. Live writes remain strict.
1267
- upstreamHostCircuitThreshold: z.number().int()
1268
- .min(0)
1269
- .max(UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD)
1270
- .optional()
1271
- .catch(undefined),
1272
- // Opt-in outbound body ceiling. An invalid hand edit disables only this guard, matching the
1273
- // circuit threshold above: a malformed number must not make the proxy refuse traffic.
1274
- maxUpstreamBodyBytes: z.number().int()
1275
- .min(0)
1276
- .optional()
1277
- .catch(undefined),
1278
- // Opt-in inbound body ceiling (#3573). An invalid hand edit degrades to the 256 MiB default
1279
- // rather than failing the parse, matching the outbound guard above: a malformed number must
1280
- // not change what the proxy admits. The hard ceiling is NOT enforced here — because of that
1281
- // `.catch`, and because a config object can be built without this schema at all — but in
1282
- // `resolveInboundBodyLimitBytes()`, which every reader goes through.
1283
- maxInboundBodyBytes: z.number().int()
1284
- .min(0)
1285
- .optional()
1286
- .catch(undefined),
1287
- appOwnedMemoryBudgetMb: z.number().int()
1288
- .min(MIN_APP_OWNED_MEMORY_BUDGET_MB)
1289
- .max(MAX_APP_OWNED_MEMORY_BUDGET_MB)
1290
- .default(DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024))
1291
- .catch(DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024)),
1292
- // A blank hostname degrades to undefined rather than failing the parse. `getDefaultConfig()`
1293
- // carries no `hostname` key, so the backup-and-defaults repair path below cannot merge one
1294
- // away — a hand-edited `"hostname": ""` would fail twice and reset providers/apiKeys to
1295
- // defaults, which is strictly worse than the bind bug this validation exists for. Degrading
1296
- // is safe: startServer() already falls back to 127.0.0.1 for a missing hostname. Write-time
1297
- // rejection lives in validateConfigCandidate() so bad values still surface to the caller.
1298
- hostname: z.string().trim().min(1).optional().catch(undefined),
1299
- // Discriminated on `enabled` so a disabled entry cannot be forced to carry a port (#1102).
1300
- // An enabled one MAY omit it: that is the companion form, which binds 127.0.0.1 on the proxy
1301
- // port and is legal only off a loopback/wildcard bind — a relationship between two fields, so
1302
- // it is enforced in validateConfigCandidate() and again at startup, not here (#4236).
1303
- // A malformed value degrades to undefined rather than failing the whole parse: this is an
1304
- // opt-in convenience surface, and a hand-edit typo here must never reset providers/apiKeys
1305
- // through the backup-and-defaults repair path.
1306
- unauthenticatedLoopbackListener: z.union([
1307
- z.object({ enabled: z.literal(false) }),
1308
- z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535).optional() }),
1309
- ]).optional().catch(undefined),
1310
- providers: z.record(z.string(), providerConfigSchema),
1311
- modelPinnedEfforts: modelPinnedEffortsSchema.optional(),
1312
- defaultProvider: z.string().min(1).default("openai"),
1313
- defaultModelAliases: z.boolean().optional(),
1314
- // Malformed hand edits disable this opt-in projection without rejecting providers.
1315
- cursorEffortRows: z.boolean().optional().catch(false),
1316
- // Fast selectors default on; malformed hand edits disable them without rejecting providers.
1317
- fastRows: z.boolean().default(true).catch(false),
1318
- // Ultra Fast is opt-in for the same reason and degrades the same way: a malformed hand
1319
- // edit turns the tier off rather than rejecting the config that carries it.
1320
- ultraFastTier: z.boolean().optional().catch(false),
1321
- codexMainAccountHardLock: z.boolean().optional().catch(false),
1322
- // Future versions remain opaque through passthrough-compatible whole-config saves.
1323
- // Only version 1 grants deletion authority in the rebase path.
1324
- configRebaseProvenance: z.unknown().optional(),
1325
- // A retry can be billable, so absence and malformed hand edits both stay off.
1326
- emptyCompletionRetry: z.boolean().optional().catch(false),
1327
- // Header suppression changes what Codex sees, so absence and malformed edits stay off.
1328
- dropCodexSafetyBuffering: z.boolean().optional().catch(false),
1329
- // A malformed hand edit must not silently stop opening the browser: fall back
1330
- // to undefined, which resolves to the historical auto-open behavior.
1331
- oauthOpenBrowser: z.boolean().optional().catch(undefined),
1332
- openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(),
1333
- // Invalid hand edits must not discard an otherwise usable config.
1334
- googleAntigravityStaticCatalogVersion: z.union([z.literal(1), z.literal(2)]).optional().catch(undefined),
1335
- subagentModelsVersion: z.number().int().positive().optional().catch(undefined),
1336
- subagentModels: z.array(z.string().min(1)).optional().catch(undefined),
1337
- // A hand-edited advisory version must not cost the operator their providers; a bad
1338
- // value degrades to undefined, which simply raises the notice again.
1339
- multiAgentSurfaceAdvisoryVersion: z.number().int().nonnegative().optional().catch(undefined),
1340
- clientIntegrations: clientIntegrationsSchema.optional().catch(undefined),
1341
- // A malformed profile policy must not fall back to legacy all-profile activation.
1342
- asideProfileSync: asideProfileSyncSchema.optional().catch({ allProfiles: false }),
1343
- providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(),
1344
- providerContextCapValues: z.record(z.string(), z.number().int().positive()).optional(),
1345
- contextCapValue: z.number().int().positive().optional(),
1346
- multiAgentGuidanceEnabled: z.boolean().optional(),
1347
- // Invalid optional recovery config must not discard unrelated provider/account state.
1348
- plaintextV2AgentMessages: z.boolean().optional().catch(undefined),
1349
- agentTaskRecovery: agentTaskRecoverySchema.optional().catch(undefined),
1350
- // Same rationale: a bad notify section must not cost the operator their providers.
1351
- quotaResetNotify: quotaResetNotifySchema.optional().catch(undefined),
1352
- // Same rationale: a bad auto-refresh section must not cost the operator their providers.
1353
- catalogAutoRefresh: catalogAutoRefreshSchema.optional().catch(undefined),
1354
- // These selections pre-date schema validation and used to pass through as
1355
- // unknown fields. Invalid hand edits must disable only the optional
1356
- // delegation/native-default feature, not reject the whole config and hide
1357
- // otherwise valid providers, accounts, or the configured listen port.
1358
- injectionModel: z.string().optional().catch(undefined),
1359
- injectionEffort: z.string().optional().catch(undefined),
1360
- syncCodexSubagentDefaults: z.boolean().optional().catch(undefined),
1361
- // Per-primary-model fallback chains. Values must be non-empty string arrays;
1362
- // malformed entries degrade to undefined rather than rejecting the whole config.
1363
- subagentModelFallbackByModel: z.record(
1364
- z.string(),
1365
- z.array(z.string().trim().min(1)).min(1),
1366
- ).optional().catch(undefined),
1367
- codexShimAutoRestore: z.boolean().optional(),
1368
- codexDesktopAuthless: z.boolean().optional().catch(undefined),
1369
- codexClientCompaction: z.boolean().optional().catch(undefined),
1370
- pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(),
1371
- // A malformed policy degrades to "no policy" rather than failing the parse, so a hand-edited
1372
- // typo cannot trip the backup-and-defaults repair path and wipe providers or pool accounts.
1373
- // Silently ignoring it would be its own trap, so the write path rejects it and loadConfig warns.
1374
- codexPool: codexPoolSchema.optional().catch(undefined),
1375
- codexQuotaAutoRefresh: codexQuotaAutoRefreshSchema.optional().catch(undefined),
1376
- codexAccountNamespaces: codexAccountNamespacesSchema.optional(),
1377
- // Selection order is a preference, not a safety control like pause: a malformed
1378
- // map degrades to "no ordering" rather than failing the parse, so a hand-edited
1379
- // typo cannot trip the backup-and-defaults repair path and wipe providers or
1380
- // pool accounts. Warning emitted in loadConfig.
1381
- codexAccountPriorities: codexAccountPrioritiesSchema.optional().catch(undefined),
1382
- activeCodexAccountPinned: z.string().regex(CODEX_ACCOUNT_PIN_PATTERN).optional().catch(undefined),
1383
- // A malformed hand edit must degrade to false without discarding providers, accounts,
1384
- // or the exact selector map. Live writes remain strict.
1385
- codexAccountPickerEnabled: z.boolean().optional().catch(false),
1386
- resetCreditAutoRedeem: z.object({
1387
- enabled: z.boolean().optional(),
1388
- leadTimeMinutes: z.number().int().min(1).max(60).optional(),
1389
- }).optional().catch(undefined),
1390
- // Same degrade-to-off rule as the flags above: a hand-edited typo in an opt-in pool
1391
- // feature must never cost the operator their providers.
1392
- pool: z.object({
1393
- kernel: z.boolean().optional(),
1394
- cacheAffinity: z.boolean().optional(),
1395
- }).optional().catch(undefined),
1396
- // Model ids excluded from the Grok Build managed block (dashboard switches).
1397
- grokExcludedModels: z.array(z.string()).optional(),
1398
- // Invalid values degrade to undefined ("auto") instead of failing the whole
1399
- // parse: a hand-edited typo must never trip the backup-and-defaults repair
1400
- // path below and wipe providers/pool accounts. Warning emitted in loadConfig.
1401
- streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined),
1402
- blockedModelRedirects: z.record(z.string(), z.string()).optional().catch(undefined),
1403
- // Same degrade-don't-reject rationale as the fields above: a hand-edited
1404
- // non-string must not trip the backup-and-defaults repair path. Unset then
1405
- // takes the canonical sideband path (src/server/live.ts normalizeSidebandRoot).
1406
- experimentalRealtimeWsBaseUrl: z.string().optional().catch(undefined),
1407
- // Salvage element by element, and never fail the parse. Two spellings were
1408
- // measured on this zod version and both lose data:
1409
- // `z.array(entry).catch(undefined)` -> one bad entry discards EVERY key
1410
- // `z.array(z.unknown())` -> a non-array value still raises
1411
- // invalid_type, reaching the
1412
- // backup-and-defaults repair path
1413
- // Starting from `unknown` is what makes both survivable. A key the user still
1414
- // has deployed must not be collateral damage for one bad neighbour, and on a
1415
- // remote bind an emptied array is worse than cosmetic: assertServerAuthConfig
1416
- // refuses to start without a data credential.
1417
- apiKeys: z.unknown().optional().transform(value => {
1418
- if (value === undefined) return undefined;
1419
- if (!Array.isArray(value)) return undefined;
1420
- return value
1421
- .filter(row => apiKeyEntrySchema.safeParse(row).success)
1422
- .map(row => apiKeyEntrySchema.parse(row) as OcxApiKeyEntry);
1423
- }),
1424
- }).passthrough().superRefine((config, ctx) => {
1425
- const claudeCode = (config as { claudeCode?: unknown }).claudeCode;
1426
- if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) {
1427
- ctx.addIssue({ code: "custom", path: ["claudeCode"], message: "claudeCode must be an object" });
1428
- } else if (claudeCode) {
1429
- const claude = claudeCode as { desktopProfile?: unknown };
1430
- if (claude.desktopProfile !== undefined) {
1431
- try {
1432
- parseDesktopProfile(claude.desktopProfile);
1433
- } catch (error) {
1434
- ctx.addIssue({
1435
- code: "custom",
1436
- path: ["claudeCode", "desktopProfile"],
1437
- message: error instanceof Error ? error.message : String(error),
1438
- });
1439
- }
1440
- }
1441
- }
1442
-
1443
- const accountNamespaces = config.codexAccountNamespaces;
1444
- if (accountNamespaces) {
1445
- const configuredAccountIds = configuredCodexPoolAccountIds(config.codexAccounts);
1446
- const configuredProviderNamespaces = new Set([
1447
- COMBO_NAMESPACE,
1448
- OPENAI_CODEX_PROVIDER_ID,
1449
- POLICY_NAMESPACE,
1450
- ...Object.keys(config.providers),
1451
- ].map(codexProviderNamespaceKey));
1452
- const namespaceTargets = new Set(
1453
- Object.values(accountNamespaces)
1454
- .filter(accountId => accountId !== MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET),
1455
- );
1456
- for (const namespace of Object.keys(accountNamespaces)) {
1457
- if (configuredProviderNamespaces.has(codexProviderNamespaceKey(namespace))) {
1458
- ctx.addIssue({
1459
- code: "custom",
1460
- path: ["codexAccountNamespaces", namespace],
1461
- message: "account selectors must not collide with configured provider, combo, or routing policy namespaces",
1462
- });
1463
- }
1464
- if (configuredAccountIds.has(namespace) || namespaceTargets.has(namespace)) {
1465
- ctx.addIssue({
1466
- code: "custom",
1467
- path: ["codexAccountNamespaces", namespace],
1468
- message: CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR,
1469
- });
1470
- }
1471
- }
1472
- }
1473
- for (const name of Object.keys(config.providers)) {
1474
- if (!isValidProviderName(name)) {
1475
- ctx.addIssue({
1476
- code: "custom",
1477
- path: ["providers", redactSecretString(name)],
1478
- message: "provider names must use letters, numbers, dot, underscore, or hyphen and cannot be reserved JavaScript object keys or routing namespaces (policy)",
1479
- });
1480
- }
1481
- const provider = config.providers[name];
1482
- if (hasFastWireCapabilityConflict(provider)) {
1483
- ctx.addIssue({
1484
- code: "custom",
1485
- path: ["providers", redactSecretString(name), "fastWire"],
1486
- message: "fastWire=null conflicts with supportsServiceTier=true",
1487
- });
1488
- }
1489
- const openRouterRoutingError = openRouterRoutingConfigError(provider);
1490
- if (openRouterRoutingError) {
1491
- ctx.addIssue({
1492
- code: "custom",
1493
- path: [
1494
- "providers",
1495
- redactSecretString(name),
1496
- openRouterRoutingError.startsWith("modelOpenRouterRouting")
1497
- ? "modelOpenRouterRouting"
1498
- : "openRouterRouting",
1499
- ],
1500
- message: openRouterRoutingError,
1501
- });
1502
- }
1503
- const vercelRoutingError = vercelGatewayRoutingConfigError(provider);
1504
- if (vercelRoutingError) {
1505
- ctx.addIssue({
1506
- code: "custom",
1507
- path: [
1508
- "providers",
1509
- redactSecretString(name),
1510
- vercelRoutingError.startsWith("modelVercelGatewayRouting")
1511
- ? "modelVercelGatewayRouting"
1512
- : "vercelGatewayRouting",
1513
- ],
1514
- message: vercelRoutingError,
1515
- });
1516
- }
1517
- if (Object.hasOwn(provider, "virtualModels")) {
1518
- ctx.addIssue({
1519
- code: "custom",
1520
- path: ["providers", redactSecretString(name), "virtualModels"],
1521
- message: "virtualModels is registry-only and must not be persisted",
1522
- });
1523
- }
1524
- const baseUrlError = providerBaseUrlConfigError(provider.baseUrl);
1525
- if (baseUrlError) {
1526
- ctx.addIssue({
1527
- code: "custom",
1528
- path: ["providers", redactSecretString(name), "baseUrl"],
1529
- message: baseUrlError,
1530
- });
1531
- } else {
1532
- const destinationError = providerDestinationConfigError(name, provider);
1533
- if (destinationError) {
1534
- ctx.addIssue({
1535
- code: "custom",
1536
- path: ["providers", redactSecretString(name), "baseUrl"],
1537
- message: destinationError,
1538
- });
1539
- }
1540
- }
1541
- for (const field of ["responsesPath", "chatCompletionsPath"] as const) {
1542
- const sendPathError = providerRelativeSendPathConfigError(field, provider[field]);
1543
- if (sendPathError) {
1544
- ctx.addIssue({
1545
- code: "custom",
1546
- path: ["providers", redactSecretString(name), field],
1547
- message: sendPathError,
1548
- });
1549
- }
1550
- }
1551
- const headersError = providerHeadersConfigError((provider as { headers?: unknown }).headers);
1552
- if (headersError) {
1553
- ctx.addIssue({
1554
- code: "custom",
1555
- path: ["providers", redactSecretString(name), "headers"],
1556
- message: headersError,
1557
- });
1558
- }
1559
- const modelCostsError = providerModelCostsConfigError((provider as { modelCosts?: unknown }).modelCosts);
1560
- if (modelCostsError) {
1561
- ctx.addIssue({
1562
- code: "custom",
1563
- // The provider key is caller-controlled and can be token-shaped; redact it
1564
- // before schemaDiagnosticsError serializes the path (ocx config validate/import).
1565
- path: ["providers", redactSecretString(name), "modelCosts"],
1566
- message: modelCostsError,
1567
- });
1568
- }
1569
- const modelDisplayNamesError = modelDisplayNamesConfigError(
1570
- (provider as { modelDisplayNames?: unknown }).modelDisplayNames,
1571
- );
1572
- if (modelDisplayNamesError) {
1573
- ctx.addIssue({
1574
- code: "custom",
1575
- path: ["providers", redactSecretString(name), "modelDisplayNames"],
1576
- message: modelDisplayNamesError,
1577
- });
1578
- }
1579
- const apiKeyTransportError = apiKeyTransportConfigError(provider as OcxProviderConfig);
1580
- if (apiKeyTransportError) {
1581
- ctx.addIssue({
1582
- code: "custom",
1583
- path: ["providers", redactSecretString(name), "apiKeyTransport"],
1584
- message: apiKeyTransportError,
1585
- });
1586
- }
1587
- const modelAdaptersError = modelAdapterRecordConfigError(
1588
- (provider as { modelAdapters?: unknown }).modelAdapters,
1589
- "modelAdapters",
1590
- name,
1591
- provider,
1592
- );
1593
- if (modelAdaptersError) {
1594
- ctx.addIssue({
1595
- code: "custom",
1596
- path: ["providers", redactSecretString(name), "modelAdapters"],
1597
- message: modelAdaptersError,
1598
- });
1599
- }
1600
- const preferHostedToolsError = modelPreferHostedToolsConfigError(
1601
- (provider as { modelPreferHostedTools?: unknown }).modelPreferHostedTools,
1602
- "modelPreferHostedTools",
1603
- name,
1604
- provider,
1605
- );
1606
- if (preferHostedToolsError) {
1607
- ctx.addIssue({
1608
- code: "custom",
1609
- path: ["providers", redactSecretString(name), "modelPreferHostedTools"],
1610
- message: preferHostedToolsError,
1611
- });
1612
- }
1613
- const maxInputError = positiveIntegerRecordConfigError(
1614
- (provider as { modelMaxInputTokens?: unknown }).modelMaxInputTokens,
1615
- "modelMaxInputTokens",
1616
- );
1617
- if (maxInputError) {
1618
- ctx.addIssue({
1619
- code: "custom",
1620
- path: ["providers", redactSecretString(name), "modelMaxInputTokens"],
1621
- message: maxInputError,
1622
- });
1623
- }
1624
- const autoCompactError = modelAutoCompactTokenLimitsConfigError(
1625
- (provider as { modelAutoCompactTokenLimits?: unknown }).modelAutoCompactTokenLimits,
1626
- { requireNativeIds: name === OPENAI_CODEX_PROVIDER_ID },
1627
- );
1628
- if (autoCompactError) {
1629
- ctx.addIssue({
1630
- code: "custom",
1631
- path: ["providers", redactSecretString(name), "modelAutoCompactTokenLimits"],
1632
- message: autoCompactError,
1633
- });
1634
- }
1635
- const reasoningSummariesError = booleanRecordConfigError(
1636
- (provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries,
1637
- "modelSupportsReasoningSummaries",
1638
- );
1639
- if (reasoningSummariesError) {
1640
- ctx.addIssue({
1641
- code: "custom",
1642
- path: ["providers", redactSecretString(name), "modelSupportsReasoningSummaries"],
1643
- message: reasoningSummariesError,
1644
- });
1645
- }
1646
- const verbositySupportError = booleanRecordConfigError(
1647
- (provider as { modelSupportsVerbosity?: unknown }).modelSupportsVerbosity,
1648
- "modelSupportsVerbosity",
1649
- );
1650
- if (verbositySupportError) {
1651
- ctx.addIssue({
1652
- code: "custom",
1653
- path: ["providers", redactSecretString(name), "modelSupportsVerbosity"],
1654
- message: verbositySupportError,
1655
- });
1656
- }
1657
- const serviceTierModelsError = booleanRecordConfigError(
1658
- (provider as { modelSupportsServiceTier?: unknown }).modelSupportsServiceTier,
1659
- "modelSupportsServiceTier",
1660
- );
1661
- if (serviceTierModelsError) {
1662
- ctx.addIssue({
1663
- code: "custom",
1664
- path: ["providers", redactSecretString(name), "modelSupportsServiceTier"],
1665
- message: serviceTierModelsError,
1666
- });
1667
- }
1668
- const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError(
1669
- (provider as { modelReasoningSummaryDelivery?: unknown }).modelReasoningSummaryDelivery,
1670
- (provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries,
1671
- );
1672
- if (reasoningSummaryDeliveryError) {
1673
- ctx.addIssue({
1674
- code: "custom",
1675
- path: ["providers", redactSecretString(name), "modelReasoningSummaryDelivery"],
1676
- message: reasoningSummaryDeliveryError,
1677
- });
1678
- }
1679
- const defaultMaxOutputError = positiveIntegerConfigError(
1680
- (provider as { defaultMaxOutputTokens?: unknown }).defaultMaxOutputTokens,
1681
- "defaultMaxOutputTokens",
1682
- );
1683
- if (defaultMaxOutputError) {
1684
- ctx.addIssue({
1685
- code: "custom",
1686
- path: ["providers", redactSecretString(name), "defaultMaxOutputTokens"],
1687
- message: defaultMaxOutputError,
1688
- });
1689
- }
1690
- const maxOutputError = positiveIntegerRecordConfigError(
1691
- (provider as { modelMaxOutputTokens?: unknown }).modelMaxOutputTokens,
1692
- "modelMaxOutputTokens",
1693
- );
1694
- if (maxOutputError) {
1695
- ctx.addIssue({
1696
- code: "custom",
1697
- path: ["providers", redactSecretString(name), "modelMaxOutputTokens"],
1698
- message: maxOutputError,
1699
- });
1700
- }
1701
- const structuredOutputOptOutError = nonBlankStringArrayConfigError(
1702
- (provider as { noStructuredOutputModels?: unknown }).noStructuredOutputModels,
1703
- "noStructuredOutputModels",
1704
- );
1705
- if (structuredOutputOptOutError) {
1706
- ctx.addIssue({
1707
- code: "custom",
1708
- path: ["providers", redactSecretString(name), "noStructuredOutputModels"],
1709
- message: structuredOutputOptOutError,
1710
- });
1711
- }
1712
- const jsonSchemaOptOutError = nonBlankStringArrayConfigError(
1713
- (provider as { noJsonSchemaModels?: unknown }).noJsonSchemaModels,
1714
- "noJsonSchemaModels",
1715
- );
1716
- if (jsonSchemaOptOutError) {
1717
- ctx.addIssue({
1718
- code: "custom",
1719
- path: ["providers", redactSecretString(name), "noJsonSchemaModels"],
1720
- message: jsonSchemaOptOutError,
1721
- });
1722
- }
1723
- const retainModelsError = nonBlankStringArrayConfigError(
1724
- (provider as { retainModels?: unknown }).retainModels,
1725
- "retainModels",
1726
- );
1727
- if (retainModelsError) {
1728
- ctx.addIssue({
1729
- code: "custom",
1730
- path: ["providers", redactSecretString(name), "retainModels"],
1731
- message: retainModelsError,
1732
- });
1733
- }
1734
- const toolReasoningOptOutError = nonBlankStringArrayConfigError(
1735
- (provider as { omitReasoningEffortWithToolsModels?: unknown }).omitReasoningEffortWithToolsModels,
1736
- "omitReasoningEffortWithToolsModels",
1737
- );
1738
- if (toolReasoningOptOutError) {
1739
- ctx.addIssue({
1740
- code: "custom",
1741
- path: ["providers", redactSecretString(name), "omitReasoningEffortWithToolsModels"],
1742
- message: toolReasoningOptOutError,
1743
- });
1744
- }
1745
- if (Object.hasOwn(provider, "codexAccountMode") && provider.codexAccountMode !== undefined) {
1746
- // Persisted account mode is valid ONLY on the canonical built-in `openai` forward provider.
1747
- // Old openai-multi rows stay parseable (they never carry a mode) so startup can migrate them.
1748
- const canonicalOpenAiShape = name === "openai"
1749
- && provider.adapter === "openai-responses"
1750
- && (provider as { authMode?: unknown }).authMode === "forward"
1751
- && typeof provider.baseUrl === "string"
1752
- && provider.baseUrl.replace(/\/+$/, "") === "https://chatgpt.com/backend-api/codex";
1753
- if (!canonicalOpenAiShape) {
1754
- ctx.addIssue({
1755
- code: "custom",
1756
- path: ["providers", redactSecretString(name), "codexAccountMode"],
1757
- message: "codexAccountMode is valid only on the canonical built-in openai provider",
1758
- });
1759
- }
1760
- }
1761
- }
1762
- if (!hasOwnProvider(config.providers, config.defaultProvider)) {
1763
- ctx.addIssue({
1764
- code: "custom",
1765
- path: ["defaultProvider"],
1766
- message: "defaultProvider must exist in providers",
1767
- });
1768
- }
1769
- const combos = (config as { combos?: unknown }).combos;
1770
- if (combos !== undefined) {
1771
- if (!combos || typeof combos !== "object" || Array.isArray(combos)) {
1772
- ctx.addIssue({ code: "custom", path: ["combos"], message: "combos must be an object" });
1773
- } else {
1774
- for (const [id, raw] of Object.entries(combos as Record<string, unknown>)) {
1775
- const alias = raw && typeof raw === "object" && !Array.isArray(raw)
1776
- ? (raw as { alias?: unknown }).alias
1777
- : undefined;
1778
- if (typeof alias === "string" && codexAccountNamespaceForModel(accountNamespaces, alias.trim())) {
1779
- ctx.addIssue({
1780
- code: "custom",
1781
- path: ["combos", id, "alias"],
1782
- message: CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
1783
- });
1784
- }
1785
- // Pass the full map so cross-combo rules (alias uniqueness) apply at load time
1786
- // too, not just via the management API; each combo is excluded from its own check.
1787
- for (const issue of comboConfigIssues(id, raw, config.providers, {
1788
- combos: combos as Record<string, import("./types").OcxComboConfig>,
1789
- excludeComboId: id,
1790
- })) {
1791
- ctx.addIssue({
1792
- code: "custom",
1793
- path: ["combos", id, ...issue.path],
1794
- message: issue.message,
1795
- });
1796
- }
1797
- }
1798
- }
1799
- }
1800
- const routingProfiles = (config as { routingProfiles?: unknown }).routingProfiles;
1801
- if (routingProfiles !== undefined) {
1802
- if (!routingProfiles || typeof routingProfiles !== "object" || Array.isArray(routingProfiles)) {
1803
- ctx.addIssue({ code: "custom", path: ["routingProfiles"], message: "routingProfiles must be an object" });
1804
- } else {
1805
- for (const [id, raw] of Object.entries(routingProfiles as Record<string, unknown>)) {
1806
- for (const issue of routingProfileIssues(id, raw, {
1807
- providers: config.providers,
1808
- combos: combos as Record<string, import("./types").OcxComboConfig> | undefined,
1809
- routingProfiles: routingProfiles as Record<string, import("./types").OcxRoutingProfileConfig>,
1810
- codexAccountNamespaces: accountNamespaces,
1811
- }, { excludeProfileId: id })) {
1812
- ctx.addIssue({
1813
- code: "custom",
1814
- path: ["routingProfiles", id, ...issue.path],
1815
- message: issue.message,
1816
- });
1817
- }
1818
- }
1819
- }
1820
- }
1821
- });
1822
-
1823
- export function hardenExistingSecret(path: string): void {
1824
- if (existsSync(path)) {
1825
- try { chmodSync(path, 0o600); } catch { /* best-effort */ }
1826
- if (process.platform === "win32") {
1827
- hardenSecretPath(path, { required: false });
1828
- }
1829
- }
1830
- }
1831
- /** Load only: discard invalid optional pins without rewriting the file or losing providers. */
1832
- function sanitizeReasoningPinsForLoad(parsed: unknown): void {
1833
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
1834
- const root = parsed as Record<string, unknown>;
1835
- let degraded = false;
1836
- const sanitizeMap = (owner: Record<string, unknown>, field: string) => {
1837
- const value = owner[field];
1838
- if (value === undefined) return;
1839
- if (!value || typeof value !== "object" || Array.isArray(value)
1840
- || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) {
1841
- delete owner[field];
1842
- degraded = true;
1843
- return;
1844
- }
1845
- const counts = new Map<string, number>();
1846
- for (const key of Object.keys(value)) counts.set(key.trim(), (counts.get(key.trim()) ?? 0) + 1);
1847
- const valid: Record<string, string> = Object.create(null);
1848
- for (const [key, effort] of Object.entries(value)) {
1849
- if (counts.get(key.trim()) !== 1 || modelPinnedEffortsConfigError({ [key]: effort }) !== null) {
1850
- degraded = true;
1851
- continue;
1852
- }
1853
- valid[key.trim()] = effort as string;
1854
- }
1855
- if (Object.keys(valid).length) owner[field] = valid;
1856
- else delete owner[field];
1857
- };
1858
- sanitizeMap(root, "modelPinnedEfforts");
1859
- if (root.providers && typeof root.providers === "object" && !Array.isArray(root.providers)) {
1860
- for (const value of Object.values(root.providers)) {
1861
- if (!value || typeof value !== "object" || Array.isArray(value)) continue;
1862
- const provider = value as Record<string, unknown>;
1863
- if (pinnedReasoningEffortConfigError(provider.pinnedReasoningEffort)) {
1864
- delete provider.pinnedReasoningEffort;
1865
- degraded = true;
1866
- }
1867
- sanitizeMap(provider, "modelPinnedReasoningEfforts");
1868
- }
1869
- }
1870
- // Never include a provider/model name or value: malformed pins can contain secrets.
1871
- if (degraded) console.warn("config.json contains invalid optional reasoning pins — ignoring invalid fields or entries");
1872
- }
1873
-
1874
- /**
1875
- * The schema's `.catch(undefined)` silently degrades an invalid persisted
1876
- * `streamMode` to "auto"; surface that once so a hand-edited typo (e.g.
1877
- * "legacy_tee") is discoverable instead of silently changing stream shape.
1878
- */
1879
- function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig): void {
1880
- if (!rawParsed || typeof rawParsed !== "object") return;
1881
- const raw = (rawParsed as Record<string, unknown>).streamMode;
1882
- if (raw !== undefined && validated.streamMode === undefined) {
1883
- console.warn(`⚠️ config.json streamMode ${JSON.stringify(raw)} is invalid (expected "auto", "legacy-tee", or "eager-relay") — falling back to "auto"`);
1884
- }
1885
- }
1886
-
1887
- /**
1888
- * Load-time degradation for `retryOn429` (loadConfig only): one hand-edited invalid optional
1889
- * field (e.g. `attempts: 0` or a string) must not trip the whole provider schema and hide every
1890
- * provider/key behind a default config. Invalid fields are dropped with a warning; the management
1891
- * write boundary still rejects invalid policies explicitly.
1892
- */
1893
- function sanitizeRetryOn429ForLoad(parsed: unknown): void {
1894
- if (!parsed || typeof parsed !== "object") return;
1895
- const root = parsed as Record<string, unknown>;
1896
- const providers = root.providers;
1897
- if (!providers || typeof providers !== "object" || Array.isArray(providers)) return;
1898
- for (const [name, provider] of Object.entries(providers as Record<string, unknown>)) {
1899
- // This sanitizer runs BEFORE schema validation, so the provider name is untrusted: redact
1900
- // secret-shaped names and JSON-escape control characters before it reaches any warning.
1901
- const safeProviderName = JSON.stringify(redactSecretString(name));
1902
- if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue;
1903
- const p = provider as Record<string, unknown>;
1904
- const policy = p.retryOn429;
1905
- if (policy === undefined) continue;
1906
- if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
1907
- delete p.retryOn429;
1908
- // Never serialize the value: an accidental `retryOn429: "sk-..."` would leak the secret.
1909
- console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429 (${typeof policy}) is invalid — ignoring the policy`);
1910
- continue;
1911
- }
1912
- const policyRecord = policy as Record<string, unknown>;
1913
- // An explicitly present but invalid master switch must not silently default to ENABLED:
1914
- // drop the whole policy so a hand-edit that tried to disable retries stays disabled.
1915
- if ("enabled" in policyRecord && typeof policyRecord.enabled !== "boolean") {
1916
- delete p.retryOn429;
1917
- console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.enabled (${typeof policyRecord.enabled}) is invalid — ignoring the whole policy`);
1918
- continue;
1919
- }
1920
- // Field checks derive from the shared policy schema so the bounds cannot drift
1921
- // between the load-time sanitizer, the config schema, and the write boundary.
1922
- const policyShape = retryOn429PolicySchema.shape;
1923
- const hadPolicyEntries = Object.keys(policyRecord).length > 0;
1924
- const cleaned: Record<string, unknown> = {};
1925
- for (const [key, fieldSchema] of Object.entries(policyShape)) {
1926
- const value = policyRecord[key];
1927
- if (value === undefined) continue;
1928
- if (fieldSchema.safeParse(value).success) cleaned[key] = value;
1929
- // Log only the received type, never the value (provider config can hold secrets).
1930
- else console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.${key} (${typeof value}) is invalid — ignoring the field`);
1931
- }
1932
- const knownKeys = new Set(Object.keys(policyShape));
1933
- for (const key of Object.keys(policyRecord)) {
1934
- if (!knownKeys.has(key)) {
1935
- // Redact the field NAME before logging: a malformed hand-edit can place a secret in a
1936
- // property name (`retryOn429: { "sk-...": true }`). Ordinary typos (e.g. `attempt`)
1937
- // stay readable, secret-shaped names become [REDACTED]. JSON-escape afterwards so a
1938
- // control-character property name (newline/ANSI) can never forge a log line.
1939
- console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.${JSON.stringify(redactSecretString(key))} is not a recognized field — ignoring it`);
1940
- }
1941
- }
1942
- if (hadPolicyEntries && Object.keys(cleaned).length === 0) {
1943
- // Every supplied field was invalid: drop the whole policy. Persisting `{}` here would
1944
- // opt IN to retries with defaults, which is the opposite of what a malformed
1945
- // disable-oriented edit (`retryOn429: { enabled: "false" }`, `attempts: 0`) asked for.
1946
- delete p.retryOn429;
1947
- console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429 has no valid fields left — removing the policy (an empty policy would enable retries with defaults)`);
1948
- } else {
1949
- // Preserve an intentionally empty `retryOn429: {}` (presence = opt-in with defaults).
1950
- p.retryOn429 = cleaned;
1951
- }
1952
- }
1953
- }
1954
-
1955
- /**
1956
- * Management write-boundary validation for `retryOn429` (fail closed). Unlike the
1957
- * lenient load-time sanitizer, invalid values and unknown keys are rejected outright so
1958
- * a POST/PATCH cannot persist a policy the proxy would then silently degrade. Reuses the
1959
- * shared policy schema. Never echoes values, and secret-shaped unknown field names are
1960
- * redacted (a malformed write can place a secret in a property name).
1961
- */
1962
- export function retryOn429PolicyConfigError(policy: unknown): string | null {
1963
- if (policy === undefined) return null;
1964
- const result = retryOn429PolicySchema.safeParse(policy);
1965
- if (result.success) return null;
1966
- const first = result.error.issues[0];
1967
- if (!first) return "retryOn429 is invalid";
1968
- if (first.code === "unrecognized_keys") {
1969
- const names = first.keys.map(key => JSON.stringify(redactSecretString(key))).join(", ");
1970
- return `retryOn429 has unrecognized field${first.keys.length > 1 ? "s" : ""}: ${names}`;
1971
- }
1972
- if (first.path.length === 0) return `retryOn429 is invalid (${first.message})`;
1973
- const field = String(first.path[first.path.length - 1]);
1974
- return `retryOn429.${field} is invalid (${first.message})`;
1975
- }
1976
-
1977
- function sanitizeCapabilityDeclarationsForLoad(parsed: unknown): void {
1978
- if (!parsed || typeof parsed !== "object") return;
1979
- const providers = (parsed as Record<string, unknown>).providers;
1980
- if (!providers || typeof providers !== "object" || Array.isArray(providers)) return;
1981
- for (const [name, value] of Object.entries(providers)) {
1982
- if (!value || typeof value !== "object" || Array.isArray(value)) continue;
1983
- const provider = value as Record<string, unknown>;
1984
- if (provider.modelCapabilities === undefined) continue;
1985
- if (modelCapabilitiesConfigError(provider.modelCapabilities) !== null) {
1986
- console.warn(`config.json provider ${JSON.stringify(redactSecretString(name))} has malformed modelCapabilities; retaining valid axes and restricting malformed input modalities to text`);
1987
- const repaired = sanitizeModelCapabilitiesForLoad(provider.modelCapabilities);
1988
- if (repaired) provider.modelCapabilities = repaired;
1989
- else delete provider.modelCapabilities;
1990
- }
1991
- }
1992
- }
1993
-
1994
- /**
1995
- * Load-time degradation for `providers.<name>.modelCosts`, mirroring
1996
- * {@link sanitizeRetryOn429ForLoad}. A hand-edited malformed display-price row
1997
- * must not fail the whole config parse — that would back up config.json and
1998
- * fall back to defaults, dropping otherwise valid providers and the default
1999
- * route for a typo in a non-runtime display field. Invalid rows are dropped
2000
- * with a warning; strict rejection stays at the management/write boundary
2001
- * (providerManagementConfigError).
2002
- */
2003
- function sanitizeModelCostsForLoad(parsed: unknown): void {
2004
- if (!parsed || typeof parsed !== "object") return;
2005
- const root = parsed as Record<string, unknown>;
2006
- const providers = root.providers;
2007
- if (!providers || typeof providers !== "object" || Array.isArray(providers)) return;
2008
- for (const [name, provider] of Object.entries(providers as Record<string, unknown>)) {
2009
- // Runs before schema validation, so the provider name is untrusted: redact
2010
- // secret-shaped names and JSON-escape control characters for the warning.
2011
- const safeProviderName = JSON.stringify(redactSecretString(name));
2012
- if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue;
2013
- const p = provider as Record<string, unknown>;
2014
- const costs = p.modelCosts;
2015
- if (costs === undefined) continue;
2016
- if (!costs || typeof costs !== "object" || Array.isArray(costs)) {
2017
- delete p.modelCosts;
2018
- console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts (${typeof costs}) is invalid — ignoring the overlay`);
2019
- continue;
2020
- }
2021
- const costsRecord = costs as Record<string, unknown>;
2022
- const hadEntries = Object.keys(costsRecord).length > 0;
2023
- let kept = 0;
2024
- for (const [modelId, entry] of Object.entries(costsRecord)) {
2025
- // Reuse the shared per-row shape contract so the load-time sanitizer
2026
- // cannot drift from the schema and the write boundary.
2027
- if (providerModelCostsConfigError({ [modelId]: entry }) === null) {
2028
- kept++;
2029
- continue;
2030
- }
2031
- delete costsRecord[modelId];
2032
- // Redact the model id: a hand-edit can place a secret in a key name.
2033
- console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts.${JSON.stringify(redactSecretString(modelId))} is invalid — ignoring the row`);
2034
- }
2035
- if (hadEntries && kept === 0) {
2036
- delete p.modelCosts;
2037
- console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts has no valid rows left — removing the overlay`);
2038
- }
2039
- }
2040
- }
2041
-
2042
- /**
2043
- * Load-time degradation for provider-scoped auto-review selectors. A malformed
2044
- * hand edit must not fail the whole config parse; the management boundary stays
2045
- * strict and rejects the same shapes before they can be written.
2046
- */
2047
- function sanitizeAutoReviewForLoad(parsed: unknown): void {
2048
- if (!parsed || typeof parsed !== "object") return;
2049
- const root = parsed as Record<string, unknown>;
2050
- const providers = root.providers;
2051
- if (!providers || typeof providers !== "object" || Array.isArray(providers)) return;
2052
- for (const [name, providerValue] of Object.entries(providers as Record<string, unknown>)) {
2053
- if (!providerValue || typeof providerValue !== "object" || Array.isArray(providerValue)) continue;
2054
- const provider = providerValue as Record<string, unknown>;
2055
- const safeProviderName = JSON.stringify(redactSecretString(name));
2056
- if (name === "openai") {
2057
- delete provider.autoReviewModel;
2058
- delete provider.autoReviewModelOverrides;
2059
- continue;
2060
- }
2061
- if (provider.autoReviewModel !== undefined
2062
- && autoReviewModelTargetConfigError(provider.autoReviewModel, "autoReviewModel", true) !== null) {
2063
- console.warn(`⚠️ config.json providers.${safeProviderName}.autoReviewModel is invalid — ignoring the selector`);
2064
- delete provider.autoReviewModel;
2065
- }
2066
- if (provider.autoReviewModelOverrides !== undefined) {
2067
- const overridesError = autoReviewModelOverridesConfigError(
2068
- provider.autoReviewModelOverrides,
2069
- "autoReviewModelOverrides",
2070
- true,
2071
- );
2072
- if (overridesError) {
2073
- console.warn(`⚠️ config.json providers.${safeProviderName}.autoReviewModelOverrides is invalid — ignoring the map`);
2074
- delete provider.autoReviewModelOverrides;
2075
- }
2076
- }
2077
- }
2078
- }
2079
-
2080
- /**
2081
- * Companion to {@link warnDegradedStreamMode} for a blank persisted `hostname`. The bind
2082
- * falls back to loopback, which is the safe direction but not what the file asked for —
2083
- * say so once instead of silently ignoring the field.
2084
- */
2085
- function warnDegradedHostname(rawParsed: unknown, validated: OcxConfig): void {
2086
- if (!rawParsed || typeof rawParsed !== "object") return;
2087
- const raw = (rawParsed as Record<string, unknown>).hostname;
2088
- if (raw !== undefined && validated.hostname === undefined) {
2089
- console.warn(`⚠️ config.json hostname ${JSON.stringify(raw)} is not a usable bind address — falling back to 127.0.0.1`);
2090
- }
2091
- }
2092
-
2093
- function degradedListenerWarnings(rawParsed: unknown, validated: OcxConfig): string[] {
2094
- const raw = rawConfigRecord(rawParsed);
2095
- if (!raw) return [];
2096
- const warnings: string[] = [];
2097
- if (raw.unauthenticatedLoopbackListener !== undefined && validated.unauthenticatedLoopbackListener === undefined) {
2098
- warnings.push("unauthenticatedLoopbackListener ignored: invalid listener configuration; repair config.json before enabling the listener");
2099
- }
2100
- const hub = rawConfigRecord(raw.hub);
2101
- if (hub?.managementIngress !== undefined && !managementIngressSchema.safeParse(hub.managementIngress).success) {
2102
- warnings.push("hub.managementIngress ignored: invalid management listener configuration; repair config.json before enabling the listener");
2103
- }
2104
- return warnings;
2105
- }
2106
-
2107
- function warnDegradedListeners(rawParsed: unknown, validated: OcxConfig): void {
2108
- for (const warning of degradedListenerWarnings(rawParsed, validated)) {
2109
- console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
2110
- }
2111
- }
2112
-
2113
- /**
2114
- * Companion to {@link warnDegradedStreamMode} for a malformed selection-order map.
2115
- * Priority is a preference, so the schema drops the whole map rather than failing
2116
- * the parse — say so once, otherwise the pool silently reverts to flat ordering.
2117
- */
2118
- function degradedCodexAccountPriorityWarnings(rawParsed: unknown, validated: OcxConfig): string[] {
2119
- const record = rawConfigRecord(rawParsed);
2120
- const warnings: string[] = [];
2121
- // The pin degrades silently otherwise, which reads as the manual selection simply
2122
- // not having survived the restart.
2123
- if (record?.activeCodexAccountPinned !== undefined && validated.activeCodexAccountPinned === undefined) {
2124
- warnings.push("activeCodexAccountPinned is not a valid account id — the manually selected account is no longer pinned");
2125
- }
2126
- const raw = record?.codexAccountPriorities;
2127
- if (raw !== undefined && validated.codexAccountPriorities === undefined) {
2128
- warnings.push("codexAccountPriorities is invalid (expected account ids mapped to integers between -100 and 100) — account selection order is disabled");
2129
- }
2130
- return warnings;
2131
- }
2132
-
2133
- function warnDegradedCodexAccountPriorities(rawParsed: unknown, validated: OcxConfig): void {
2134
- for (const warning of degradedCodexAccountPriorityWarnings(rawParsed, validated)) {
2135
- console.warn(`⚠️ config.json ${warning}`);
2136
- }
2137
- }
2138
-
2139
- function degradedCodexQuotaAutoRefreshWarning(rawParsed: unknown, validated: OcxConfig): string | null {
2140
- const raw = rawConfigRecord(rawParsed)?.codexQuotaAutoRefresh;
2141
- if (raw === undefined || validated.codexQuotaAutoRefresh !== undefined) return null;
2142
- return "codexQuotaAutoRefresh is invalid — automatic quota-window activation is disabled";
2143
- }
2144
-
2145
- function warnDegradedCodexQuotaAutoRefresh(rawParsed: unknown, validated: OcxConfig): void {
2146
- const warning = degradedCodexQuotaAutoRefreshWarning(rawParsed, validated);
2147
- if (warning) console.warn(`⚠️ config.json ${warning}`);
2148
- }
2149
-
2150
- /**
2151
- * The apiKeys schema salvages entry by entry rather than failing the parse, so a
2152
- * dropped key is otherwise invisible — and it will not be re-saved by the next
2153
- * mutation. Say so out loud. Compares the raw array against the validated one,
2154
- * the same shape as the degrade warnings above.
2155
- */
2156
- /** One definition of "usable secret", shared by the schema and the warnings. */
2157
- function isUsableApiKeySecret(value: unknown): value is string {
2158
- return typeof value === "string" && value.length > 0 && value === value.trim();
2159
- }
2160
-
2161
- /**
2162
- * Give every salvaged key a stable, targetable id.
2163
- *
2164
- * Pure and deterministic on purpose. Two earlier spellings were wrong: minting a
2165
- * UUID inside the schema transform handed out a different id on every parse, and
2166
- * repairing-then-writing during `loadConfig` put a file write on the read path,
2167
- * where it could clobber a concurrent legitimate save with a stale snapshot.
2168
- *
2169
- * So the replacement id is derived from the entry's position, which is already
2170
- * how the file orders these rows: same file in, same ids out, no I/O and no
2171
- * randomness. It is not derived from the secret — a public identifier should
2172
- * never be a function of key material.
2173
- */
2174
- function normalizeApiKeyIds(config: OcxConfig): OcxConfig {
2175
- const keys = config.apiKeys;
2176
- if (!keys?.length) return config;
2177
- // Reserve every explicit id BEFORE synthesizing any, or a synthetic
2178
- // `salvaged-1` assigned to row 1 would push a row that legitimately owns that
2179
- // id onto `salvaged-2`. An id the user already has is the one thing this
2180
- // repair must never take away.
2181
- const reserved = new Set<string>();
2182
- for (const entry of keys) {
2183
- if (entry.id) reserved.add(entry.id);
2184
- }
2185
- const taken = new Set<string>(reserved);
2186
- const kept = new Set<string>();
2187
- keys.forEach((entry, index) => {
2188
- // The first row holding an explicit id keeps it; later collisions are the
2189
- // ones that move.
2190
- if (entry.id && !kept.has(entry.id)) {
2191
- kept.add(entry.id);
2192
- return;
2193
- }
2194
- let candidate = `salvaged-${index + 1}`;
2195
- let suffix = 1;
2196
- while (taken.has(candidate)) candidate = `salvaged-${index + 1}-${++suffix}`;
2197
- entry.id = candidate;
2198
- taken.add(candidate);
2199
- kept.add(candidate);
2200
- });
2201
- return config;
2202
- }
2203
-
2204
- function warnDegradedApiKeys(rawParsed: unknown, validated: OcxConfig): void {
2205
- if (!rawParsed || typeof rawParsed !== "object") return;
2206
- const raw = (rawParsed as Record<string, unknown>).apiKeys;
2207
- if (raw === undefined) return;
2208
- if (!Array.isArray(raw)) {
2209
- console.warn(`⚠️ config.json apiKeys is not an array — ignoring it; generate a new key from the API tab`);
2210
- return;
2211
- }
2212
- const dropped = raw.length - (validated.apiKeys?.length ?? 0);
2213
- if (dropped > 0) {
2214
- console.warn(`⚠️ config.json apiKeys: skipped ${dropped} malformed entr${dropped === 1 ? "y" : "ies"} — the remaining keys still work`);
2215
- }
2216
- // Same-length repairs are invisible to the count above, and they are the ones
2217
- // that show up as a blank name or an unknown date in the dashboard. Say so.
2218
- const repaired = raw.filter(row => {
2219
- if (!row || typeof row !== "object") return false;
2220
- const entry = row as Record<string, unknown>;
2221
- // Must match the schema exactly: a row whose key is unusable was DROPPED, and
2222
- // saying "the key still works" about it would be a lie.
2223
- if (!isUsableApiKeySecret(entry.key)) return false;
2224
- return typeof entry.id !== "string" || !entry.id
2225
- || typeof entry.name !== "string"
2226
- || typeof entry.createdAt !== "string";
2227
- }).length;
2228
- if (repaired > 0) {
2229
- console.warn(`⚠️ config.json apiKeys: repaired metadata on ${repaired} entr${repaired === 1 ? "y" : "ies"} — the key still works, but its name or date may read as unknown`);
2230
- }
2231
- // A duplicate id is repaired too, and it is not visible in either count above.
2232
- const ids = raw.filter(row => row && typeof row === "object" && isUsableApiKeySecret((row as Record<string, unknown>).key))
2233
- .map(row => (row as Record<string, unknown>).id)
2234
- .filter((id): id is string => typeof id === "string" && !!id);
2235
- const duplicates = ids.length - new Set(ids).size;
2236
- if (duplicates > 0) {
2237
- console.warn(`⚠️ config.json apiKeys: ${duplicates} entr${duplicates === 1 ? "y" : "ies"} shared an id — reassigned so each key can be renamed and revoked on its own`);
2238
- }
2239
- }
2240
-
2241
- const CLAUDE_SUBAGENT_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
2242
-
2243
- function isClaudeSubagentEffort(value: unknown): value is NonNullable<OcxClaudeCodeConfig["subagentEffort"]> {
2244
- return typeof value === "string" && CLAUDE_SUBAGENT_EFFORTS.includes(value as typeof CLAUDE_SUBAGENT_EFFORTS[number]);
2245
- }
2246
-
2247
- function rawClaudeSubagentEffort(rawParsed: unknown): unknown {
2248
- const raw = rawConfigRecord(rawParsed);
2249
- const claudeCode = raw?.claudeCode;
2250
- if (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode)) return undefined;
2251
- return (claudeCode as Record<string, unknown>).subagentEffort;
2252
- }
2253
-
2254
- function normalizePersistedClaudeCode(claudeCode: unknown): OcxConfig["claudeCode"] {
2255
- if (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode)) {
2256
- return claudeCode as OcxConfig["claudeCode"];
2257
- }
2258
- const normalized = { ...claudeCode } as Record<string, unknown>;
2259
- if (Object.hasOwn(normalized, "subagentEffort") && !isClaudeSubagentEffort(normalized.subagentEffort)) {
2260
- delete normalized.subagentEffort;
2261
- }
2262
- // A hand-authored config never passes through the management validator, so coerce here too.
2263
- // A malformed classifierFallbacks (a bare string, or an array with non-string entries) would
2264
- // otherwise reach the resolver unchecked.
2265
- if (Object.hasOwn(normalized, "classifierModel")) {
2266
- const value = typeof normalized.classifierModel === "string" ? normalized.classifierModel.trim() : "";
2267
- if (value.length > 0) normalized.classifierModel = value;
2268
- else delete normalized.classifierModel;
2269
- }
2270
- if (Object.hasOwn(normalized, "classifierFallbacks")) {
2271
- const raw = normalized.classifierFallbacks;
2272
- const kept = Array.isArray(raw)
2273
- ? raw.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map(entry => entry.trim())
2274
- : [];
2275
- if (kept.length > 0) normalized.classifierFallbacks = kept;
2276
- else delete normalized.classifierFallbacks;
2277
- }
2278
- const desktopProfile = normalized.desktopProfile;
2279
- if (desktopProfile && typeof desktopProfile === "object" && !Array.isArray(desktopProfile)) {
2280
- const profile = { ...desktopProfile } as Record<string, unknown>;
2281
- if (typeof profile.appliedFingerprint !== "string") delete profile.appliedFingerprint;
2282
- if (typeof profile.appliedAt !== "string") delete profile.appliedAt;
2283
- normalized.desktopProfile = profile;
2284
- }
2285
- return normalized as OcxConfig["claudeCode"];
2286
- }
2287
-
2288
- function normalizeClaudeSubagentEffort(config: OcxConfig, _rawParsed: unknown): OcxConfig {
2289
- // Unconditional. This used to short-circuit when `subagentEffort` was absent or already valid,
2290
- // which meant a config whose ONLY defect was elsewhere in `claudeCode` was never normalized.
2291
- // The specialized subagentEffort WARNING is a separate concern and stays exactly as it is.
2292
- if (!config.claudeCode) return config;
2293
- return { ...config, claudeCode: normalizePersistedClaudeCode(config.claudeCode) };
2294
- }
2295
-
2296
- function warnDegradedClaudeSubagentEffort(rawParsed: unknown): void {
2297
- const rawEffort = rawClaudeSubagentEffort(rawParsed);
2298
- if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
2299
- console.warn(`⚠️ config.json claudeCode.subagentEffort is invalid (expected ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}) — ignoring it. Other settings were preserved.`);
2300
- }
2301
- }
2302
-
2303
- function malformedUpstreamHostCircuitThresholdWarning(rawParsed: unknown): string | null {
2304
- const raw = rawConfigRecord(rawParsed);
2305
- if (!raw || !Object.hasOwn(raw, "upstreamHostCircuitThreshold")) return null;
2306
- const threshold = raw.upstreamHostCircuitThreshold;
2307
- if (threshold === undefined) return null;
2308
- if (typeof threshold === "number"
2309
- && Number.isInteger(threshold)
2310
- && threshold >= 0
2311
- && threshold <= UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) return null;
2312
- return `upstreamHostCircuitThreshold ignored: expected an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`;
2313
- }
2314
-
2315
- function warnDegradedUpstreamHostCircuitThreshold(rawParsed: unknown): void {
2316
- const warning = malformedUpstreamHostCircuitThresholdWarning(rawParsed);
2317
- if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
2318
- }
2319
-
2320
- function malformedPlaintextV2AgentMessagesWarning(value: unknown): string | null {
2321
- const raw = rawConfigRecord(value);
2322
- if (!raw || raw.plaintextV2AgentMessages === undefined || typeof raw.plaintextV2AgentMessages === "boolean") return null;
2323
- return "plaintextV2AgentMessages ignored: expected a boolean";
2324
- }
2325
-
2326
- function warnDegradedPlaintextV2AgentMessages(value: unknown): void {
2327
- const warning = malformedPlaintextV2AgentMessagesWarning(value);
2328
- if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
2329
- }
2330
-
2331
- function malformedAgentTaskRecoveryWarning(rawParsed: unknown): string | null {
2332
- const raw = rawConfigRecord(rawParsed);
2333
- if (!raw || !Object.hasOwn(raw, "agentTaskRecovery")) return null;
2334
- const result = agentTaskRecoverySchema.safeParse(raw.agentTaskRecovery);
2335
- if (result.success) return null;
2336
- const field = result.error.issues[0]?.path.join(".");
2337
- return `agentTaskRecovery${field ? `.${field}` : ""} ignored: invalid experimental recovery configuration`;
2338
- }
2339
-
2340
- function warnDegradedAgentTaskRecovery(rawParsed: unknown): void {
2341
- const warning = malformedAgentTaskRecoveryWarning(rawParsed);
2342
- if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
2343
- }
2344
-
2345
- function malformedRuntimeRoleWarning(rawParsed: unknown): string | null {
2346
- const raw = rawConfigRecord(rawParsed);
2347
- if (!raw || !Object.hasOwn(raw, "runtimeRole") || raw.runtimeRole === undefined) return null;
2348
- if (runtimeRoleSchema.safeParse(raw.runtimeRole).success) return null;
2349
- return 'runtimeRole ignored: expected "standalone", "hub", or "client"; falling back to "standalone"';
2350
- }
2351
-
2352
- function warnDegradedRuntimeRole(rawParsed: unknown): void {
2353
- const warning = malformedRuntimeRoleWarning(rawParsed);
2354
- if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
2355
- }
2356
-
2357
- function malformedOptionalRemoteBlockWarning(
2358
- rawParsed: unknown,
2359
- key: "hub" | "remoteGui",
2360
- ): string | null {
2361
- const raw = rawConfigRecord(rawParsed);
2362
- if (!raw || !Object.hasOwn(raw, key) || raw[key] === undefined) return null;
2363
- const schema = key === "hub" ? hubConfigSchema : remoteGuiConfigSchema;
2364
- const result = schema.safeParse(raw[key]);
2365
- if (result.success) return null;
2366
- const field = result.error.issues[0]?.path.join(".");
2367
- return `${key}${field ? `.${field}` : ""} ignored: invalid remote GUI configuration`;
2368
- }
2369
-
2370
- function malformedClientConnectionWarning(rawParsed: unknown): string | null {
2371
- const raw = rawConfigRecord(rawParsed);
2372
- if (!raw || !Object.hasOwn(raw, "client") || raw.client === undefined) return null;
2373
- const result = clientConnectionSchema.safeParse(raw.client);
2374
- if (result.success) return null;
2375
- const field = result.error.issues[0]?.path.join(".");
2376
- return `client${field ? `.${field}` : ""} invalid: remote client mode is disabled until config.json is repaired`;
2377
- }
2378
-
2379
- function warnDegradedOptionalRemoteBlocks(rawParsed: unknown): void {
2380
- for (const key of ["hub", "remoteGui"] as const) {
2381
- const warning = malformedOptionalRemoteBlockWarning(rawParsed, key);
2382
- if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
2383
- }
2384
- }
2385
-
2386
- function malformedQuotaResetNotifyWarning(rawParsed: unknown): string | null {
2387
- const raw = rawConfigRecord(rawParsed);
2388
- if (!raw || !Object.hasOwn(raw, "quotaResetNotify")) return null;
2389
- const result = quotaResetNotifySchema.safeParse(raw.quotaResetNotify);
2390
- if (result.success) return null;
2391
- const field = result.error.issues[0]?.path.join(".");
2392
- return `quotaResetNotify${field ? `.${field}` : ""} ignored: invalid quota-reset notification configuration`;
2393
- }
2394
-
2395
- function malformedCatalogAutoRefreshWarning(rawParsed: unknown): string | null {
2396
- const raw = rawConfigRecord(rawParsed);
2397
- if (!raw || !Object.hasOwn(raw, "catalogAutoRefresh")) return null;
2398
- const result = catalogAutoRefreshSchema.safeParse(raw.catalogAutoRefresh);
2399
- if (result.success) return null;
2400
- const field = result.error.issues[0]?.path.join(".");
2401
- return `catalogAutoRefresh${field ? `.${field}` : ""} ignored: invalid catalog auto-refresh configuration`;
2402
- }
2403
-
2404
- /**
2405
- * Same silent-in-the-wrong-direction failure as the notification block: a dropped pool policy means
2406
- * the accounts the operator meant to exclude keep taking traffic, and the only visible symptom is
2407
- * traffic going somewhere it was supposed to stop going.
2408
- */
2409
- function malformedCodexPoolWarning(rawParsed: unknown): string | null {
2410
- const raw = rawConfigRecord(rawParsed);
2411
- if (!raw || !Object.hasOwn(raw, "codexPool")) return null;
2412
- const result = codexPoolSchema.safeParse(raw.codexPool);
2413
- if (result.success) return null;
2414
- const field = result.error.issues[0]?.path.join(".");
2415
- return `codexPool${field ? `.${field}` : ""} ignored: invalid Codex pool selection policy`;
2416
- }
2417
-
2418
- /**
2419
- * Warn once per load that the section was dropped.
2420
- *
2421
- * This matters more than a usual degradation notice: the failure is SILENT in the direction
2422
- * that hurts. A dropped section means notifications are off, so the operator sees nothing —
2423
- * which is exactly what they would see if the feature were working and no reset had happened.
2424
- */
2425
- function warnDegradedQuotaResetNotify(rawParsed: unknown): void {
2426
- const warning = malformedQuotaResetNotifyWarning(rawParsed);
2427
- if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
2428
- }
2429
-
2430
- /**
2431
- * Warn once per load that the section was dropped.
2432
- *
2433
- * Same silent-in-the-wrong-direction failure as the notification block: a dropped section
2434
- * means the scheduler never starts, so the operator sees a stale catalog — which is exactly
2435
- * what they would see if the feature were working and no new models had shipped.
2436
- */
2437
- function warnDegradedCatalogAutoRefresh(rawParsed: unknown): void {
2438
- const warning = malformedCatalogAutoRefreshWarning(rawParsed);
2439
- if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
2440
- }
2441
-
2442
- /**
2443
- * Warn once per load that the pool policy was dropped.
2444
- *
2445
- * `.catch(undefined)` turns a malformed policy into a SUCCESSFUL parse, so without this the proxy
2446
- * starts, rotates onto the accounts the operator meant to exclude, and prints nothing. The visible
2447
- * symptom would be traffic going exactly where it was told not to go.
2448
- */
2449
- function warnDegradedCodexPool(rawParsed: unknown): void {
2450
- const warning = malformedCodexPoolWarning(rawParsed);
2451
- if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
2452
- }
2453
-
2454
- type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults";
2455
-
2456
- function rawConfigRecord(rawParsed: unknown): Record<string, unknown> | null {
2457
- return rawParsed !== null && typeof rawParsed === "object" && !Array.isArray(rawParsed)
2458
- ? rawParsed as Record<string, unknown>
2459
- : null;
2460
- }
2461
-
2462
- function malformedNativeSubagentFields(rawParsed: unknown): NativeSubagentPersistedField[] {
2463
- const raw = rawConfigRecord(rawParsed);
2464
- if (!raw) return [];
2465
- const malformed: NativeSubagentPersistedField[] = [];
2466
- if (Object.hasOwn(raw, "injectionModel") && typeof raw.injectionModel !== "string") {
2467
- malformed.push("injectionModel");
2468
- }
2469
- if (Object.hasOwn(raw, "injectionEffort") && typeof raw.injectionEffort !== "string") {
2470
- malformed.push("injectionEffort");
2471
- }
2472
- if (Object.hasOwn(raw, "syncCodexSubagentDefaults") && typeof raw.syncCodexSubagentDefaults !== "boolean") {
2473
- malformed.push("syncCodexSubagentDefaults");
2474
- }
2475
- return malformed;
2476
- }
2477
-
2478
- function malformedNativeSubagentFieldWarning(field: NativeSubagentPersistedField): string {
2479
- const expected = field === "syncCodexSubagentDefaults" ? "a boolean" : "a string";
2480
- return `${field} ignored: expected ${expected}`;
2481
- }
2482
-
2483
- function malformedCodexAccountPickerWarning(rawParsed: unknown): string | null {
2484
- const raw = rawConfigRecord(rawParsed);
2485
- if (!raw || !Object.hasOwn(raw, "codexAccountPickerEnabled")) return null;
2486
- if (typeof raw.codexAccountPickerEnabled === "boolean") return null;
2487
- return "codexAccountPickerEnabled ignored: expected a boolean";
2488
- }
2489
-
2490
- function warnDegradedCodexAccountPicker(rawParsed: unknown): void {
2491
- const warning = malformedCodexAccountPickerWarning(rawParsed);
2492
- if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`);
2493
- }
2494
-
2495
- function nativeSubagentSyncDisabledReason(config: OcxConfig, rawParsed?: unknown): string | null {
2496
- if (config.syncCodexSubagentDefaults !== true) return null;
2497
- const malformed = malformedNativeSubagentFields(rawParsed);
2498
- if (malformed.includes("injectionModel")) return "injectionModel must be a string";
2499
- if (!config.injectionModel?.trim()) return "a nonblank injectionModel is required";
2500
- if (malformed.includes("injectionEffort")) return "injectionEffort must be a string or omitted";
2501
- if (config.injectionEffort !== undefined && !isCodexReasoningEffort(config.injectionEffort)) {
2502
- return "injectionEffort must be a supported Codex reasoning effort";
2503
- }
2504
- return null;
2505
- }
2506
-
2507
- function normalizeNativeSubagentSync(config: OcxConfig, rawParsed?: unknown): OcxConfig {
2508
- if (!nativeSubagentSyncDisabledReason(config, rawParsed)) return config;
2509
- const normalized = { ...config };
2510
- delete normalized.syncCodexSubagentDefaults;
2511
- return normalized;
2512
- }
2513
-
2514
- function warnDegradedNativeSubagentConfig(rawParsed: unknown, config: OcxConfig): void {
2515
- for (const field of malformedNativeSubagentFields(rawParsed)) {
2516
- console.warn(`⚠️ config.json ${malformedNativeSubagentFieldWarning(field)}. Other settings were preserved.`);
2517
- }
2518
- const reason = nativeSubagentSyncDisabledReason(config, rawParsed);
2519
- if (reason) {
2520
- console.warn(`⚠️ config.json syncCodexSubagentDefaults was disabled: ${reason}. Other settings were preserved.`);
2521
- }
2522
- }
2523
-
2524
- /**
2525
- * Registry metadata can gain service-tier capability after a config was written. An explicit
2526
- * `fastWire: null` remains authoritative on load and on whole-document writes; rejecting either
2527
- * would discard or lock access to unrelated providers and API keys. Direct contradictions within
2528
- * one provider row remain schema errors through the outer config refinement, where the dynamic
2529
- * provider name can be redacted before it reaches diagnostics.
2530
- */
2531
- function inheritedFastWireConflictProviderNames(
2532
- config: Pick<OcxConfig, "providers">,
2533
- ): string[] {
2534
- const conflicts: string[] = [];
2535
- for (const [name, provider] of Object.entries(config.providers)) {
2536
- if (provider.fastWire !== null || provider.supportsServiceTier === false) continue;
2537
- const registry = providerMatchesRegistryTransport(name, provider)
2538
- ? getProviderRegistryEntry(name)
2539
- : undefined;
2540
- if (!registry) continue;
2541
- const effectiveProviderCapability = provider.supportsServiceTier ?? registry.supportsServiceTier;
2542
- const effectiveModelCapabilities = {
2543
- ...(registryModelServiceTierCapabilityApplies(registry, provider)
2544
- ? registry.modelSupportsServiceTier ?? {}
2545
- : {}),
2546
- ...(provider.modelSupportsServiceTier ?? {}),
2547
- };
2548
- if (
2549
- effectiveProviderCapability === true
2550
- || Object.values(effectiveModelCapabilities).some(value => value === true)
2551
- ) {
2552
- conflicts.push(name);
2553
- }
2554
- }
2555
- return conflicts;
2556
- }
2557
-
2558
- function inheritedFastWireConflictWarning(name: string): string {
2559
- return `providers.${redactSecretString(name)}.fastWire=null overrides service-tier capability inherited from the matching registry entry`;
2560
- }
2561
-
2562
- function warnInheritedFastWireConflicts(configPath: string, config: OcxConfig): void {
2563
- const names = inheritedFastWireConflictProviderNames(config);
2564
- if (names.length === 0 || warnedInheritedFastWireConflicts.has(configPath)) return;
2565
- warnedInheritedFastWireConflicts.add(configPath);
2566
- console.warn(
2567
- `⚠️ config.json ${names.map(inheritedFastWireConflictWarning).join("; ")}. `
2568
- + "The persisted providers and API keys were preserved.",
2569
- );
2570
- }
2571
-
2572
- /**
2573
- * Load and validate config.json into an OcxConfig. Missing files reset to
2574
- * defaults and clear stale overlays. Broken existing files also fall back to
2575
- * default routing (after backup), but keep the last-good cost-overlay registry
2576
- * until a valid config or a genuinely missing file is observed. A partially-
2577
- * invalid config is merged with defaults so providers and pool accounts survive.
2578
- */
2579
- export function loadConfig(): OcxConfig {
2580
- const dir = getConfigDir();
2581
- const configPath = getConfigPath();
2582
- hardenConfigDir();
2583
- hardenExistingSecret(configPath);
2584
- hardenExistingSecret(join(dir, "auth.json"));
2585
- if (!existsSync(configPath)) {
2586
- return withRefreshedCostOverlays(getDefaultConfig());
2587
- }
2588
- try {
2589
- const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
2590
- const parsed = JSON.parse(raw);
2591
- sanitizeAliasesForLoad(parsed);
2592
- sanitizeReasoningPinsForLoad(parsed);
2593
- sanitizeModelDisplayNamesForLoad(parsed);
2594
- sanitizeAutoReviewForLoad(parsed);
2595
- sanitizeRetryOn429ForLoad(parsed);
2596
- sanitizeModelCostsForLoad(parsed);
2597
- sanitizeCapabilityDeclarationsForLoad(parsed);
2598
- const result = configSchema.safeParse(parsed);
2599
- if (result.success) {
2600
- const config = normalizeApiKeyIds(result.data as OcxConfig);
2601
- warnInheritedFastWireConflicts(configPath, config);
2602
- warnDegradedStreamMode(parsed, config);
2603
- warnDegradedHostname(parsed, config);
2604
- warnDegradedListeners(parsed, config);
2605
- warnDegradedApiKeys(parsed, config);
2606
- warnDegradedCodexAccountPriorities(parsed, config);
2607
- warnDegradedCodexQuotaAutoRefresh(parsed, config);
2608
- warnDegradedClaudeSubagentEffort(parsed);
2609
- warnDegradedNativeSubagentConfig(parsed, config);
2610
- warnDegradedCodexAccountPicker(parsed);
2611
- warnDegradedUpstreamHostCircuitThreshold(parsed);
2612
- warnDegradedPlaintextV2AgentMessages(parsed);
2613
- warnDegradedAgentTaskRecovery(parsed);
2614
- warnDegradedRuntimeRole(parsed);
2615
- warnDegradedOptionalRemoteBlocks(parsed);
2616
- warnDegradedQuotaResetNotify(parsed);
2617
- warnDegradedCatalogAutoRefresh(parsed);
2618
- warnDegradedCodexPool(parsed);
2619
- return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
2620
- }
2621
- // Schema validation failed — merge defaults into the raw object instead of
2622
- // discarding it entirely, so pool accounts and providers survive a missing
2623
- // field like defaultProvider.
2624
- const defaults = getDefaultConfig();
2625
- // Pin the keys whose ABSENCE is meaningful. Spreading defaults underneath means any
2626
- // key the stored document lacks is inherited, which is right for additive defaults and
2627
- // wrong for a behavioral mode: a config that reaches this path only because it lost
2628
- // `defaultProvider` would be repaired into v1 sub-agents and a pre-answered advisory,
2629
- // silently changing a setting its operator never touched.
2630
- const merged = {
2631
- ...defaults,
2632
- ...parsed,
2633
- subagentModelsVersion: parsed.subagentModelsVersion,
2634
- multiAgentMode: parsed.multiAgentMode,
2635
- multiAgentSurfaceAdvisoryVersion: parsed.multiAgentSurfaceAdvisoryVersion,
2636
- };
2637
- // Ensure providers from both sides survive
2638
- if (parsed.providers && defaults.providers) {
2639
- merged.providers = { ...defaults.providers, ...parsed.providers };
2640
- }
2641
- const retryResult = configSchema.safeParse(merged);
2642
- if (retryResult.success) {
2643
- warnConfigRepaired(configPath, result.error);
2644
- const config = normalizeApiKeyIds(retryResult.data as OcxConfig);
2645
- warnInheritedFastWireConflicts(configPath, config);
2646
- warnDegradedHostname(parsed, config);
2647
- warnDegradedListeners(parsed, config);
2648
- warnDegradedApiKeys(parsed, config);
2649
- warnDegradedCodexAccountPriorities(parsed, config);
2650
- warnDegradedCodexQuotaAutoRefresh(parsed, config);
2651
- warnDegradedClaudeSubagentEffort(parsed);
2652
- warnDegradedNativeSubagentConfig(parsed, config);
2653
- warnDegradedCodexAccountPicker(parsed);
2654
- warnDegradedUpstreamHostCircuitThreshold(parsed);
2655
- warnDegradedPlaintextV2AgentMessages(parsed);
2656
- warnDegradedAgentTaskRecovery(parsed);
2657
- warnDegradedRuntimeRole(parsed);
2658
- warnDegradedOptionalRemoteBlocks(parsed);
2659
- warnDegradedQuotaResetNotify(parsed);
2660
- warnDegradedCatalogAutoRefresh(parsed);
2661
- warnDegradedCodexPool(parsed);
2662
- return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
2663
- }
2664
- // Still failing, but if every complaint is about one or more named entries
2665
- // in an independent section, drop exactly those and keep the rest. Falling
2666
- // back to defaults here would silently retire the operator's providers,
2667
- // keys and prices over a mistake in one routing profile.
2668
- const salvaged = salvageConfigCandidate(merged, retryResult.error);
2669
- if (salvaged) {
2670
- {
2671
- warnDroppedConfigSections(configPath, salvaged.dropped, salvaged.issues);
2672
- const config = normalizeApiKeyIds(salvaged.parsed);
2673
- warnInheritedFastWireConflicts(configPath, config);
2674
- warnDegradedHostname(parsed, config);
2675
- warnDegradedListeners(parsed, config);
2676
- warnDegradedApiKeys(parsed, config);
2677
- warnDegradedCodexAccountPriorities(parsed, config);
2678
- warnDegradedCodexQuotaAutoRefresh(parsed, config);
2679
- warnDegradedClaudeSubagentEffort(parsed);
2680
- warnDegradedNativeSubagentConfig(parsed, config);
2681
- warnDegradedCodexAccountPicker(parsed);
2682
- warnDegradedUpstreamHostCircuitThreshold(parsed);
2683
- warnDegradedPlaintextV2AgentMessages(parsed);
2684
- warnDegradedAgentTaskRecovery(parsed);
2685
- warnDegradedRuntimeRole(parsed);
2686
- warnDegradedOptionalRemoteBlocks(parsed);
2687
- warnDegradedQuotaResetNotify(parsed);
2688
- warnDegradedCatalogAutoRefresh(parsed);
2689
- warnDegradedCodexPool(parsed);
2690
- return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
2691
- }
2692
- }
2693
- // Merge couldn't fix it — truly broken config
2694
- warnAndBackupInvalidConfig(configPath, result.error);
2695
- return getDefaultConfig();
2696
- } catch (error) {
2697
- warnAndBackupInvalidConfig(configPath, error);
2698
- return getDefaultConfig();
2699
- }
2700
- }
2701
-
2702
- /** Hand-edited alias mistakes disable only the bad alias; providers and routing survive. */
2703
- function sanitizeAliasesForLoad(raw: unknown): void {
2704
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
2705
- const root = raw as Record<string, unknown>;
2706
- if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) return;
2707
- const providers = root.providers as Record<string, Record<string, unknown>>;
2708
- const providerNames = new Set(Object.keys(providers).map(name => name.toLowerCase()));
2709
- const claimedProviders = new Set<string>();
2710
- const comboAliases = new Set(Object.values((root.combos as Record<string, { alias?: unknown }> | undefined) ?? {})
2711
- .map(combo => typeof combo?.alias === "string" ? combo.alias.toLowerCase() : "").filter(Boolean));
2712
- const accountNamespaces = new Set(Object.keys((root.codexAccountNamespaces as Record<string, unknown> | undefined) ?? {}).map(name => name.toLowerCase()));
2713
- for (const provider of Object.values(providers)) {
2714
- const alias = provider.alias;
2715
- if (typeof alias !== "string" || !isValidProviderName(alias)
2716
- || providerNames.has(alias.toLowerCase()) || claimedProviders.has(alias.toLowerCase())
2717
- || comboAliases.has(alias.toLowerCase()) || accountNamespaces.has(alias.toLowerCase())) {
2718
- if (alias !== undefined) console.warn("Ignoring invalid or colliding provider alias in config.json");
2719
- delete provider.alias;
2720
- } else claimedProviders.add(alias.toLowerCase());
2721
- if (!provider.modelAliases || typeof provider.modelAliases !== "object" || Array.isArray(provider.modelAliases)) {
2722
- if (provider.modelAliases !== undefined) delete provider.modelAliases;
2723
- continue;
2724
- }
2725
- const aliases = provider.modelAliases as Record<string, unknown>;
2726
- const nativeIds = new Set((Array.isArray(provider.models) ? provider.models : []).filter((id): id is string => typeof id === "string").map(id => id.toLowerCase()));
2727
- const claimed = new Set<string>();
2728
- for (const [id, value] of Object.entries(aliases)) {
2729
- const lower = typeof value === "string" ? value.toLowerCase() : "";
2730
- if (typeof value !== "string" || !MODEL_ALIAS_PATTERN.test(value) || claimed.has(lower)
2731
- || nativeIds.has(lower) || comboAliases.has(lower) || /^(?:gpt-|o1-|o3-|o4-|codex-)/i.test(value)) {
2732
- console.warn(`Ignoring invalid or colliding model alias for ${id} in config.json`);
2733
- delete aliases[id];
2734
- } else claimed.add(lower);
2735
- }
2736
- }
2737
- }
2738
-
2739
- /** Hand-edited display-name mistakes disable only the bad label. */
2740
- function sanitizeModelDisplayNamesForLoad(raw: unknown): void {
2741
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
2742
- const root = raw as Record<string, unknown>;
2743
- if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) return;
2744
- for (const [providerName, providerValue] of Object.entries(root.providers as Record<string, unknown>)) {
2745
- if (!providerValue || typeof providerValue !== "object" || Array.isArray(providerValue)) continue;
2746
- const provider = providerValue as Record<string, unknown>;
2747
- const value = provider.modelDisplayNames;
2748
- if (value === undefined) continue;
2749
- const providerLabel = JSON.stringify(redactSecretString(providerName));
2750
- if (!value || typeof value !== "object" || Array.isArray(value)
2751
- || Object.entries(value).length > MODEL_DISCOVERY_MAX_MODELS) {
2752
- console.warn(`Ignoring invalid modelDisplayNames map for provider ${providerLabel} in config.json`);
2753
- delete provider.modelDisplayNames;
2754
- continue;
2755
- }
2756
- const labels = value as Record<string, unknown>;
2757
- for (const [modelId, rawDisplayName] of Object.entries(labels)) {
2758
- const displayName = typeof rawDisplayName === "string" ? rawDisplayName.trim() : rawDisplayName;
2759
- if (modelDisplayNamesConfigError({ [modelId]: displayName })) {
2760
- const safeModelId = JSON.stringify(redactSecretString(modelId));
2761
- console.warn(`Ignoring invalid modelDisplayNames entry ${safeModelId} for provider ${providerLabel} in config.json`);
2762
- delete labels[modelId];
2763
- } else {
2764
- labels[modelId] = displayName;
2765
- }
2766
- }
2767
- if (Object.keys(labels).length === 0) delete provider.modelDisplayNames;
2768
- }
2769
- }
2770
-
2771
- /** Refresh the user cost-overlay registry from `config` and return it unchanged. */
2772
- function withRefreshedCostOverlays(config: OcxConfig): OcxConfig {
2773
- refreshUserCostOverlays(config);
2774
- return config;
2775
- }
2776
-
2777
- export type ConfigDiagnostics = {
2778
- config: OcxConfig;
2779
- source: "default" | "file" | "fallback";
2780
- error: string | null;
2781
- /** Non-fatal config concerns; absent when there are no warnings. */
2782
- warnings?: string[];
2783
- };
2784
-
2785
- type ConfigFileSnapshot = {
2786
- diagnostics: ConfigDiagnostics;
2787
- /** Exact file contents, including a possible BOM, used as the optimistic revision. */
2788
- raw?: string;
2789
- };
2790
-
2791
- function configPlaceholderWarnings(config: OcxConfig): string[] {
2792
- const warnings: string[] = [];
2793
- for (const [name, provider] of Object.entries(config.providers)) {
2794
- const placeholder = provider.baseUrl.match(/\{[^}]*\}/)?.[0];
2795
- if (placeholder) {
2796
- warnings.push(`providers.${name}.baseUrl contains unresolved ${placeholder}; set the real provider URL`);
2797
- }
2798
- }
2799
- return warnings;
2800
- }
2801
-
2802
- function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): ConfigDiagnostics {
2803
- // Unsafe hand-edited optional values are disabled in memory instead of rejecting
2804
- // the entire config, which would hide unrelated providers/accounts. The next
2805
- // ordinary save persists the normalized absence.
2806
- const syncDisabledReason = nativeSubagentSyncDisabledReason(config, rawParsed);
2807
- const rawEffort = rawClaudeSubagentEffort(rawParsed);
2808
- const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed);
2809
- const warnings = configPlaceholderWarnings(normalized);
2810
- warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning));
2811
- warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized));
2812
- warnings.push(...degradedListenerWarnings(rawParsed, normalized));
2813
- const quotaAutoRefreshWarning = degradedCodexQuotaAutoRefreshWarning(rawParsed, normalized);
2814
- if (quotaAutoRefreshWarning) warnings.push(quotaAutoRefreshWarning);
2815
- if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
2816
- warnings.push(`claudeCode.subagentEffort ignored: expected one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`);
2817
- }
2818
- warnings.push(...malformedNativeSubagentFields(rawParsed).map(malformedNativeSubagentFieldWarning));
2819
- const pickerWarning = malformedCodexAccountPickerWarning(rawParsed);
2820
- if (pickerWarning) warnings.push(pickerWarning);
2821
- const hostCircuitWarning = malformedUpstreamHostCircuitThresholdWarning(rawParsed);
2822
- if (hostCircuitWarning) warnings.push(hostCircuitWarning);
2823
- const recoveryWarning = malformedAgentTaskRecoveryWarning(rawParsed);
2824
- if (recoveryWarning) warnings.push(recoveryWarning);
2825
- const runtimeRoleWarning = malformedRuntimeRoleWarning(rawParsed);
2826
- if (runtimeRoleWarning) warnings.push(runtimeRoleWarning);
2827
- const hubWarning = malformedOptionalRemoteBlockWarning(rawParsed, "hub");
2828
- if (hubWarning) warnings.push(hubWarning);
2829
- const remoteGuiWarning = malformedOptionalRemoteBlockWarning(rawParsed, "remoteGui");
2830
- if (remoteGuiWarning) warnings.push(remoteGuiWarning);
2831
- const clientWarning = malformedClientConnectionWarning(rawParsed);
2832
- if (clientWarning) warnings.push(clientWarning);
2833
- const notifyWarning = malformedQuotaResetNotifyWarning(rawParsed);
2834
- if (notifyWarning) warnings.push(notifyWarning);
2835
- const catalogRefreshWarning = malformedCatalogAutoRefreshWarning(rawParsed);
2836
- if (catalogRefreshWarning) warnings.push(catalogRefreshWarning);
2837
- const codexPoolWarning = malformedCodexPoolWarning(rawParsed);
2838
- if (codexPoolWarning) warnings.push(codexPoolWarning);
2839
- const plaintextWarning = malformedPlaintextV2AgentMessagesWarning(rawParsed);
2840
- if (plaintextWarning) warnings.push(plaintextWarning);
2841
- if (syncDisabledReason) {
2842
- warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`);
2843
- }
2844
- return {
2845
- config: normalized,
2846
- source: "file",
2847
- error: null,
2848
- ...(warnings.length > 0 ? { warnings } : {}),
2849
- };
2850
- }
2851
-
2852
- export function subagentDefaultSyncEffective(
2853
- config: Pick<OcxConfig, "syncCodexSubagentDefaults" | "injectionModel">,
2854
- ): boolean {
2855
- return config.syncCodexSubagentDefaults === true && Boolean(config.injectionModel?.trim());
2856
- }
2857
-
2858
- function mergeConfigDefaults(parsed: unknown): unknown {
2859
- if (!parsed || typeof parsed !== "object") return parsed;
2860
- const defaults = getDefaultConfig();
2861
- const raw = parsed as Record<string, unknown>;
2862
- // Same absence-is-meaningful pin as the repair merge above.
2863
- const merged: Record<string, unknown> = {
2864
- ...defaults,
2865
- ...raw,
2866
- subagentModelsVersion: raw.subagentModelsVersion,
2867
- multiAgentMode: raw.multiAgentMode,
2868
- multiAgentSurfaceAdvisoryVersion: raw.multiAgentSurfaceAdvisoryVersion,
2869
- };
2870
- if (raw.providers && typeof raw.providers === "object" && defaults.providers) {
2871
- merged.providers = { ...defaults.providers, ...(raw.providers as Record<string, unknown>) };
2872
- }
2873
- return merged;
2874
- }
2875
-
2876
- function schemaDiagnosticsError(error: z.ZodError): string {
2877
- const details = error.issues.map(issue => {
2878
- const path = issue.path.join(".") || "config";
2879
- return `${path}: ${issue.message}`;
2880
- });
2881
- return details.length > 0 ? `schema_invalid: ${details.join("; ")}` : "schema_invalid";
2882
- }
2883
-
2884
- /**
2885
- * Reject a hostname the schema deliberately degrades on read. Load-time has to keep a
2886
- * blank value non-fatal (see the `hostname` field comment), but an incoming write is a
2887
- * live caller who can be told the value is wrong — silently rewriting it to loopback
2888
- * would look like the bind succeeded on the address they asked for.
2889
- */
2890
- function blankHostnameError(value: unknown): string | null {
2891
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
2892
- const hostname = (value as Record<string, unknown>).hostname;
2893
- if (hostname === undefined) return null;
2894
- if (typeof hostname !== "string" || !hostname.trim()) {
2895
- return "schema_invalid: hostname: must be a nonblank bind address";
2896
- }
2897
- return null;
2898
- }
2899
-
2900
- function claudeSubagentEffortError(value: unknown): string | null {
2901
- const effort = rawClaudeSubagentEffort(value);
2902
- if (effort === undefined || isClaudeSubagentEffort(effort)) return null;
2903
- return `schema_invalid: claudeCode.subagentEffort: must be one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`;
2904
- }
2905
-
2906
- function appOwnedMemoryBudgetError(value: unknown): string | null {
2907
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
2908
- const budget = (value as Record<string, unknown>).appOwnedMemoryBudgetMb;
2909
- if (budget === undefined) return null;
2910
- if (typeof budget !== "number" || !Number.isInteger(budget)
2911
- || budget < MIN_APP_OWNED_MEMORY_BUDGET_MB || budget > MAX_APP_OWNED_MEMORY_BUDGET_MB) {
2912
- return `schema_invalid: appOwnedMemoryBudgetMb: must be an integer from ${MIN_APP_OWNED_MEMORY_BUDGET_MB} to ${MAX_APP_OWNED_MEMORY_BUDGET_MB}`;
2913
- }
2914
- return null;
2915
- }
2916
-
2917
- function upstreamHostCircuitThresholdError(value: unknown): string | null {
2918
- const raw = rawConfigRecord(value);
2919
- if (!raw || !Object.hasOwn(raw, "upstreamHostCircuitThreshold")) return null;
2920
- const threshold = raw.upstreamHostCircuitThreshold;
2921
- if (threshold === undefined) return null;
2922
- if (typeof threshold === "number"
2923
- && Number.isInteger(threshold)
2924
- && threshold >= 0
2925
- && threshold <= UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) return null;
2926
- return `schema_invalid: upstreamHostCircuitThreshold: must be an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`;
2927
- }
2928
-
2929
- function plaintextV2AgentMessagesError(value: unknown): string | null {
2930
- return malformedPlaintextV2AgentMessagesWarning(value)
2931
- ? "schema_invalid: plaintextV2AgentMessages: must be a boolean or omitted"
2932
- : null;
2933
- }
2934
-
2935
- function agentTaskRecoveryError(value: unknown): string | null {
2936
- const raw = rawConfigRecord(value);
2937
- if (!raw || !Object.hasOwn(raw, "agentTaskRecovery") || raw.agentTaskRecovery === undefined) return null;
2938
- const result = agentTaskRecoverySchema.safeParse(raw.agentTaskRecovery);
2939
- if (result.success) return null;
2940
- const issue = result.error.issues[0];
2941
- const field = issue?.path.join(".");
2942
- return `schema_invalid: agentTaskRecovery${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
2943
- }
2944
-
2945
- function runtimeRoleError(value: unknown): string | null {
2946
- const raw = rawConfigRecord(value);
2947
- if (!raw || !Object.hasOwn(raw, "runtimeRole") || raw.runtimeRole === undefined) return null;
2948
- if (runtimeRoleSchema.safeParse(raw.runtimeRole).success) return null;
2949
- return 'schema_invalid: runtimeRole: must be one of "standalone", "hub", or "client"';
2950
- }
2951
-
2952
- function remoteGuiConfigError(value: unknown): string | null {
2953
- const raw = rawConfigRecord(value);
2954
- if (!raw) return null;
2955
- for (const [key, schema] of [
2956
- ["hub", hubConfigSchema],
2957
- ["remoteGui", remoteGuiConfigSchema],
2958
- ] as const) {
2959
- if (!Object.hasOwn(raw, key) || raw[key] === undefined) continue;
2960
- const result = schema.safeParse(raw[key]);
2961
- if (result.success) continue;
2962
- const issue = result.error.issues[0];
2963
- const field = issue?.path.join(".");
2964
- return `schema_invalid: ${key}${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
2965
- }
2966
- return null;
2967
- }
2968
-
2969
- function clientConnectionConfigError(value: unknown): string | null {
2970
- const raw = rawConfigRecord(value);
2971
- if (!raw || !Object.hasOwn(raw, "client") || raw.client === undefined) return null;
2972
- const result = clientConnectionSchema.safeParse(raw.client);
2973
- if (result.success) return null;
2974
- const issue = result.error.issues[0];
2975
- const field = issue?.path.join(".");
2976
- return `schema_invalid: client${field ? `.${field}` : ""}: ${issue?.message ?? "invalid client connection"}`;
2977
- }
2978
-
2979
- function clientRolePairError(value: unknown): string | null {
2980
- const raw = rawConfigRecord(value);
2981
- if (!raw) return null;
2982
- const hasClient = Object.hasOwn(raw, "client") && raw.client !== undefined;
2983
- if (raw.runtimeRole === "client" && !hasClient) {
2984
- return "schema_invalid: runtimeRole client requires a complete client connection";
2985
- }
2986
- if (hasClient && raw.runtimeRole !== "client") {
2987
- return "schema_invalid: client connection requires runtimeRole client";
2988
- }
2989
- return null;
2990
- }
2991
-
2992
- function quotaResetNotifyError(value: unknown): string | null {
2993
- const raw = rawConfigRecord(value);
2994
- if (!raw || !Object.hasOwn(raw, "quotaResetNotify") || raw.quotaResetNotify === undefined) return null;
2995
- const result = quotaResetNotifySchema.safeParse(raw.quotaResetNotify);
2996
- if (result.success) return null;
2997
- const issue = result.error.issues[0];
2998
- const field = issue?.path.join(".");
2999
- return `schema_invalid: quotaResetNotify${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
3000
- }
3001
-
3002
- function catalogAutoRefreshError(value: unknown): string | null {
3003
- const raw = rawConfigRecord(value);
3004
- if (!raw || !Object.hasOwn(raw, "catalogAutoRefresh") || raw.catalogAutoRefresh === undefined) return null;
3005
- const result = catalogAutoRefreshSchema.safeParse(raw.catalogAutoRefresh);
3006
- if (result.success) return null;
3007
- const issue = result.error.issues[0];
3008
- const field = issue?.path.join(".");
3009
- return `schema_invalid: catalogAutoRefresh${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
3010
- }
3011
-
3012
- /**
3013
- * The read path degrades a malformed pool policy to undefined, which for an exclusion policy means
3014
- * the excluded accounts quietly keep serving traffic. Reject it on write so `ocx config set` cannot
3015
- * create a policy that looks applied and is not.
3016
- */
3017
- function codexPoolError(value: unknown): string | null {
3018
- const raw = rawConfigRecord(value);
3019
- if (!raw || !Object.hasOwn(raw, "codexPool") || raw.codexPool === undefined) return null;
3020
- const result = codexPoolSchema.safeParse(raw.codexPool);
3021
- if (result.success) return null;
3022
- const issue = result.error.issues[0];
3023
- const field = issue?.path.join(".");
3024
- return `schema_invalid: codexPool${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`;
3025
- }
3026
-
3027
- /**
3028
- * Same reasoning as {@link blankHostnameError}, and more urgent: the read path degrades a
3029
- * malformed selection-order map to undefined, which on a write would drop every entry the
3030
- * user had accumulated and still report success. A load-time degrade leaves the raw map in
3031
- * the file to be repaired by hand; a degraded write erases it. One bad `ocx config set`
3032
- * must not cost the whole map, so a live caller is told instead.
3033
- */
3034
- function codexAccountPrioritiesError(value: unknown): string | null {
3035
- const raw = rawConfigRecord(value);
3036
- if (!raw) return null;
3037
- if (raw.codexAccountPriorities !== undefined) {
3038
- const parsed = codexAccountPrioritiesSchema.safeParse(raw.codexAccountPriorities);
3039
- if (!parsed.success) {
3040
- return schemaDiagnosticsError(parsed.error).replace("schema_invalid: ", "schema_invalid: codexAccountPriorities.");
3041
- }
3042
- }
3043
- // Tested as a string rather than coerced: `String(123)` matches the id pattern, so a
3044
- // coercing guard waves a non-string pin through to the schema, where `.catch(undefined)`
3045
- // drops it and reports the write as a success — the exact silent-degrade this guards.
3046
- const pin = raw.activeCodexAccountPinned;
3047
- if (pin !== undefined && (typeof pin !== "string" || !CODEX_ACCOUNT_PIN_PATTERN.test(pin))) {
3048
- return "schema_invalid: activeCodexAccountPinned: must be an account id";
3049
- }
3050
- return null;
3051
- }
3052
-
3053
- function codexQuotaAutoRefreshError(value: unknown): string | null {
3054
- const raw = rawConfigRecord(value);
3055
- if (!raw || raw.codexQuotaAutoRefresh === undefined) return null;
3056
- const parsed = codexQuotaAutoRefreshSchema.safeParse(raw.codexQuotaAutoRefresh);
3057
- if (parsed.success) return null;
3058
- const details = parsed.error.issues.map(issue => {
3059
- const path = issue.path.join(".");
3060
- const message = path === ""
3061
- ? issue.message.replace(/^codexQuotaAutoRefresh\s*/, "")
3062
- : issue.message;
3063
- return `codexQuotaAutoRefresh${path ? `.${path}` : ""}: ${message}`;
3064
- });
3065
- return `schema_invalid: ${details.join("; ")}`;
3066
- }
3067
-
3068
- function googleAntigravityStaticCatalogVersionError(value: unknown): string | null {
3069
- const raw = rawConfigRecord(value);
3070
- if (!raw || !Object.hasOwn(raw, "googleAntigravityStaticCatalogVersion")) return null;
3071
- const version = raw.googleAntigravityStaticCatalogVersion;
3072
- if (version === undefined || version === 1 || version === 2) return null;
3073
- return "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1, 2, or omitted";
3074
- }
3075
-
3076
- function codexAccountPickerEnabledError(value: unknown): string | null {
3077
- const raw = rawConfigRecord(value);
3078
- if (!raw) return null;
3079
- const descriptor = Object.getOwnPropertyDescriptor(raw, "codexAccountPickerEnabled");
3080
- if (!descriptor) {
3081
- return "codexAccountPickerEnabled" in raw
3082
- ? "schema_invalid: codexAccountPickerEnabled: must be an own boolean data property or omitted"
3083
- : null;
3084
- }
3085
- if (!("value" in descriptor)) {
3086
- return "schema_invalid: codexAccountPickerEnabled: must be an own boolean data property or omitted";
3087
- }
3088
- const enabled = descriptor.value;
3089
- if (enabled === undefined || typeof enabled === "boolean") return null;
3090
- return "schema_invalid: codexAccountPickerEnabled: must be a boolean or omitted";
3091
- }
3092
-
3093
- function emptyCompletionRetryError(value: unknown): string | null {
3094
- const raw = rawConfigRecord(value);
3095
- if (!raw || !Object.hasOwn(raw, "emptyCompletionRetry")) return null;
3096
- const enabled = raw.emptyCompletionRetry;
3097
- if (enabled === undefined || typeof enabled === "boolean") return null;
3098
- return "schema_invalid: emptyCompletionRetry: must be a boolean or omitted";
3099
- }
3100
-
3101
- function dropCodexSafetyBufferingError(value: unknown): string | null {
3102
- const raw = rawConfigRecord(value);
3103
- if (!raw || !Object.hasOwn(raw, "dropCodexSafetyBuffering")) return null;
3104
- const enabled = raw.dropCodexSafetyBuffering;
3105
- if (enabled === undefined || typeof enabled === "boolean") return null;
3106
- return "schema_invalid: dropCodexSafetyBuffering: must be a boolean or omitted";
3107
- }
3108
-
3109
- function oauthOpenBrowserError(value: unknown): string | null {
3110
- const raw = rawConfigRecord(value);
3111
- if (!raw || !Object.hasOwn(raw, "oauthOpenBrowser")) return null;
3112
- const enabled = raw.oauthOpenBrowser;
3113
- if (enabled === undefined || typeof enabled === "boolean") return null;
3114
- return "schema_invalid: oauthOpenBrowser: must be a boolean or omitted";
3115
- }
3116
-
3117
- /** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
3118
- /**
3119
- * Reject a loopback-listener port that collides with the proxy port (#1102), and a port-less
3120
- * companion listener on a bind address that already owns 127.0.0.1 (#4236).
3121
- *
3122
- * The schema can only check the shape of each field on its own; the two ports being distinct —
3123
- * and the port-less form being compatible with `hostname` — are relationships between fields.
3124
- * Letting either through would surface as a startup failure after the public listener already
3125
- * bound, which reads like an unrelated port conflict.
3126
- *
3127
- * Both keys are read from the same candidate, so `ocx config set hostname 127.0.0.1` on a host
3128
- * whose listener is already the companion form is refused by this same check, with the same
3129
- * message, rather than breaking the next start.
3130
- *
3131
- * This is write-time only, matching `blankHostnameError`: a live caller can be told the value
3132
- * is wrong, whereas a hand-edited config on the read path degrades to undefined rather than
3133
- * resetting the whole file. `assertLoopbackListenerBindable` repeats the decision at startup so
3134
- * a hand edit that skipped this boundary fails with the same sentence instead of EADDRINUSE.
3135
- */
3136
- function loopbackListenerPortError(value: unknown): string | null {
3137
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
3138
- const listener = (value as Record<string, unknown>).unauthenticatedLoopbackListener;
3139
- if (listener === undefined) return null;
3140
- if (!listener || typeof listener !== "object" || Array.isArray(listener)) {
3141
- return "schema_invalid: unauthenticatedLoopbackListener: must be an object or omitted";
3142
- }
3143
- const entry = listener as Record<string, unknown>;
3144
- // `enabled` must be a real boolean. The schema's `.catch(undefined)` would otherwise DELETE
3145
- // a `"true"` string entry and report success, leaving an operator convinced they enabled an
3146
- // unauthenticated listener that is in fact off. Load-time still degrades quietly — a hand
3147
- // edit must not reset the file — but a live caller gets told.
3148
- if (typeof entry.enabled !== "boolean") {
3149
- return "schema_invalid: unauthenticatedLoopbackListener.enabled: must be a boolean";
3150
- }
3151
- if (entry.enabled !== true) return null;
3152
- const hostname = typeof (value as Record<string, unknown>).hostname === "string"
3153
- ? (value as Record<string, unknown>).hostname as string
3154
- : undefined;
3155
- const proxyPort = (value as Record<string, unknown>).port;
3156
- const listenerPort = entry.port;
3157
- // The companion form. `port` omitted means "same port as the public listener, on 127.0.0.1",
3158
- // which only exists as a free address when the public listener is bound somewhere else.
3159
- if (listenerPort === undefined) {
3160
- return loopbackCompanionBindError(
3161
- hostname,
3162
- typeof proxyPort === "number" ? proxyPort : 10100,
3163
- );
3164
- }
3165
- if (typeof listenerPort !== "number" || !Number.isInteger(listenerPort) || listenerPort < 1 || listenerPort > 65535) {
3166
- return "schema_invalid: unauthenticatedLoopbackListener.port: must be an integer port when enabled, or omitted to share the proxy port";
3167
- }
3168
- if (typeof proxyPort === "number" && proxyPort === listenerPort) {
3169
- return "schema_invalid: unauthenticatedLoopbackListener.port: must differ from the proxy port";
3170
- }
3171
- return null;
3172
- }
3173
-
3174
- /**
3175
- * The one sentence both the write boundary and startup use for an impossible companion bind.
3176
- *
3177
- * Exported so `startServer` can fail with the identical text: an operator who hand-edited the
3178
- * file past `validateConfigCandidate` must read the same diagnosis, not EADDRINUSE.
3179
- */
3180
- export function loopbackCompanionBindError(
3181
- hostname: string | undefined,
3182
- proxyPort: number,
3183
- ): string | null {
3184
- if (loopbackCompanionAllowed(hostname)) return null;
3185
- const bind = (hostname ?? "").trim() || "127.0.0.1";
3186
- return "schema_invalid: unauthenticatedLoopbackListener: a port-less listener binds "
3187
- + `127.0.0.1:${proxyPort}, which the public listener on hostname "${bind}" already holds. `
3188
- + "Either set a distinct unauthenticatedLoopbackListener.port, or remove the listener — a "
3189
- + "loopback bind already admits local callers without a credential.";
3190
- }
3191
-
3192
- /**
3193
- * Validate the hub management ingress at the live-write boundary.
3194
- *
3195
- * The persisted schema intentionally degrades a malformed hand edit to disabled so a typo in
3196
- * this opt-in listener cannot discard providers or credentials. A live config mutation must not
3197
- * get that leniency: it receives an exact field error before the degrading schema is applied.
3198
- */
3199
- function managementIngressConfigError(value: unknown): string | null {
3200
- const raw = rawConfigRecord(value);
3201
- if (!raw) return null;
3202
- const hub = rawConfigRecord(raw.hub);
3203
- if (!hub || !Object.hasOwn(hub, "managementIngress") || hub.managementIngress === undefined) return null;
3204
- const ingress = rawConfigRecord(hub.managementIngress);
3205
- if (!ingress) {
3206
- return "schema_invalid: hub.managementIngress: must be an object or omitted";
3207
- }
3208
- if (typeof ingress.enabled !== "boolean") {
3209
- return "schema_invalid: hub.managementIngress.enabled: must be a boolean";
3210
- }
3211
- const keys = Object.keys(ingress);
3212
- if (ingress.enabled === false) {
3213
- return keys.length === 1
3214
- ? null
3215
- : "schema_invalid: hub.managementIngress: disabled ingress accepts only enabled";
3216
- }
3217
- if (keys.some(key => key !== "enabled" && key !== "port")) {
3218
- return "schema_invalid: hub.managementIngress: contains an unsupported field";
3219
- }
3220
- const ingressPort = ingress.port;
3221
- if (typeof ingressPort !== "number" || !Number.isInteger(ingressPort) || ingressPort < 1 || ingressPort > 65535) {
3222
- return "schema_invalid: hub.managementIngress.port: must be an integer port when enabled";
3223
- }
3224
- if (raw.runtimeRole !== "hub") {
3225
- return "schema_invalid: hub.managementIngress: enabled ingress requires runtimeRole hub";
3226
- }
3227
- const proxyPort = typeof raw.port === "number" ? raw.port : 10100;
3228
- if (proxyPort === ingressPort) {
3229
- return "schema_invalid: hub.managementIngress.port: must differ from the proxy port";
3230
- }
3231
- const loopback = rawConfigRecord(raw.unauthenticatedLoopbackListener);
3232
- if (loopback?.enabled === true && loopback.port === ingressPort) {
3233
- return "schema_invalid: hub.managementIngress.port: must differ from unauthenticatedLoopbackListener.port";
3234
- }
3235
- return null;
3236
- }
3237
-
3238
- export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
3239
- const boundaryError = configReasoningPinsConfigError(value)
3240
- ?? blankHostnameError(value)
3241
- ?? claudeSubagentEffortError(value)
3242
- ?? appOwnedMemoryBudgetError(value)
3243
- ?? upstreamHostCircuitThresholdError(value)
3244
- ?? plaintextV2AgentMessagesError(value)
3245
- ?? agentTaskRecoveryError(value)
3246
- ?? quotaResetNotifyError(value)
3247
- ?? catalogAutoRefreshError(value)
3248
- ?? codexPoolError(value)
3249
- ?? googleAntigravityStaticCatalogVersionError(value)
3250
- ?? codexAccountPrioritiesError(value)
3251
- ?? codexQuotaAutoRefreshError(value)
3252
- ?? codexAccountPickerEnabledError(value)
3253
- ?? emptyCompletionRetryError(value)
3254
- ?? dropCodexSafetyBufferingError(value)
3255
- ?? oauthOpenBrowserError(value)
3256
- ?? runtimeRoleError(value)
3257
- ?? remoteGuiConfigError(value)
3258
- ?? clientConnectionConfigError(value)
3259
- ?? clientRolePairError(value)
3260
- ?? loopbackListenerPortError(value)
3261
- ?? managementIngressConfigError(value);
3262
- if (boundaryError) return { ok: false, error: boundaryError };
3263
- const result = configSchema.safeParse(value);
3264
- if (result.success) {
3265
- const config = normalizeApiKeyIds(result.data as OcxConfig);
3266
- return { ok: true, config };
3267
- }
3268
- return { ok: false, error: schemaDiagnosticsError(result.error) };
3269
- }
3270
-
3271
- function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics {
3272
- try {
3273
- const parsed = JSON.parse(raw.replace(/^\uFEFF/, ""));
217
+ const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
218
+ const parsed = JSON.parse(raw);
219
+ sanitizeAliasesForLoad(parsed);
3274
220
  sanitizeReasoningPinsForLoad(parsed);
3275
- // Same degradation as loadConfig: a hand-edited invalid retryOn429 must not trip the
3276
- // schema and send the caller a default-config fallback (the config command could then
3277
- // persist that fallback over the user's providers/keys).
3278
221
  sanitizeModelDisplayNamesForLoad(parsed);
3279
222
  sanitizeAutoReviewForLoad(parsed);
3280
223
  sanitizeRetryOn429ForLoad(parsed);
@@ -3282,385 +225,93 @@ function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics {
3282
225
  sanitizeCapabilityDeclarationsForLoad(parsed);
3283
226
  const result = configSchema.safeParse(parsed);
3284
227
  if (result.success) {
3285
- return validFileConfigDiagnostics(normalizeApiKeyIds(result.data as OcxConfig), parsed);
228
+ const config = normalizeApiKeyIds(result.data as OcxConfig);
229
+ warnInheritedFastWireConflicts(configPath, config);
230
+ warnDegradedStreamMode(parsed, config);
231
+ warnDegradedHostname(parsed, config);
232
+ warnDegradedListeners(parsed, config);
233
+ warnDegradedApiKeys(parsed, config);
234
+ warnDegradedCodexAccountPriorities(parsed, config);
235
+ warnDegradedCodexQuotaAutoRefresh(parsed, config);
236
+ warnDegradedClaudeSubagentEffort(parsed);
237
+ warnDegradedNativeSubagentConfig(parsed, config);
238
+ warnDegradedCodexAccountPicker(parsed);
239
+ warnDegradedUpstreamHostCircuitThreshold(parsed);
240
+ warnDegradedPlaintextV2AgentMessages(parsed);
241
+ warnDegradedAgentTaskRecovery(parsed);
242
+ warnDegradedRuntimeRole(parsed);
243
+ warnDegradedOptionalRemoteBlocks(parsed);
244
+ warnDegradedQuotaResetNotify(parsed);
245
+ warnDegradedCatalogAutoRefresh(parsed);
246
+ warnDegradedCodexPool(parsed);
247
+ warnDegradedCredentialGroups(parsed);
248
+ return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
3286
249
  }
3287
-
250
+ // Schema validation failed — merge defaults into the raw object instead of
251
+ // discarding it entirely, so pool accounts and providers survive a missing
252
+ // field like defaultProvider.
3288
253
  const merged = mergeConfigDefaults(parsed);
3289
254
  const retryResult = configSchema.safeParse(merged);
3290
255
  if (retryResult.success) {
3291
- return validFileConfigDiagnostics(normalizeApiKeyIds(retryResult.data as OcxConfig), parsed);
3292
- }
3293
-
3294
- // #1785: one invalid routing profile must not make diagnostics report the built-in
3295
- // defaults AS the config, because a later config write persists those defaults over the
3296
- // operator's providers, keys and prices.
3297
- //
3298
- // The failure is still reported. `source` stays "fallback" and `error` keeps the real
3299
- // schema message -- diagnostics is the surface that tells callers the file is invalid,
3300
- // and every consumer that must refuse an invalid config (provider reload, catalog sync,
3301
- // cost reconcile, codex admission) gates on exactly those two fields. Only `config`
3302
- // changes: it carries the salvaged document instead of factory defaults, so a caller
3303
- // that ignores the error and writes it back preserves what the operator configured.
3304
- const salvaged = salvageConfigCandidate(merged, retryResult.error);
3305
- if (salvaged) {
3306
- const config = normalizeApiKeyIds(salvaged.parsed);
3307
- const warnings = degradedListenerWarnings(parsed, config);
3308
- return {
3309
- config,
3310
- source: "fallback",
3311
- error: schemaDiagnosticsError(result.error),
3312
- ...(warnings.length > 0 ? { warnings } : {}),
3313
- };
3314
- }
3315
-
3316
- return { config: getDefaultConfig(), source: "fallback", error: schemaDiagnosticsError(result.error) };
3317
- } catch {
3318
- return { config: getDefaultConfig(), source: "fallback", error: "invalid_json" };
3319
- }
3320
- }
3321
-
3322
- function readConfigFileSnapshot(): ConfigFileSnapshot {
3323
- try {
3324
- const raw = readFileSync(getConfigPath(), "utf-8");
3325
- return { diagnostics: configDiagnosticsFromRaw(raw), raw };
3326
- } catch (error) {
3327
- if (isMissingPathError(error)) {
3328
- return {
3329
- diagnostics: { config: getDefaultConfig(), source: "default", error: null },
3330
- };
3331
- }
3332
- return {
3333
- diagnostics: { config: getDefaultConfig(), source: "fallback", error: "invalid_json" },
3334
- };
3335
- }
3336
- }
3337
-
3338
- export function readConfigDiagnostics(): ConfigDiagnostics {
3339
- return readConfigFileSnapshot().diagnostics;
3340
- }
3341
-
3342
- /** Read-only init preflight. Occupied unsafe entries are never treated as absence. */
3343
- export function observeInitialConfigState(): "missing" | "exists" | "invalid" {
3344
- try {
3345
- if (!lstatSync(getConfigPath()).isFile()) return "invalid";
3346
- } catch (error) {
3347
- return isMissingPathError(error) ? "missing" : "invalid";
3348
- }
3349
- return readConfigFileSnapshot().diagnostics.source === "file" ? "exists" : "invalid";
3350
- }
3351
-
3352
- /**
3353
- * The persisted config, plus a digest of the EXACT bytes it was parsed from.
3354
- *
3355
- * A union rather than a nullable digest, because `{ kind: "read" }` with no
3356
- * digest is a state that cannot occur — and a state that cannot occur should
3357
- * not be a state that can be written down. Refusing it at runtime is a check
3358
- * somebody eventually forgets; making it unrepresentable is not.
3359
- *
3360
- * Why a byte digest at all: the Codex write lock compares an authority snapshot
3361
- * taken before the lock against one taken while holding it, and its config
3362
- * component used to hash the PARSED object. Two files that differ only in
3363
- * whitespace or key order parse identically, so a non-cooperating writer could
3364
- * rewrite the file between admission and commit and the comparison would see
3365
- * nothing. Hashing what was actually read closes that.
3366
- *
3367
- * `readConfigFileSnapshot` stays private on purpose. Its `raw` carries provider
3368
- * API keys and admission tokens, and `privacy:scan` reads tracked source text,
3369
- * not runtime values — so it would not catch a caller that logged or serialized
3370
- * that string. The digest travels; the bytes do not.
3371
- */
3372
- export type ConfigAdmissionSnapshot =
3373
- | Readonly<{ kind: "read"; diagnostics: ConfigDiagnostics; contentSha256: string }>
3374
- | Readonly<{ kind: "unreadable"; diagnostics: ConfigDiagnostics; contentSha256: null }>;
3375
-
3376
- export function readConfigAdmissionSnapshot(): ConfigAdmissionSnapshot {
3377
- let bytes: Buffer;
3378
- try {
3379
- // ONE read. Hashing the file and then reading it again to parse would leave
3380
- // a window for the two to disagree, which is the exact hazard this exists
3381
- // to detect — the check would become a second chance to be wrong.
3382
- bytes = readFileSync(getConfigPath());
3383
- } catch (error) {
3384
- return {
3385
- kind: "unreadable",
3386
- diagnostics: isMissingPathError(error)
3387
- ? { config: getDefaultConfig(), source: "default", error: null }
3388
- : { config: getDefaultConfig(), source: "fallback", error: "invalid_json" },
3389
- contentSha256: null,
3390
- };
3391
- }
3392
- return {
3393
- kind: "read",
3394
- // Decoded from the same buffer that was hashed, not re-read from disk.
3395
- diagnostics: configDiagnosticsFromRaw(bytes.toString("utf-8")),
3396
- contentSha256: createHash("sha256").update(bytes).digest("hex"),
3397
- };
3398
- }
3399
-
3400
- const CONFIG_MUTATION_DB_FILENAME = "config-mutation.sqlite";
3401
- const CONFIG_MUTATION_DB_SIDECARS = ["-journal", "-wal", "-shm"] as const;
3402
- let warnedConfigMutationDirectoryAcl = false;
3403
-
3404
- export class ConfigMutationLockError extends Error {
3405
- readonly code = "CONFIG_MUTATION_LOCK_UNAVAILABLE";
3406
-
3407
- constructor(message: string, options?: { cause?: unknown }) {
3408
- super(message, options);
3409
- this.name = "ConfigMutationLockError";
3410
- }
3411
- }
3412
-
3413
- function configMutationDatabasePath(): string {
3414
- const dir = getConfigDir();
3415
- // First statement on purpose: a rejected mutation must leave nothing behind, not a
3416
- // freshly created/chmod'd directory or database. See src/lib/test-home-guard.ts.
3417
- assertNotRealHomeUnderTest(dir);
3418
- if (!existsSync(dir)) {
3419
- mkdirSync(dir, { recursive: true, mode: 0o700 });
3420
- } else {
3421
- try { chmodSync(dir, 0o700); } catch { /* best-effort on existing dir */ }
3422
- }
3423
- if (windowsSecretAclApplies()) {
3424
- try {
3425
- // Distinct timeout memo from management-token directory harden: a required
3426
- // management-dir timeout must not poison config mutation on the same home
3427
- // (windows-latest server-management-auth cases).
3428
- hardenSecretDir(dir, { required: true, timeoutMemoKey: `${dir}::config-mutation` });
3429
- } catch (error) {
3430
- if (!warnedConfigMutationDirectoryAcl) {
3431
- warnedConfigMutationDirectoryAcl = true;
3432
- const diagnostics = error instanceof Error ? error.message : "ACL hardening failed";
3433
- console.warn(
3434
- `[opencodex] Config mutation coordination directory ACL hardening did not complete; continuing without it. ${diagnostics}`,
3435
- );
3436
- }
3437
- }
3438
- }
3439
- const path = join(dir, CONFIG_MUTATION_DB_FILENAME);
3440
- recordOwnedConfigPath(dir, path);
3441
- for (const suffix of CONFIG_MUTATION_DB_SIDECARS) {
3442
- recordOwnedConfigPath(dir, `${path}${suffix}`);
3443
- }
3444
- return path;
3445
- }
3446
-
3447
- /** Raised when an independent config-mutation transaction is requested recursively. */
3448
- export class NestedConfigMutationError extends Error {
3449
- constructor() {
3450
- super("prepareConfigMutationDatabasePathForWrite must not run inside withConfigMutationLockSync");
3451
- this.name = "NestedConfigMutationError";
3452
- }
3453
- }
3454
-
3455
- /**
3456
- * Prepare the shared config-mutation database path for an independent top-level
3457
- * SQLite transaction. Callers must not invoke this while holding
3458
- * {@link withConfigMutationLockSync}; a second `BEGIN IMMEDIATE` deliberately
3459
- * fails busy instead of joining an uncommitted transaction.
3460
- *
3461
- * @throws {NestedConfigMutationError} If a config mutation lock is already held.
3462
- */
3463
- export function prepareConfigMutationDatabasePathForWrite(): string {
3464
- if (configMutationLockDepth > 0) {
3465
- throw new NestedConfigMutationError();
3466
- }
3467
- return configMutationDatabasePath();
3468
- }
3469
-
3470
- let configMutationLockDepth = 0;
3471
- let configMutationDatabase: Database | null = null;
3472
-
3473
- /**
3474
- * Serialize synchronous config and Codex credential-generation commits across processes with an
3475
- * OS-backed SQLite write transaction. `busy_timeout=0` is deliberate: runtime request paths must
3476
- * fail immediately under contention rather than freeze the Bun event loop. Process exit releases
3477
- * SQLite locks without stale-owner deletion or lease recovery races.
3478
- *
3479
- * Reentrancy is limited to the current synchronous call stack; never return a Promise from `fn`.
3480
- */
3481
- export function withConfigMutationLockSync<T>(fn: () => T): T {
3482
- if (configMutationLockDepth > 0) {
3483
- configMutationLockDepth += 1;
3484
- try {
3485
- return fn();
3486
- } finally {
3487
- configMutationLockDepth -= 1;
3488
- }
3489
- }
3490
- const path = configMutationDatabasePath();
3491
- let database: Database | undefined;
3492
- let transactionOpen = false;
3493
- try {
3494
- database = new Database(path, { create: true });
3495
- try { chmodSync(path, 0o600); } catch { /* platform may ignore chmod */ }
3496
- database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
3497
- transactionOpen = true;
3498
- initializeConfigGeneration(database);
3499
- } catch (cause) {
3500
- if (transactionOpen) {
3501
- try { database?.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ }
3502
- }
3503
- try { database?.close(); } catch { /* acquisition already failed */ }
3504
- const code = cause && typeof cause === "object" && "code" in cause
3505
- ? String((cause as { code?: unknown }).code)
3506
- : "";
3507
- throw new ConfigMutationLockError(
3508
- code === "SQLITE_BUSY" ? "Config mutation already in progress" : "Could not acquire config mutation transaction",
3509
- { cause },
3510
- );
3511
- }
3512
-
3513
- configMutationLockDepth = 1;
3514
- configMutationDatabase = database;
3515
- try {
3516
- const value = fn();
3517
- database.exec("COMMIT");
3518
- transactionOpen = false;
3519
- return value;
3520
- } catch (error) {
3521
- if (transactionOpen) {
3522
- try { database.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ }
3523
- transactionOpen = false;
3524
- }
3525
- throw error;
3526
- } finally {
3527
- configMutationLockDepth = 0;
3528
- configMutationDatabase = null;
3529
- try { database.close(); } catch { /* the OS lock is released with the handle */ }
3530
- }
3531
- }
3532
-
3533
- function bumpGenerationForCooperatingConfigWrite(): void {
3534
- if (!configMutationDatabase) {
3535
- throw new Error("A cooperating config write requires the config mutation transaction.");
3536
- }
3537
- bumpCurrentConfigGeneration(configMutationDatabase);
3538
- }
3539
-
3540
- export const readConfigGeneration: ReadConfigGeneration = () => {
3541
- try {
3542
- return readConfigGenerationAtPath(configMutationDatabasePath());
3543
- } catch {
3544
- return { kind: "unavailable", reason: "database" };
3545
- }
3546
- };
3547
-
3548
- export function observeConfigGeneration(): ConfigGenerationObservation {
3549
- return observeConfigGenerationAtPath(join(getConfigDir(), CONFIG_MUTATION_DB_FILENAME));
3550
- }
3551
-
3552
- /**
3553
- * Read the generation from the transaction that is open RIGHT NOW.
3554
- *
3555
- * The observer cannot do this job. On the very first acquisition the
3556
- * `BEGIN IMMEDIATE` that creates the table has not committed yet, so a separate
3557
- * read-only connection cannot read a generation from it — measured, not
3558
- * assumed. A caller that compared a pre-lock observation against an observer
3559
- * re-read would therefore refuse every first write as stale.
3560
- *
3561
- * Throwing when no transaction is open is deliberate. Being called outside the
3562
- * lock is broken plumbing, and returning a typed "unavailable" would let that
3563
- * bug arrive disguised as an environmental failure — retried forever, on a
3564
- * machine where nothing is wrong.
3565
- */
3566
- export function readConfigGenerationInCurrentMutationTransaction(): ConfigGeneration {
3567
- if (configMutationLockDepth < 1 || !configMutationDatabase) {
3568
- throw new Error(
3569
- "readConfigGenerationInCurrentMutationTransaction requires an open config mutation transaction.",
3570
- );
3571
- }
3572
- return readConfigGenerationInTransaction(configMutationDatabase);
3573
- }
3574
-
3575
- export const bumpConfigGeneration: BumpConfigGeneration = expected => {
3576
- try {
3577
- return bumpConfigGenerationAtPath(configMutationDatabasePath(), expected);
3578
- } catch {
3579
- return { kind: "unavailable", reason: "database" };
3580
- }
3581
- };
3582
-
3583
- function configGenerationFailureReason(error: unknown): "busy" | "database" {
3584
- const cause = error instanceof ConfigMutationLockError ? error.cause : error;
3585
- const code = cause && typeof cause === "object" && "code" in cause
3586
- ? String((cause as { code?: unknown }).code)
3587
- : "";
3588
- const message = cause instanceof Error ? cause.message : "";
3589
- return code === "SQLITE_BUSY"
3590
- || code === "SQLITE_LOCKED"
3591
- || /database (?:is|table is) locked/i.test(message)
3592
- ? "busy"
3593
- : "database";
3594
- }
3595
-
3596
- export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync = (
3597
- expected,
3598
- commit,
3599
- ) => {
3600
- let callbackThrew = false;
3601
- let callbackError: unknown;
3602
- try {
3603
- return withConfigMutationLockSync(() => {
3604
- const database = configMutationDatabase;
3605
- if (!database) throw new Error("Config mutation transaction database is unavailable.");
3606
- const current = readConfigGenerationInTransaction(database);
3607
- if (current.value !== expected.value) return { kind: "conflict", current };
3608
- try {
3609
- return { kind: "matched", generation: current, value: commit() };
3610
- } catch (error) {
3611
- callbackThrew = true;
3612
- callbackError = error;
3613
- throw error;
256
+ warnConfigRepaired(configPath, result.error);
257
+ const config = normalizeApiKeyIds(retryResult.data as OcxConfig);
258
+ warnInheritedFastWireConflicts(configPath, config);
259
+ warnDegradedHostname(parsed, config);
260
+ warnDegradedListeners(parsed, config);
261
+ warnDegradedApiKeys(parsed, config);
262
+ warnDegradedCodexAccountPriorities(parsed, config);
263
+ warnDegradedCodexQuotaAutoRefresh(parsed, config);
264
+ warnDegradedClaudeSubagentEffort(parsed);
265
+ warnDegradedNativeSubagentConfig(parsed, config);
266
+ warnDegradedCodexAccountPicker(parsed);
267
+ warnDegradedUpstreamHostCircuitThreshold(parsed);
268
+ warnDegradedPlaintextV2AgentMessages(parsed);
269
+ warnDegradedAgentTaskRecovery(parsed);
270
+ warnDegradedRuntimeRole(parsed);
271
+ warnDegradedOptionalRemoteBlocks(parsed);
272
+ warnDegradedQuotaResetNotify(parsed);
273
+ warnDegradedCatalogAutoRefresh(parsed);
274
+ warnDegradedCodexPool(parsed);
275
+ warnDegradedCredentialGroups(parsed);
276
+ return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
277
+ }
278
+ // Still failing, but if every complaint is about one or more named entries
279
+ // in an independent section, drop exactly those and keep the rest. Falling
280
+ // back to defaults here would silently retire the operator's providers,
281
+ // keys and prices over a mistake in one routing profile.
282
+ const salvaged = salvageConfigCandidate(merged, retryResult.error);
283
+ if (salvaged) {
284
+ {
285
+ warnDroppedConfigSections(configPath, salvaged.dropped, salvaged.issues);
286
+ const config = normalizeApiKeyIds(salvaged.parsed);
287
+ warnInheritedFastWireConflicts(configPath, config);
288
+ warnDegradedHostname(parsed, config);
289
+ warnDegradedListeners(parsed, config);
290
+ warnDegradedApiKeys(parsed, config);
291
+ warnDegradedCodexAccountPriorities(parsed, config);
292
+ warnDegradedCodexQuotaAutoRefresh(parsed, config);
293
+ warnDegradedClaudeSubagentEffort(parsed);
294
+ warnDegradedNativeSubagentConfig(parsed, config);
295
+ warnDegradedCodexAccountPicker(parsed);
296
+ warnDegradedUpstreamHostCircuitThreshold(parsed);
297
+ warnDegradedPlaintextV2AgentMessages(parsed);
298
+ warnDegradedAgentTaskRecovery(parsed);
299
+ warnDegradedRuntimeRole(parsed);
300
+ warnDegradedOptionalRemoteBlocks(parsed);
301
+ warnDegradedQuotaResetNotify(parsed);
302
+ warnDegradedCatalogAutoRefresh(parsed);
303
+ warnDegradedCodexPool(parsed);
304
+ warnDegradedCredentialGroups(parsed);
305
+ return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
3614
306
  }
3615
- });
3616
- } catch (error) {
3617
- if (callbackThrew && error === callbackError) throw error;
3618
- return { kind: "unavailable", reason: configGenerationFailureReason(error) };
3619
- }
3620
- };
3621
-
3622
- /**
3623
- * Atomic config.json write WITHOUT the mutation lock; callers must hold
3624
- * `withConfigMutationLockSync`. Returns true when bytes changed. Refreshes the
3625
- * cost-overlay registry from the persisted config so runtime estimates follow
3626
- * every save path.
3627
- */
3628
- function persistConfigUnlocked(config: OcxConfig): boolean {
3629
- const pinError = configReasoningPinsConfigError(config);
3630
- if (pinError) throw new Error(pinError);
3631
- const configPath = getConfigPath();
3632
- const rawBeforeWrite = readRawConfigJson();
3633
- const clientPersistenceError = failClosedClientPersistenceError(rawBeforeWrite, config);
3634
- if (clientPersistenceError) throw new Error(clientPersistenceError);
3635
- // External editors can add provider rows the live config deliberately does
3636
- // not route with yet; merge them at the serialization boundary so an
3637
- // unrelated in-process save cannot erase the provider or its overlay.
3638
- // Provider preservation reads symbol-keyed live-owner state, which structuredClone
3639
- // intentionally drops. Resolve that ownership before projecting JSON provenance.
3640
- const provenanceProjection = projectConfigRebaseProvenance(config);
3641
- const persisted = withPreservedDiskOnlyProviders(config);
3642
- if (provenanceProjection.configRebaseProvenance === undefined) delete persisted.configRebaseProvenance;
3643
- else persisted.configRebaseProvenance = provenanceProjection.configRebaseProvenance;
3644
- const bytes = JSON.stringify(persisted, null, 2) + "\n";
3645
- let unchanged = false;
3646
- try {
3647
- unchanged = readFileSync(configPath, "utf8") === bytes;
307
+ }
308
+ // Merge couldn't fix it — truly broken config
309
+ warnAndBackupInvalidConfig(configPath, result.error);
310
+ return getDefaultConfig();
3648
311
  } catch (error) {
3649
- if (!isMissingPathError(error)) throw error;
3650
- }
3651
- // Keep the runtime overlay registry in sync with EVERY persist path,
3652
- // including byte-identical saves: a cooperating CLI process may have written
3653
- // the same bytes (e.g. before a proxy notification), and Logs/Usage must
3654
- // adopt the overlay without waiting for a changed save or restart.
3655
- if (unchanged) {
3656
- refreshUserCostOverlays(persisted);
3657
- return false;
312
+ warnAndBackupInvalidConfig(configPath, error);
313
+ return getDefaultConfig();
3658
314
  }
3659
- atomicWriteFile(configPath, bytes);
3660
- // For changed saves, refresh only AFTER the write succeeded so a failed
3661
- // write cannot leave estimates reflecting configuration never persisted.
3662
- refreshUserCostOverlays(persisted);
3663
- return true;
3664
315
  }
3665
316
 
3666
317
  export type PersistedConfigInitializationOutcome = "created" | "exists" | "invalid";
@@ -3807,901 +458,3 @@ export function mutatePersistedConfig<T>(
3807
458
  return { status: "unavailable", reason: "conflict" };
3808
459
  });
3809
460
  }
3810
-
3811
- function failClosedClientPersistenceError(
3812
- raw: Record<string, unknown> | undefined,
3813
- candidate: OcxConfig,
3814
- ): string | null {
3815
- if (!raw) return null;
3816
- const rawHasClient = Object.hasOwn(raw, "client") && raw.client !== undefined;
3817
- const rawRole = raw.runtimeRole;
3818
- const rawRoleValid = rawRole === undefined
3819
- || rawRole === "standalone"
3820
- || rawRole === "hub"
3821
- || rawRole === "client";
3822
- const rawClientValid = !rawHasClient || clientConnectionSchema.safeParse(raw.client).success;
3823
- const rawPairValid = rawRoleValid
3824
- && ((rawRole === "client" && rawHasClient && rawClientValid)
3825
- || (rawRole !== "client" && !rawHasClient));
3826
- if (rawPairValid) return null;
3827
-
3828
- const candidateValid = candidate.runtimeRole === "client"
3829
- && clientConnectionSchema.safeParse(candidate.client).success;
3830
- const deletions = configRebaseDeletionKeys(candidate);
3831
- const explicitClear = deletions.has("client") && deletions.has("runtimeRole");
3832
- if (candidateValid || explicitClear) return null;
3833
- return "config write refused: malformed or mismatched remote client state must be repaired or explicitly cleared";
3834
- }
3835
-
3836
- export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolean {
3837
- return config.websockets === true;
3838
- }
3839
-
3840
- /**
3841
- * Opt-in Ultra Fast, read with the house `=== true` idiom so an absent key and a
3842
- * malformed one both mean off.
3843
- */
3844
- export function ultraFastTierEnabled(config: Pick<OcxConfig, "ultraFastTier">): boolean {
3845
- return config.ultraFastTier === true;
3846
- }
3847
-
3848
- /**
3849
- * Default cadence for the opt-in catalog auto-refresh (issue #3630): one converge pass
3850
- * per hour. Each pass spends a live /models call against every enabled provider, and
3851
- * provider catalogs are themselves cached upstream for minutes, so an hour is fresh
3852
- * enough for newly released models to appear without an `ocx sync`.
3853
- */
3854
- export const CATALOG_AUTO_REFRESH_DEFAULT_INTERVAL_MS: number = 60 * 60_000;
3855
-
3856
- /**
3857
- * Floor under the configured cadence, for the same reason src/quota/reset-poller.ts has
3858
- * MIN_INTERVAL_MS: below this the refresh buys no freshness — upstream caches have not
3859
- * moved — and only multiplies the chance of a rate limit across every enabled provider.
3860
- */
3861
- export const CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS: number = 15 * 60_000;
3862
-
3863
- /**
3864
- * Opt-in master switch, read with the house `=== true` idiom so an absent key and a
3865
- * malformed one both mean off. Pure on purpose: the scheduler calls this from a
3866
- * dynamically imported context, so it takes an explicit config slice and reads nothing
3867
- * global.
3868
- */
3869
- export function isCatalogAutoRefreshEnabled(
3870
- config: Pick<OcxConfig, "catalogAutoRefresh">,
3871
- ): boolean {
3872
- return config.catalogAutoRefresh?.enabled === true;
3873
- }
3874
-
3875
- /**
3876
- * Resolved tick interval in milliseconds. An explicit `intervalMinutes: 0` returns 0 —
3877
- * the section stays configured but the timer stays dormant — and any other value is
3878
- * clamped up to CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS so a hand edit cannot outrun the
3879
- * upstream catalog caches. Absent means the hourly default.
3880
- */
3881
- export function resolveCatalogAutoRefreshIntervalMs(
3882
- config: Pick<OcxConfig, "catalogAutoRefresh">,
3883
- ): number {
3884
- const minutes = config.catalogAutoRefresh?.intervalMinutes;
3885
- if (minutes === undefined) return CATALOG_AUTO_REFRESH_DEFAULT_INTERVAL_MS;
3886
- if (minutes === 0) return 0;
3887
- return Math.max(CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS, Math.floor(minutes * 60_000));
3888
- }
3889
-
3890
- // ---------------------------------------------------------------------------
3891
- // Hand-edit protection for the `claudeCode` subtree (devlog 260726_claude_auth_auto/040 H1).
3892
- //
3893
- // `saveConfig` serializes the WHOLE config object, so ANY service-time save — a model
3894
- // visibility toggle, a 429 key rotation on the request path — rewrites `claudeCode`
3895
- // from whatever the long-lived server config happens to hold. A user who hand-edits
3896
- // `config.json` while the proxy runs then watches their edit vanish for no visible
3897
- // reason (issue #488). Enumerating `claudeCode` mutators cannot fix that; the guard has
3898
- // to live in ONE save wrapper that every live-config writer goes through.
3899
- // ---------------------------------------------------------------------------
3900
-
3901
- /**
3902
- * Baseline keyed on the CONFIG INSTANCE, never a module global: a second `loadConfig()`
3903
- * elsewhere must not refresh the baseline the long-lived server config is judged
3904
- * against, or a later stale save would masquerade as "our own change".
3905
- */
3906
- const claudeCodeBaseline = new WeakMap<OcxConfig, unknown>();
3907
- /**
3908
- * Full live-config baseline used to rebase unrelated cooperating writes. The
3909
- * Claude subtree and the bound listener fields remain on their dedicated
3910
- * reconciliation paths below.
3911
- */
3912
- const liveConfigBaseline = new WeakMap<OcxConfig, OcxConfig>();
3913
- /**
3914
- * The live config retains the address of the socket Bun actually opened, while
3915
- * this map retains the operator's desired address for the next process start.
3916
- * Keeping them separate prevents an unrelated live save from restoring a stale
3917
- * externally exposed bind after OAuth adopted a newer loopback disk config.
3918
- */
3919
- type PersistedServerBinding = Pick<OcxConfig, "port" | "hostname">;
3920
-
3921
- const persistedLiveServerBinding = new WeakMap<OcxConfig, PersistedServerBinding>();
3922
-
3923
- /**
3924
- * Arm the baseline for a long-lived config. MANDATORY at `startServer`, not lazy on
3925
- * first save — arming lazily would lose exactly the hand edit made before that first
3926
- * save, which is the case the guard exists for.
3927
- */
3928
- export function armClaudeCodeBaseline(config: OcxConfig): void {
3929
- liveConfigBaseline.set(config, structuredClone(config));
3930
- claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
3931
- }
3932
-
3933
- /**
3934
- * Adopt one schema-validated provider that was read from the authoritative disk
3935
- * config into a long-lived server config without rebasing any unrelated field.
3936
- * Updating the matching baseline row keeps a later guarded save from treating the
3937
- * adopted provider as an unsaved live edit that should defeat a newer disk change.
3938
- */
3939
- export function adoptPersistedProviderIntoLiveConfig(
3940
- config: OcxConfig,
3941
- name: string,
3942
- provider: OcxProviderConfig,
3943
- persistedConfig?: OcxConfig,
3944
- ): void {
3945
- config.providers[name] = structuredClone(provider);
3946
- const baseline = liveConfigBaseline.get(config);
3947
- if (baseline) baseline.providers[name] = structuredClone(provider);
3948
- if (persistedConfig) refreshPreservedProviderOwner(config, persistedConfig);
3949
- }
3950
-
3951
- /** Test seam only: is this instance armed? */
3952
- export function claudeCodeBaselineArmed(config: OcxConfig): boolean {
3953
- return claudeCodeBaseline.has(config);
3954
- }
3955
-
3956
- /**
3957
- * Structural compare of parsed subtrees. NOT `JSON.stringify`: key order must not
3958
- * decide whether a user's hand edit survives.
3959
- */
3960
- function deepEqual(a: unknown, b: unknown): boolean {
3961
- if (a === b) return true;
3962
- if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
3963
- if (Array.isArray(a) !== Array.isArray(b)) return false;
3964
- if (Array.isArray(a) && Array.isArray(b)) {
3965
- return a.length === b.length && a.every((item, index) => deepEqual(item, b[index]));
3966
- }
3967
- const left = a as Record<string, unknown>;
3968
- const right = b as Record<string, unknown>;
3969
- // `undefined` values and absent keys are the same thing after a JSON round-trip.
3970
- const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
3971
- for (const key of keys) {
3972
- if (left[key] === undefined && right[key] === undefined) continue;
3973
- if (!deepEqual(left[key], right[key])) return false;
3974
- }
3975
- return true;
3976
- }
3977
-
3978
- const MISSING_CONFIG_VALUE = Symbol("missing-config-value");
3979
- type ConfigMergeValue = unknown | typeof MISSING_CONFIG_VALUE;
3980
-
3981
- function isPlainConfigRecord(value: ConfigMergeValue): value is Record<string, unknown> {
3982
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
3983
- const prototype = Object.getPrototypeOf(value);
3984
- return prototype === Object.prototype || prototype === null;
3985
- }
3986
-
3987
- function ownConfigValue(record: Record<string, unknown>, key: string): ConfigMergeValue {
3988
- return Object.hasOwn(record, key) ? record[key] : MISSING_CONFIG_VALUE;
3989
- }
3990
-
3991
- function cloneConfigValue(value: ConfigMergeValue): ConfigMergeValue {
3992
- return value === MISSING_CONFIG_VALUE ? value : structuredClone(value);
3993
- }
3994
-
3995
- type IndexedCustomModels = {
3996
- order: string[];
3997
- byId: Map<string, Record<string, unknown>>;
3998
- };
3999
-
4000
- function indexCustomModels(value: ConfigMergeValue): IndexedCustomModels | null {
4001
- if (!Array.isArray(value)) return null;
4002
- const order: string[] = [];
4003
- const byId = new Map<string, Record<string, unknown>>();
4004
- for (const item of value) {
4005
- if (!isPlainConfigRecord(item) || typeof item.id !== "string" || item.id.length === 0 || byId.has(item.id)) {
4006
- return null;
4007
- }
4008
- order.push(item.id);
4009
- byId.set(item.id, item);
4010
- }
4011
- return { order, byId };
4012
- }
4013
-
4014
- /**
4015
- * Merge custom-model rows by their stable id instead of treating the array as
4016
- * one opaque value. A row changed only on disk is adopted, a row changed only
4017
- * in the live config is retained, and disjoint edits to the same row recurse
4018
- * through the normal three-way object merge. A newer persisted row deletion
4019
- * wins over a stale live edit to that row.
4020
- */
4021
- function reconcileCustomModels(
4022
- baseline: ConfigMergeValue,
4023
- live: ConfigMergeValue,
4024
- persisted: ConfigMergeValue,
4025
- ): ConfigMergeValue | null {
4026
- const baselineRows = indexCustomModels(baseline);
4027
- const liveRows = indexCustomModels(live);
4028
- const persistedRows = indexCustomModels(persisted);
4029
- if (!baselineRows || !liveRows || !persistedRows) return null;
4030
-
4031
- const order = [...liveRows.order, ...persistedRows.order.filter(id => !liveRows.byId.has(id))];
4032
- const merged: Array<Record<string, unknown>> = [];
4033
- for (const id of order) {
4034
- const baselineRow = baselineRows.byId.get(id) ?? MISSING_CONFIG_VALUE;
4035
- const persistedRow = persistedRows.byId.get(id) ?? MISSING_CONFIG_VALUE;
4036
- const row = baselineRow !== MISSING_CONFIG_VALUE && persistedRow === MISSING_CONFIG_VALUE
4037
- ? MISSING_CONFIG_VALUE
4038
- : reconcileConfigValue(
4039
- baselineRow,
4040
- liveRows.byId.get(id) ?? MISSING_CONFIG_VALUE,
4041
- persistedRow,
4042
- );
4043
- if (row !== MISSING_CONFIG_VALUE) merged.push(row as Record<string, unknown>);
4044
- }
4045
- return merged;
4046
- }
4047
-
4048
- function reconcileConfigRecord(
4049
- live: Record<string, unknown>,
4050
- baseline: Record<string, unknown>,
4051
- persisted: Record<string, unknown>,
4052
- skippedKeys?: ReadonlySet<string>,
4053
- persistedDeletionsWin = false,
4054
- ): void {
4055
- const keys = new Set([...Object.keys(baseline), ...Object.keys(live), ...Object.keys(persisted)]);
4056
- for (const key of keys) {
4057
- if (skippedKeys?.has(key)) continue;
4058
- const baselineValue = ownConfigValue(baseline, key);
4059
- const liveValue = ownConfigValue(live, key);
4060
- const persistedValue = ownConfigValue(persisted, key);
4061
- const merged = persistedDeletionsWin
4062
- && baselineValue !== MISSING_CONFIG_VALUE
4063
- && persistedValue === MISSING_CONFIG_VALUE
4064
- ? MISSING_CONFIG_VALUE
4065
- : key === "customModels"
4066
- ? reconcileCustomModels(baselineValue, liveValue, persistedValue)
4067
- ?? reconcileConfigValue(baselineValue, liveValue, persistedValue)
4068
- : reconcileConfigValue(baselineValue, liveValue, persistedValue, key === "providers");
4069
- if (merged === MISSING_CONFIG_VALUE) delete live[key];
4070
- else live[key] = merged;
4071
- }
4072
- }
4073
-
4074
- function reconcileConfigValue(
4075
- baseline: ConfigMergeValue,
4076
- live: ConfigMergeValue,
4077
- persisted: ConfigMergeValue,
4078
- persistedChildDeletionsWin = false,
4079
- ): ConfigMergeValue {
4080
- const liveChanged = !deepEqual(live, baseline);
4081
- const persistedChanged = !deepEqual(persisted, baseline);
4082
-
4083
- if (!liveChanged) {
4084
- if (live !== MISSING_CONFIG_VALUE && Array.isArray(live) && Array.isArray(persisted)) {
4085
- live.splice(0, live.length, ...structuredClone(persisted));
4086
- return live;
4087
- }
4088
- if (isPlainConfigRecord(live) && isPlainConfigRecord(persisted)) {
4089
- reconcileConfigRecord(
4090
- live,
4091
- isPlainConfigRecord(baseline) ? baseline : {},
4092
- persisted,
4093
- );
4094
- return live;
4095
- }
4096
- return cloneConfigValue(persisted);
4097
- }
4098
-
4099
- if (!persistedChanged) return live;
4100
-
4101
- if (isPlainConfigRecord(live)
4102
- && isPlainConfigRecord(persisted)
4103
- && (baseline === MISSING_CONFIG_VALUE || isPlainConfigRecord(baseline))) {
4104
- reconcileConfigRecord(
4105
- live,
4106
- isPlainConfigRecord(baseline) ? baseline : {},
4107
- persisted,
4108
- undefined,
4109
- persistedChildDeletionsWin,
4110
- );
4111
- }
4112
- // Same-leaf conflicts prefer the pending live management mutation.
4113
- return live;
4114
- }
4115
-
4116
- /**
4117
- * Reconcile an async OAuth disk commit into the shared live config without erasing
4118
- * management mutations that have not saved yet. The baseline is a normalized disk
4119
- * snapshot from immediately before login; disjoint object edits merge recursively,
4120
- * while same-leaf conflicts prefer live state.
4121
- */
4122
- export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline: OcxConfig): void {
4123
- const diagnostics = readConfigDiagnostics();
4124
- if (diagnostics.source === "fallback") {
4125
- throw new Error(`OAuth config reconciliation failed: ${diagnostics.error ?? "invalid config file"}`);
4126
- }
4127
- const persisted = diagnostics.config;
4128
- const claudeGuardArmed = claudeCodeBaseline.has(config);
4129
- const pendingLiveClaudeMutation = claudeGuardArmed
4130
- && !deepEqual(config.claudeCode, claudeCodeBaseline.get(config));
4131
-
4132
- persistedLiveServerBinding.set(config, {
4133
- port: persisted.port,
4134
- ...(persisted.hostname !== undefined ? { hostname: persisted.hostname } : {}),
4135
- });
4136
-
4137
- reconcileConfigRecord(
4138
- config as unknown as Record<string, unknown>,
4139
- persistedBaseline as unknown as Record<string, unknown>,
4140
- persisted as unknown as Record<string, unknown>,
4141
- new Set(["hostname", "port", ...(claudeGuardArmed ? ["claudeCode"] : [])]),
4142
- );
4143
-
4144
- if (claudeGuardArmed && !pendingLiveClaudeMutation) {
4145
- if (persisted.claudeCode === undefined) delete config.claudeCode;
4146
- else config.claudeCode = structuredClone(persisted.claudeCode);
4147
- claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
4148
- }
4149
- // The reconciliation may have adopted a providers.<name>.modelCosts edit made
4150
- // by a cooperating process while the OAuth login was pending; keep the overlay
4151
- // registry (and the usage-cache overlay version) in sync with the live config.
4152
- refreshUserCostOverlays(config);
4153
- }
4154
-
4155
- /** The literal file, with no schema merge or default injection. */
4156
- function readRawConfigJson(): Record<string, unknown> | undefined {
4157
- try {
4158
- const configPath = getConfigPath();
4159
- if (!existsSync(configPath)) return undefined;
4160
- const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
4161
- const parsed = JSON.parse(raw) as unknown;
4162
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
4163
- return parsed as Record<string, unknown>;
4164
- } catch {
4165
- // Unreadable or corrupt: behave exactly as before. Never fail a save over protection.
4166
- return undefined;
4167
- }
4168
- }
4169
-
4170
- /**
4171
- * Read only schema-valid binding fields from the literal file. Missing fields mean
4172
- * their schema defaults; malformed fields keep the last known persisted value.
4173
- */
4174
- function readPersistedServerBinding(
4175
- raw: Record<string, unknown>,
4176
- baseline: PersistedServerBinding,
4177
- ): PersistedServerBinding {
4178
- const port = raw.port === undefined
4179
- ? 10100
4180
- : (typeof raw.port === "number"
4181
- && Number.isInteger(raw.port)
4182
- && raw.port >= 0
4183
- && raw.port <= 65535
4184
- ? raw.port
4185
- : baseline.port);
4186
- const hostname = raw.hostname === undefined
4187
- ? undefined
4188
- : (typeof raw.hostname === "string" ? raw.hostname : baseline.hostname);
4189
- return { port, ...(hostname !== undefined ? { hostname } : {}) };
4190
- }
4191
-
4192
- /**
4193
- * The save entry point for every writer holding a LIVE server config.
4194
- *
4195
- * Conflict policy, chosen deliberately:
4196
- * - disk changed, we did not → their hand edit wins;
4197
- * - disk changed AND we changed → disjoint fields are merged, while a same-leaf
4198
- * conflict keeps the live value;
4199
- * - a provider or custom-model row deleted on disk stays deleted even if stale
4200
- * live state edited that same row;
4201
- * - file missing/unreadable → save what we have, no throw.
4202
- *
4203
- * Custom-model rows are merged by their stable `id`, preserving independent
4204
- * edits and deletions across stale whole-config saves.
4205
- */
4206
- export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
4207
- const pinError = configReasoningPinsConfigError(config);
4208
- if (pinError) throw new Error(pinError);
4209
- withConfigMutationLockSync(() => {
4210
- const bindingBaseline = persistedLiveServerBinding.get(config);
4211
- // One authoritative pre-write read feeds both the live-config reconciliation and
4212
- // custom-model deletion migration. A second read could observe different bytes.
4213
- const onDisk = readRawConfigJson();
4214
- const baseline = liveConfigBaseline.get(config);
4215
- if (baseline && onDisk !== undefined) {
4216
- const persistedDiagnostics = configDiagnosticsFromRaw(JSON.stringify(onDisk));
4217
- if (persistedDiagnostics.source === "file") {
4218
- const deletedKeys = configRebaseDeletionKeys(config);
4219
- const provenanceExists = configHasRebaseProvenance(config);
4220
- // Only keys this live config is actually known to have diverged on may be
4221
- // rebased. The baseline is captured once when the server arms it, so any key
4222
- // that appeared on disk afterwards — through saveConfig(), a hand edit, or
4223
- // another process — is absent from the baseline as well as from the live
4224
- // config. Reconciling those keys reads "live never changed this" and adopts
4225
- // the disk value, which resurrects a field the live writer had deliberately
4226
- // deleted (#1462 regression: PUT /api/grok/selection with an empty list).
4227
- // Restrict the merge to keys the baseline knew about, plus keys the live
4228
- // config still carries; a key that exists only on disk is left to the
4229
- // ordinary whole-config write below.
4230
- const rebaseableKeys = new Set([
4231
- ...Object.keys(baseline as unknown as Record<string, unknown>),
4232
- ...Object.keys(config as unknown as Record<string, unknown>),
4233
- ...(provenanceExists
4234
- ? Object.keys(persistedDiagnostics.config as unknown as Record<string, unknown>)
4235
- : []),
4236
- ]);
4237
- const skipped = new Set(["hostname", "port", "claudeCode", CONFIG_REBASE_PROVENANCE_KEY]);
4238
- for (const key of Object.keys(persistedDiagnostics.config as unknown as Record<string, unknown>)) {
4239
- if (!rebaseableKeys.has(key)) skipped.add(key);
4240
- }
4241
- reconcileConfigRecord(
4242
- config as unknown as Record<string, unknown>,
4243
- baseline as unknown as Record<string, unknown>,
4244
- persistedDiagnostics.config as unknown as Record<string, unknown>,
4245
- skipped,
4246
- );
4247
- for (const key of deletedKeys) delete (config as unknown as Record<string, unknown>)[key];
4248
- }
4249
- }
4250
- if (claudeCodeBaseline.has(config)) {
4251
- if (onDisk !== undefined) {
4252
- const baseline = claudeCodeBaseline.get(config);
4253
- const persistedClaudeCode = normalizePersistedClaudeCode(onDisk.claudeCode);
4254
- const diskChanged = !deepEqual(persistedClaudeCode, baseline);
4255
- const weChanged = !deepEqual(config.claudeCode, baseline);
4256
- if (diskChanged && !weChanged) {
4257
- config.claudeCode = persistedClaudeCode;
4258
- }
4259
- }
4260
- }
4261
- const provenanceProjection = projectConfigRebaseProvenance(config);
4262
- const projectedConfig = projectCustomModelCatalogMigration(
4263
- onDisk,
4264
- config,
4265
- );
4266
- if (provenanceProjection.configRebaseProvenance === undefined) delete projectedConfig.configRebaseProvenance;
4267
- else projectedConfig.configRebaseProvenance = provenanceProjection.configRebaseProvenance;
4268
- const persistedBinding = bindingBaseline && onDisk
4269
- ? readPersistedServerBinding(onDisk, bindingBaseline)
4270
- : bindingBaseline;
4271
- if (persistedBinding) {
4272
- const persistedConfig: OcxConfig = { ...projectedConfig, port: persistedBinding.port };
4273
- if (persistedBinding.hostname === undefined) delete persistedConfig.hostname;
4274
- else persistedConfig.hostname = persistedBinding.hostname;
4275
- if (persistConfigUnlocked(persistedConfig)) bumpGenerationForCooperatingConfigWrite();
4276
- persistedLiveServerBinding.set(config, persistedBinding);
4277
- } else {
4278
- if (persistConfigUnlocked(projectedConfig)) bumpGenerationForCooperatingConfigWrite();
4279
- }
4280
- adoptCustomModelCatalogMigration(config, projectedConfig);
4281
- if (claudeCodeBaseline.has(config)) {
4282
- claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
4283
- }
4284
- if (liveConfigBaseline.has(config)) {
4285
- if (projectedConfig.configRebaseProvenance === undefined) delete config.configRebaseProvenance;
4286
- else config.configRebaseProvenance = structuredClone(projectedConfig.configRebaseProvenance);
4287
- liveConfigBaseline.set(config, structuredClone(projectedConfig));
4288
- }
4289
- clearPendingConfigTopLevelDeletions(config);
4290
- });
4291
- }
4292
-
4293
- export function codexAutoStartEnabled(config: Pick<OcxConfig, "codexAutoStart">): boolean {
4294
- return config.codexAutoStart !== false;
4295
- }
4296
-
4297
- export const CODEX_SHIM_AUTO_RESTORE_ENV = "OPENCODEX_CODEX_SHIM_AUTO_RESTORE";
4298
-
4299
- export function codexShimAutoRestoreEnabled(
4300
- config: Pick<OcxConfig, "codexShimAutoRestore">,
4301
- env: NodeJS.ProcessEnv = process.env,
4302
- ): boolean {
4303
- return config.codexShimAutoRestore !== false && env[CODEX_SHIM_AUTO_RESTORE_ENV] !== "0";
4304
- }
4305
-
4306
- export function multiAgentGuidanceEnabled(
4307
- config: Pick<OcxConfig, "multiAgentGuidanceEnabled">,
4308
- ): boolean {
4309
- return config.multiAgentGuidanceEnabled !== false;
4310
- }
4311
-
4312
- export function runtimeRole(config: Pick<OcxConfig, "runtimeRole">): OcxRuntimeRole {
4313
- return config.runtimeRole ?? "standalone";
4314
- }
4315
-
4316
- export function getDefaultConfig(): OcxConfig {
4317
- // Fresh-install default: works out of the box with Codex's ChatGPT OAuth (no API key).
4318
- // gpt-* requests forward the caller's incoming OAuth headers to the ChatGPT backend.
4319
- // Adding extra providers (e.g. opencode-go) and switching defaultProvider is a user/runtime choice.
4320
- return {
4321
- port: 10100,
4322
- emptyCompletionRetry: false,
4323
- dropCodexSafetyBuffering: false,
4324
- fastRows: true,
4325
- managementUsageMaxReadBytes: 64 * 1024 * 1024,
4326
- appOwnedMemoryBudgetMb: DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024),
4327
- // Fresh/re-initialized configs are already written in the current three-tier
4328
- // OpenAI shape. Mark them as such so startup does not mistake them for a
4329
- // legacy config and collide with an immutable backup from an earlier setup.
4330
- openaiProviderTierVersion: OPENAI_PROVIDER_TIER_VERSION,
4331
- providers: {
4332
- openai: {
4333
- adapter: "openai-responses",
4334
- baseUrl: "https://chatgpt.com/backend-api/codex",
4335
- authMode: "forward",
4336
- codexAccountMode: "pool",
4337
- },
4338
- },
4339
- defaultProvider: "openai",
4340
- subagentModels: [...DEFAULT_SUBAGENT_MODELS],
4341
- subagentModelsVersion: SUBAGENT_MODELS_VERSION,
4342
- // v1 is the shipped surface while a v2 native-to-routed task is undeliverable
4343
- // ciphertext. Written explicitly rather than left absent, because an absent key
4344
- // means base everywhere else. A fresh install starts already acknowledged: there is
4345
- // nothing to advise an operator who is on the recommended surface.
4346
- multiAgentMode: "v1",
4347
- multiAgentSurfaceAdvisoryVersion: MULTI_AGENT_SURFACE_ADVISORY_VERSION,
4348
- multiAgentGuidanceEnabled: true,
4349
- websockets: false,
4350
- codexAutoStart: true,
4351
- codexShimAutoRestore: true,
4352
- };
4353
- }
4354
-
4355
- export function resolveEnvValue(value: string | undefined): string | undefined {
4356
- if (!value) return undefined;
4357
- const match = value.match(/^\$\{(\w+)\}$/);
4358
- if (match) return process.env[match[1]];
4359
- if (value.startsWith("$")) return process.env[value.slice(1)];
4360
- return value;
4361
- }
4362
-
4363
- const warnedProxyConfigDiscards = new Set<"proxy" | "noProxy" | "noProxyElements">();
4364
-
4365
- function warnProxyConfigDiscardOnce(kind: "proxy" | "noProxy" | "noProxyElements"): void {
4366
- if (warnedProxyConfigDiscards.has(kind)) return;
4367
- warnedProxyConfigDiscards.add(kind);
4368
- if (kind === "proxy") {
4369
- console.warn(
4370
- "⚠️ config.json proxy was discarded because it is not a non-empty resolved string — configured proxy routing is disabled; existing proxy environment variables remain authoritative, otherwise outbound requests use direct egress",
4371
- );
4372
- } else if (kind === "noProxy") {
4373
- console.warn(
4374
- "⚠️ config.json noProxy was discarded because it is not a string, string array, or resolved environment reference — existing NO_PROXY and loopback bypasses remain",
4375
- );
4376
- } else {
4377
- console.warn(
4378
- "⚠️ config.json noProxy contains invalid elements — invalid elements were ignored; valid entries, existing NO_PROXY, and loopback bypasses remain",
4379
- );
4380
- }
4381
- }
4382
-
4383
- /**
4384
- * Mirror `config.proxy` into HTTP(S)_PROXY env vars. Bun fetch consumes them natively; transports
4385
- * such as the ChatGPT upstream WebSocket select the same environment explicitly. User-set HTTP(S)_PROXY
4386
- * variables win; config fills missing scheme proxies, which take precedence over ALL_PROXY for WS.
4387
- * localhost/127.0.0.1 are appended to NO_PROXY so the CLI's own health checks and
4388
- * running-proxy API calls stay direct. Call once per process entry that makes outbound provider
4389
- * requests (server start, catalog sync).
4390
- */
4391
- export function applyProxyEnv(config: OcxConfig): void {
4392
- applyProxyEnvWith(config);
4393
- }
4394
-
4395
- /** Test seam for `proxy: "auto"`: the registry reader and platform are injectable. */
4396
- export function applyProxyEnvWith(
4397
- config: OcxConfig,
4398
- auto: { reader?: WindowsProxyRegistryReader; platform?: NodeJS.Platform } = {},
4399
- ): void {
4400
- // `proxy` and `noProxy` are not declared in the top-level schema, which ends in
4401
- // `.passthrough()`, so whatever is on disk arrives here verbatim. A non-string value
4402
- // reached string-only methods and threw out of this function, and it runs once per
4403
- // process entry point — the failure was a startup crash, not a degraded proxy. Ignore
4404
- // malformed values with a privacy-safe warning instead: they cannot express a routing
4405
- // intent, and refusing to start is a worse answer than starting without them.
4406
- const rawProxy = config.proxy;
4407
- let proxy = typeof rawProxy === "string" ? resolveEnvValue(rawProxy) : undefined;
4408
- if (!proxy) {
4409
- if (rawProxy !== undefined) warnProxyConfigDiscardOnce("proxy");
4410
- return;
4411
- }
4412
- if (proxy.trim().toLowerCase() === "auto") {
4413
- // #1525 slice 1: one startup read of the Windows static proxy. Never copy the literal
4414
- // "auto" into HTTP_PROXY; every non-proxy outcome leaves outbound routing as it was.
4415
- if (process.env.HTTP_PROXY?.trim() || process.env.http_proxy?.trim()
4416
- || process.env.HTTPS_PROXY?.trim() || process.env.https_proxy?.trim()) {
4417
- console.log("[opencodex] proxy \"auto\": existing HTTP_PROXY/HTTPS_PROXY environment wins; system proxy not consulted");
4418
- proxy = undefined;
4419
- } else {
4420
- const found = readWindowsSystemProxy(auto.reader, auto.platform);
4421
- if (found.kind === "proxy") {
4422
- console.log(`[opencodex] proxy "auto": using Windows system proxy ${describeProxyForLog(found.url)}`);
4423
- proxy = found.url;
4424
- } else {
4425
- const reason = found.kind === "unsupported"
4426
- ? "only Windows system proxy discovery is supported; using direct egress on this OS"
4427
- : found.kind === "disabled"
4428
- ? "Windows system proxy is disabled; using direct egress"
4429
- : found.kind === "socks-only"
4430
- ? "Windows system proxy is SOCKS-only, which HTTP_PROXY cannot express; using direct egress"
4431
- : "Windows proxy settings could not be read; using direct egress";
4432
- console.log(`[opencodex] proxy "auto": ${reason}`);
4433
- proxy = undefined;
4434
- }
4435
- }
4436
- }
4437
- if (proxy) {
4438
- if (!process.env.HTTP_PROXY?.trim() && !process.env.http_proxy?.trim()) process.env.HTTP_PROXY = proxy;
4439
- if (!process.env.HTTPS_PROXY?.trim() && !process.env.https_proxy?.trim()) process.env.HTTPS_PROXY = proxy;
4440
- }
4441
- const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? "";
4442
- const entries = existing.split(",").map(s => s.trim()).filter(Boolean);
4443
- const seen = new Set(entries.map(e => e.toLowerCase()));
4444
- // Configured entries first, then loopback: loopback is unconditional, so appending it last
4445
- // keeps it present even when the operator lists a loopback host themselves.
4446
- const raw = config.noProxy;
4447
- let configuredEntries: string[];
4448
- if (Array.isArray(raw)) {
4449
- // One unusable element must not discard the operator's other entries.
4450
- if (raw.some(entry => typeof entry !== "string")) warnProxyConfigDiscardOnce("noProxyElements");
4451
- configuredEntries = raw.filter((entry): entry is string => typeof entry === "string");
4452
- } else if (typeof raw === "string") {
4453
- const resolved = resolveEnvValue(raw);
4454
- if (raw && resolved === undefined) warnProxyConfigDiscardOnce("noProxy");
4455
- configuredEntries = (resolved ?? "").split(",");
4456
- } else {
4457
- if (raw !== undefined) warnProxyConfigDiscardOnce("noProxy");
4458
- configuredEntries = [];
4459
- }
4460
- const configured = configuredEntries
4461
- .map(entry => entry.trim())
4462
- .filter(Boolean);
4463
- for (const host of [...configured, "localhost", "127.0.0.1", "::1", "[::1]"]) {
4464
- const key = host.toLowerCase();
4465
- if (!seen.has(key)) {
4466
- entries.push(host);
4467
- seen.add(key);
4468
- }
4469
- }
4470
- process.env.NO_PROXY = entries.join(",");
4471
- }
4472
-
4473
- function warnConfigRepaired(configPath: string, error: z.ZodError): void {
4474
- if (warnedConfigFallbacks.has(configPath)) return;
4475
- warnedConfigFallbacks.add(configPath);
4476
- const fields = error.issues.map(i => i.path.join(".") || "config").join(", ");
4477
- console.error(`opencodex config at ${configPath}: repaired missing field(s) [${fields}] with defaults. Your providers and accounts are preserved.`);
4478
- }
4479
-
4480
- /**
4481
- * Sections whose entries are independent of one another, so one bad entry is
4482
- * safe to drop without changing what the rest mean.
4483
- *
4484
- * Both are validated entry-by-entry in the `superRefine` above, which raises
4485
- * every finding as a *document*-level issue. That is what made a single routing
4486
- * candidate naming a disabled provider discard the operator's whole config —
4487
- * all eleven providers, every API key, and the entire `modelCosts` table —
4488
- * while the proxy carried on serving from built-in defaults and reporting
4489
- * healthy.
4490
- */
4491
- const SALVAGEABLE_CONFIG_SECTIONS = ["routingProfiles", "combos"] as const;
4492
-
4493
- /** Optional nested fields that can be dropped whole without changing the rest of the document. */
4494
- const SALVAGEABLE_OPTIONAL_FIELDS: ReadonlyArray<readonly [string, string]> = [
4495
- ["claudeCode", "desktopProfile"],
4496
- ];
4497
-
4498
- function isSalvageableConfigPath(section: string, id: string): boolean {
4499
- if ((SALVAGEABLE_CONFIG_SECTIONS as readonly string[]).includes(section)) return true;
4500
- return SALVAGEABLE_OPTIONAL_FIELDS.some(path => path[0] === section && path[1] === id);
4501
- }
4502
-
4503
- /**
4504
- * Drop just the named entries a parse failure blamed, so the rest of the
4505
- * document survives.
4506
- *
4507
- * Returns `null` when the failure was not confined to those sections — the
4508
- * caller then keeps its existing behaviour rather than guessing.
4509
- *
4510
- * The whole entry goes, not the individual offending candidate. A routing
4511
- * profile that quietly loses one candidate still routes, just not where the
4512
- * operator said it should, and a policy that silently changed shape is a worse
4513
- * outcome than one that is plainly absent. Absent is also the loud option: a
4514
- * dry-run against it answers `unknown_profile`, which — paired with the warning
4515
- * this emits — points at the real mistake.
4516
- */
4517
- function dropInvalidConfigSections(
4518
- parsed: unknown,
4519
- error: z.ZodError,
4520
- ): { candidate: Record<string, unknown>; dropped: string[] } | null {
4521
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
4522
-
4523
- const doomed = new Map<string, Set<string>>();
4524
- for (const issue of error.issues) {
4525
- if (isUnsalvageableIssue(issue)) return null;
4526
- const [section, id] = issue.path;
4527
- if (typeof section !== "string" || typeof id !== "string") return null;
4528
- if (!isSalvageableConfigPath(section, id)) return null;
4529
- // A complaint about the container itself ("combos must be an object") is
4530
- // not about one entry, so there is nothing selective to drop.
4531
- if (issue.path.length < 2) return null;
4532
- let ids = doomed.get(section);
4533
- if (!ids) doomed.set(section, ids = new Set());
4534
- ids.add(id);
4535
- }
4536
- if (doomed.size === 0) return null;
4537
-
4538
- const candidate: Record<string, unknown> = { ...(parsed as Record<string, unknown>) };
4539
- const dropped: string[] = [];
4540
- for (const [section, ids] of doomed) {
4541
- const current = candidate[section];
4542
- if (!current || typeof current !== "object" || Array.isArray(current)) return null;
4543
- const kept: Record<string, unknown> = {};
4544
- for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
4545
- if (ids.has(key)) dropped.push(`${section}.${key}`);
4546
- else kept[key] = value;
4547
- }
4548
- candidate[section] = kept;
4549
- }
4550
- return dropped.length > 0 ? { candidate, dropped } : null;
4551
- }
4552
-
4553
- /**
4554
- * Salvage until the document parses, not just once.
4555
- *
4556
- * One pass is not enough because the sections depend on each other: routing
4557
- * profiles are validated against the combo map, so dropping an invalid combo can
4558
- * expose a profile that referenced it. A single-pass salvage sees that second
4559
- * failure and gives up, discarding the whole config -- the exact outcome this
4560
- * code exists to prevent.
4561
- *
4562
- * `rawDocument` is the operator's document before defaults were merged in. When
4563
- * supplied, the same entries are deleted from it too, so a diagnostics caller can
4564
- * still tell an absent optional setting from one we injected.
4565
- */
4566
-
4567
- /**
4568
- * Findings that must never be salvaged away.
4569
- *
4570
- * Salvage removes the entry a finding blamed, which is right for an ordinary
4571
- * validation mistake and wrong for a namespace collision: the collision is a
4572
- * *relationship* between a combo/profile and a Codex account selector, and it is
4573
- * reported on the combo. Dropping that combo makes the document parse and quietly
4574
- * admits the account selector the schema just refused, turning a hard admission
4575
- * boundary into a config that loads. Refuse the whole document instead.
4576
- */
4577
- const UNSALVAGEABLE_ISSUE_MESSAGES: readonly string[] = [
4578
- CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
4579
- ];
4580
-
4581
- function isUnsalvageableIssue(issue: z.ZodIssue): boolean {
4582
- return UNSALVAGEABLE_ISSUE_MESSAGES.some(message => issue.message.includes(message));
4583
- }
4584
- function salvageConfigCandidate(
4585
- merged: unknown,
4586
- initialError: z.ZodError,
4587
- rawDocument?: unknown,
4588
- ): {
4589
- candidate: Record<string, unknown>;
4590
- rawCandidate: unknown;
4591
- parsed: OcxConfig;
4592
- dropped: string[];
4593
- issues: z.ZodIssue[];
4594
- } | null {
4595
- let candidate: unknown = merged;
4596
- let rawCandidate: unknown = rawDocument;
4597
- let error = initialError;
4598
- const dropped: string[] = [];
4599
- const issues: z.ZodIssue[] = [];
4600
- // Bounded by construction: every pass must remove at least one entry, and there
4601
- // are only so many entries to remove.
4602
- const budget = countSalvageableEntries(merged) + 1;
4603
- for (let pass = 0; pass < budget; pass++) {
4604
- const step = dropInvalidConfigSections(candidate, error);
4605
- if (!step || step.dropped.length === 0) return null;
4606
- dropped.push(...step.dropped);
4607
- issues.push(...error.issues);
4608
- candidate = step.candidate;
4609
- rawCandidate = deleteEntryPaths(rawCandidate, step.dropped);
4610
- const result = configSchema.safeParse(candidate);
4611
- if (result.success) {
4612
- return { candidate: step.candidate, rawCandidate, parsed: result.data as OcxConfig, dropped, issues };
4613
- }
4614
- error = result.error;
4615
- }
4616
- return null;
4617
- }
4618
-
4619
- function countSalvageableEntries(document: unknown): number {
4620
- if (!document || typeof document !== "object" || Array.isArray(document)) return 0;
4621
- let total = 0;
4622
- for (const section of SALVAGEABLE_CONFIG_SECTIONS) {
4623
- const value = (document as Record<string, unknown>)[section];
4624
- if (value && typeof value === "object" && !Array.isArray(value)) {
4625
- total += Object.keys(value as Record<string, unknown>).length;
4626
- }
4627
- }
4628
- for (const [section, id] of SALVAGEABLE_OPTIONAL_FIELDS) {
4629
- const container = (document as Record<string, unknown>)[section];
4630
- if (container && typeof container === "object" && !Array.isArray(container)
4631
- && Object.hasOwn(container as Record<string, unknown>, id)) {
4632
- total += 1;
4633
- }
4634
- }
4635
- return total;
4636
- }
4637
-
4638
- /** Delete `section.id` entries from a copy of the raw document. */
4639
- function deleteEntryPaths(document: unknown, entryPaths: readonly string[]): unknown {
4640
- if (!document || typeof document !== "object" || Array.isArray(document)) return document;
4641
- const next: Record<string, unknown> = { ...(document as Record<string, unknown>) };
4642
- for (const entryPath of entryPaths) {
4643
- const separator = entryPath.indexOf(".");
4644
- if (separator <= 0) continue;
4645
- const section = entryPath.slice(0, separator);
4646
- const id = entryPath.slice(separator + 1);
4647
- const container = next[section];
4648
- if (!container || typeof container !== "object" || Array.isArray(container)) continue;
4649
- const kept: Record<string, unknown> = { ...(container as Record<string, unknown>) };
4650
- delete kept[id];
4651
- next[section] = kept;
4652
- }
4653
- return next;
4654
- }
4655
-
4656
- /**
4657
- * Entry ids are operator-chosen and can be token-shaped, so nothing dynamic reaches
4658
- * the log unredacted. Static section names stay readable -- they are the part that
4659
- * tells the operator where to look.
4660
- */
4661
- function redactEntryPath(entryPath: string): string {
4662
- const separator = entryPath.indexOf(".");
4663
- if (separator <= 0) return redactSecretString(entryPath);
4664
- return entryPath.slice(0, separator) + "." + redactSecretString(entryPath.slice(separator + 1));
4665
- }
4666
-
4667
- function redactIssuePath(path: readonly PropertyKey[]): string {
4668
- return path
4669
- .map((segment, index) => (index === 0 && typeof segment === "string" ? segment : redactSecretString(String(segment))))
4670
- .join(".");
4671
- }
4672
-
4673
- function warnDroppedConfigSections(configPath: string, dropped: string[], issues: readonly z.ZodIssue[]): void {
4674
- if (warnedConfigFallbacks.has(configPath)) return;
4675
- warnedConfigFallbacks.add(configPath);
4676
- const reasons = issues
4677
- .map(issue => `${redactIssuePath(issue.path)}: ${redactSecretString(issue.message)}`)
4678
- .join("; ");
4679
- console.error(
4680
- `opencodex config at ${configPath}: dropped [${dropped.map(redactEntryPath).join(", ")}] and loaded the rest — ${reasons}. `
4681
- + "Everything else in your config, including providers and modelCosts, is preserved.",
4682
- );
4683
- }
4684
-
4685
- function warnAndBackupInvalidConfig(configPath: string, error: unknown): void {
4686
- if (warnedConfigFallbacks.has(configPath)) return;
4687
- warnedConfigFallbacks.add(configPath);
4688
-
4689
- const backupPath = backupInvalidConfig(configPath);
4690
- const reason = error instanceof z.ZodError
4691
- ? error.issues.map(issue => `${issue.path.join(".") || "config"}: ${issue.message}`).join("; ")
4692
- : error instanceof Error ? error.message : String(error);
4693
- const backupNote = backupPath ? ` A backup was written to ${backupPath}.` : "";
4694
- console.error(`Could not load opencodex config at ${configPath}: ${reason}. Using default config.${backupNote}`);
4695
- }
4696
-
4697
- export function backupInvalidConfig(configPath: string): string | null {
4698
- if (!existsSync(configPath)) return null;
4699
- const backupPath = `${configPath}.invalid-${new Date().toISOString().replace(/[:.]/g, "-")}`;
4700
- try {
4701
- copyFileSync(configPath, backupPath);
4702
- try { chmodSync(backupPath, 0o600); } catch { /* best-effort */ }
4703
- return backupPath;
4704
- } catch {
4705
- return null;
4706
- }
4707
- }