@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
@@ -0,0 +1,855 @@
1
+ import * as z from "zod/v4";
2
+ import { join } from "node:path";
3
+ import { isValidProviderName } from "../provider-name";
4
+ import {
5
+ modelPinnedEffortsConfigError,
6
+ pinnedReasoningEffortConfigError,
7
+ modelDisplayNamesConfigError,
8
+ autoReviewModelOverridesConfigError,
9
+ autoReviewModelTargetConfigError,
10
+ normalizeNonBlankStringArray,
11
+ normalizeAutoReviewModelOverrides,
12
+ modelCapabilitiesConfigError,
13
+ mergeModelCapabilities,
14
+ } from "../provider-validation";
15
+ import { isValidCodexAccountNamespaceTarget } from "../../codex/account-namespace-match";
16
+ import { isCodexAccountPriorityKey } from "../../codex/account-priority";
17
+ import { parseAccountPriority } from "../../codex/pool-rotation";
18
+ import { credentialGroupIssues } from "../../routing/identity-domains";
19
+ import { providerDestinationConfigError } from "../../lib/destination-policy";
20
+ import { redactSecretString } from "../../lib/redact";
21
+ import {
22
+ MODEL_ADAPTER_OVERRIDE_ALLOWED,
23
+ pinnedWireAdapter,
24
+ PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS,
25
+ UPSTREAM_HTTP_VERSION_VALUES,
26
+ type OcxProviderConfig,
27
+ type FastWire,
28
+ type ProviderCostOverlay,
29
+ } from "../../types";
30
+ import { fastWireDeclarationError } from "../../providers/fastwire";
31
+ import { getProviderRegistryEntry, providerMatchesRegistryTransport, providerModelWireDefault } from "../../providers/registry";
32
+ import { resolveOpenAiVirtualModel } from "../../providers/openai-virtual-models";
33
+ import { COST4_RATE_KEYS, isValidCost4Rate } from "../../usage/user-cost-overlays";
34
+ import { MAX_COST4_RATE } from "../../usage/expected-prices";
35
+ import { isHostedToolUnsupportedForModel } from "../../responses/hosted-tool-policy";
36
+ import { getConfigDir } from "../paths";
37
+
38
+ /** One definition of "usable secret", shared by the schema and the warnings. */
39
+ export function isUsableApiKeySecret(value: unknown): value is string {
40
+ return typeof value === "string" && value.length > 0 && value === value.trim();
41
+ }
42
+
43
+ /**
44
+ * Bounds for the opt-in same-target 429 wait-and-retry policy. Single source of truth
45
+ * shared by the config schema, the load-time sanitizer, and the management write
46
+ * boundary. Strict, so an unknown key is rejected at every validation boundary instead
47
+ * of being silently ignored (the load-time sanitizer still degrades unknown keys with a
48
+ * warning before schema validation, so hand-edited configs keep loading).
49
+ */
50
+ export const retryOn429PolicySchema = z.object({
51
+ enabled: z.boolean().optional(),
52
+ attempts: z.number().int().min(1).max(20).optional(),
53
+ intervalMs: z.number().int().min(100).max(600_000).optional(),
54
+ // The effective cap for a single wait is MAX_COOLDOWN_MS (10 min) in key-failover.ts;
55
+ // larger configured values would be dead config.
56
+ maxIntervalMs: z.number().int().min(100).max(600_000).optional(),
57
+ respectRetryAfter: z.boolean().optional(),
58
+ }).strict();
59
+
60
+ /**
61
+ * `transientRetryOn5xx` accepts only these keys. `attempts` is a TOTAL send budget shared by
62
+ * both retry layers, so the ceiling is deliberately lower than `retryOn429`'s: 10 total sends
63
+ * against an already-failing provider is already generous.
64
+ */
65
+ const transientRetryOn5xxPolicySchema = z.object({
66
+ enabled: z.boolean().optional(),
67
+ attempts: z.number().int().min(1).max(10).optional(),
68
+ }).strict();
69
+
70
+ const requestPacingRuleSchema = z.object({
71
+ // Keep the RPM-derived timer within the same one-hour bound as minIntervalMs.
72
+ requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(),
73
+ minIntervalMs: z.number().int().min(1).max(3_600_000).optional(),
74
+ }).strict().refine(value => value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined, {
75
+ message: "request pacing rules need requestsPerMinute or minIntervalMs",
76
+ });
77
+
78
+ const requestPacingSchema = z.object({
79
+ enabled: z.boolean(),
80
+ requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(),
81
+ minIntervalMs: z.number().int().min(1).max(3_600_000).optional(),
82
+ models: z.record(z.string().trim().min(1), requestPacingRuleSchema).optional(),
83
+ }).strict().refine(value => value.enabled === false
84
+ || value.requestsPerMinute !== undefined
85
+ || value.minIntervalMs !== undefined
86
+ || (value.models !== undefined && Object.keys(value.models).length > 0), {
87
+ message: "enabled request pacing needs a provider rule or model override",
88
+ });
89
+
90
+ export function requestPacingConfigError(value: unknown): string | null {
91
+ if (value === undefined) return null;
92
+ const parsed = requestPacingSchema.safeParse(value);
93
+ if (parsed.success) return null;
94
+ return "requestPacing must contain enabled and a valid requestsPerMinute/minIntervalMs provider rule or model overrides";
95
+ }
96
+
97
+ /**
98
+ * Bounds for the opt-in passthrough web-search bridge (`providers.<name>.webSearchBridge`,
99
+ * #3761). Strict for the same reason `retryOn429` is: a misspelled key here would silently
100
+ * leave the bridge disarmed while the operator believes they enabled it.
101
+ *
102
+ * `endpoint` names the destination that receives this provider's API key, so it gets the same
103
+ * literal destination assessment `baseUrl` gets (#4519) — see `providerWebSearchBridgeConfigError`
104
+ * below. This schema itself still only shape-checks: it is `.catch(undefined)` at the provider
105
+ * row, and a hand-edited config file never reaches the error function at all. The authorization
106
+ * boundary is therefore `resolveOllamaWebSearchEndpoint`, which runs the same assessment and is
107
+ * the only reader of this field in the tree; config validation is where an operator is told why,
108
+ * not what makes the value safe.
109
+ */
110
+ const providerWebSearchBridgeSchema = z.object({
111
+ enabled: z.boolean().optional(),
112
+ backend: z.enum(PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS).optional(),
113
+ maxSearches: z.number().int().min(1).max(10).optional(),
114
+ timeoutMs: z.number().int().min(1_000).max(600_000).optional(),
115
+ endpoint: z.string().min(1).optional(),
116
+ }).strict();
117
+
118
+ export function providerWebSearchBridgeConfigError(
119
+ value: unknown,
120
+ providerName: string,
121
+ provider: Pick<OcxProviderConfig, "allowPrivateNetwork">,
122
+ ): string | null {
123
+ if (value === undefined) return null;
124
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
125
+ return "webSearchBridge must be a plain object";
126
+ }
127
+ const parsed = providerWebSearchBridgeSchema.safeParse(value);
128
+ if (!parsed.success) {
129
+ return "webSearchBridge accepts only enabled (boolean), backend "
130
+ + `(${PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS.join("|")}), maxSearches (1..10), `
131
+ + "timeoutMs (1000..600000), and endpoint (absolute http(s) URL)";
132
+ }
133
+ const endpoint = parsed.data.endpoint;
134
+ if (endpoint !== undefined) {
135
+ let url: URL;
136
+ try {
137
+ url = new URL(endpoint);
138
+ } catch {
139
+ return "webSearchBridge.endpoint must be an absolute http(s) URL";
140
+ }
141
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
142
+ return "webSearchBridge.endpoint must be an absolute http(s) URL";
143
+ }
144
+ // Same classifier baseUrl uses, so a metadata address is refused outright and loopback or
145
+ // private space needs the provider's allowPrivateNetwork opt-in (or a registry entry that is
146
+ // local by definition, which is what keeps a self-hosted Ollama working). Literal-only and
147
+ // synchronous, exactly as at the baseUrl boundary: no DNS is resolved here.
148
+ const destinationError = providerDestinationConfigError(providerName, {
149
+ baseUrl: endpoint,
150
+ allowPrivateNetwork: provider.allowPrivateNetwork,
151
+ });
152
+ if (destinationError) {
153
+ return destinationError.replace(/^baseUrl/, "webSearchBridge.endpoint");
154
+ }
155
+ }
156
+ return null;
157
+ }
158
+
159
+ const fastWireSchema = z.object({
160
+ kind: z.string(),
161
+ canonicalToWire: z.record(z.string().trim(), z.string().trim()),
162
+ foreignCallerTiers: z.string(),
163
+ betas: z.array(z.string().trim()).optional(),
164
+ }).strict().superRefine((fastWire, ctx) => {
165
+ const error = fastWireDeclarationError({ fastWire });
166
+ if (error) ctx.addIssue({ code: "custom", message: error });
167
+ }).transform(fastWire => fastWire as FastWire);
168
+
169
+ const modelDisplayNamesSchema = z.unknown().superRefine((value, ctx) => {
170
+ const error = modelDisplayNamesConfigError(value);
171
+ if (error) ctx.addIssue({ code: "custom", message: error });
172
+ }).transform(value => {
173
+ const labels = Object.create(null) as Record<string, string>;
174
+ for (const [modelId, displayName] of Object.entries(value as Record<string, string>)) {
175
+ labels[modelId] = displayName;
176
+ }
177
+ return labels;
178
+ });
179
+
180
+ const pinnedReasoningEffortSchema = z.unknown().superRefine((value, ctx) => {
181
+ const error = pinnedReasoningEffortConfigError(value);
182
+ if (error) ctx.addIssue({ code: "custom", message: error });
183
+ }).transform(value => value as string);
184
+
185
+ export const modelPinnedEffortsSchema = z.unknown().superRefine((value, ctx) => {
186
+ const error = modelPinnedEffortsConfigError(value);
187
+ if (error) ctx.addIssue({ code: "custom", message: error });
188
+ }).transform(value => Object.fromEntries(
189
+ Object.entries(value as Record<string, string>).map(([key, effort]) => [key.trim(), effort]),
190
+ ));
191
+
192
+ const autoReviewModelSchema = z.unknown().superRefine((value, ctx) => {
193
+ const error = autoReviewModelTargetConfigError(value, "autoReviewModel", true);
194
+ if (error) ctx.addIssue({ code: "custom", message: error });
195
+ }).transform(value => {
196
+ if (typeof value !== "string") return undefined;
197
+ const trimmed = value.trim();
198
+ return trimmed ? trimmed : undefined;
199
+ });
200
+
201
+ const autoReviewModelOverridesSchema = z.unknown().superRefine((value, ctx) => {
202
+ const error = autoReviewModelOverridesConfigError(value, "autoReviewModelOverrides", true);
203
+ if (error) ctx.addIssue({ code: "custom", message: error });
204
+ }).transform(value => normalizeAutoReviewModelOverrides(value));
205
+
206
+ const modelCapabilitiesSchema = z.unknown().superRefine((value, ctx) => {
207
+ const error = modelCapabilitiesConfigError(value);
208
+ if (error) ctx.addIssue({ code: "custom", message: error });
209
+ }).transform(value => mergeModelCapabilities(undefined, value));
210
+
211
+ /**
212
+ * Zod schema for one provider entry: known fields are validated strictly while unknown
213
+ * fields pass through (preserved for runtime extensions).
214
+ */
215
+ export const providerConfigSchema = z.object({
216
+ modelCapabilities: modelCapabilitiesSchema.optional(),
217
+ pinnedReasoningEffort: pinnedReasoningEffortSchema.optional(),
218
+ modelPinnedReasoningEfforts: modelPinnedEffortsSchema.optional(),
219
+ // Validated rather than left to passthrough: an unrecognized strategy would otherwise
220
+ // load silently and then be ignored at selection time, which reads as a broken feature
221
+ // rather than a rejected setting.
222
+ apiKeyPoolStrategy: z.enum(["round-robin", "fill-first", "quota"]).optional(),
223
+ autoReviewModel: autoReviewModelSchema.optional(),
224
+ autoReviewModelOverrides: autoReviewModelOverridesSchema.optional(),
225
+ adapter: z.string().min(1),
226
+ baseUrl: z.string().min(1),
227
+ alias: z.string().optional(),
228
+ modelAliases: z.record(z.string(), z.string()).optional(),
229
+ modelDisplayNames: modelDisplayNamesSchema.optional(),
230
+ defaultAliases: z.boolean().optional(),
231
+ initialModelSelection: z.object({
232
+ version: z.literal(1),
233
+ registrationId: z.uuid(),
234
+ status: z.enum(["pending", "ready", "all-off"]),
235
+ modelCount: z.number().int().nonnegative().optional(),
236
+ }).optional().catch(undefined),
237
+ requestPacing: requestPacingSchema.optional().catch(undefined),
238
+ mcpMaxTools: z.number().int().positive().optional(),
239
+ mcpMaxSchemaBytes: z.number().int().positive().optional(),
240
+ mcpMaxResultBytes: z.number().int().positive().optional(),
241
+ apiKeyTransport: z.enum(["x-api-key", "bearer"]).optional(),
242
+ responsesPath: z.string().min(1).optional(),
243
+ chatCompletionsPath: z.string().min(1).optional(),
244
+ statelessResponses: z.boolean().optional(),
245
+ requiresAdjacentResponsesToolResults: z.boolean().optional(),
246
+ annotateEmptyToolOutputs: z.boolean().optional(),
247
+ fastWire: fastWireSchema.nullable().optional(),
248
+ supportsServiceTier: z.boolean().optional(),
249
+ modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(),
250
+ preserveResponsesReasoningContent: z.boolean().optional(),
251
+ decodesNativeCompactionBlobs: z.boolean().optional(),
252
+ allowEncryptedV2AgentTasks: z.boolean().optional(),
253
+ allowPrivateNetwork: z.boolean().optional(),
254
+ // The management API accepts `null` as "clear this", so a config written before the POST
255
+ // canonicalization below can hold one on disk. Rejecting it here would send the operator
256
+ // through invalid-config recovery for a value the API told them was fine.
257
+ upstreamHttpVersion: z.enum(UPSTREAM_HTTP_VERSION_VALUES)
258
+ .nullish()
259
+ .transform(value => value ?? undefined),
260
+ // Opt-in upstream Responses WebSocket for OpenAI-compatible providers (e.g.
261
+ // aggregators whose WebSocket ingress is measurably faster than SSE). The
262
+ // canonical ChatGPT backend WS selection is independent of this flag.
263
+ upstreamWebsocket: z.boolean().optional(),
264
+ directGeminiWireRenames: z.boolean().optional(),
265
+ noStructuredOutputModels: z.array(z.string().min(1))
266
+ .transform(normalizeNonBlankStringArray)
267
+ .optional(),
268
+ noJsonSchemaModels: z.array(z.string().min(1))
269
+ .transform(normalizeNonBlankStringArray)
270
+ .optional(),
271
+ retainModels: z.array(z.string().min(1))
272
+ .transform(normalizeNonBlankStringArray)
273
+ .optional(),
274
+ omitReasoningEffortWithToolsModels: z.array(z.string().min(1))
275
+ .transform(normalizeNonBlankStringArray)
276
+ .optional(),
277
+ retryOn429: retryOn429PolicySchema.optional(),
278
+ transientRetryOn5xx: transientRetryOn5xxPolicySchema.optional(),
279
+ codexAccountMode: z.enum(["pool", "direct"]).optional(),
280
+ // Validated rather than passed through: this schema ends in `.passthrough()`, so an
281
+ // undeclared key survives verbatim. A misspelled `codexToolMode` therefore used to be
282
+ // accepted, persisted, and then silently resolved to the `code_mode_only` default — the
283
+ // operator asked for shell mode, got code mode, and was told nothing (#2106).
284
+ codexToolMode: z.enum(["code_mode_only", "shell"]).optional(),
285
+ responsesItemIdRepair: z.object({
286
+ message: z.array(z.string().min(1)).optional(),
287
+ reasoning: z.array(z.string().min(1)).optional(),
288
+ repairMissingTerminalIds: z.boolean().optional(),
289
+ repairInvalidIds: z.boolean().optional(),
290
+ }).strict().optional(),
291
+ responsesSnapshotRepair: z.boolean().optional(),
292
+ // Invalid blocks degrade to "absent" rather than failing the whole config load: an unusable
293
+ // bridge block must never send an operator through invalid-config recovery for an opt-in
294
+ // feature that is off by default. The management write boundary still rejects it loudly.
295
+ webSearchBridge: providerWebSearchBridgeSchema.optional().catch(undefined),
296
+ xaiResponsesXSearch: z.boolean().optional(),
297
+ xaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined),
298
+ zaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined),
299
+ }).passthrough();
300
+
301
+
302
+ /**
303
+ * Shared shape check for the two relative send-path overrides. `field` names the
304
+ * offending key so the message stays specific to what the user actually wrote.
305
+ */
306
+ export function providerRelativeSendPathConfigError(field: string, value: string | undefined): string | null {
307
+ if (value === undefined) return null;
308
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) || value.includes("://")) {
309
+ return `${field} must be a relative path without a URL scheme`;
310
+ }
311
+ if (!value.startsWith("/")) return `${field} must start with /`;
312
+ if (value.includes("?") || value.includes("#")) {
313
+ return `${field} must not include query strings or fragments`;
314
+ }
315
+ return null;
316
+ }
317
+
318
+ /**
319
+ * Validate `providers.<name>.modelCosts`: a plain object keyed by exact model
320
+ * id, each value a 4-tuple of non-negative finite USD-per-1M-token rates.
321
+ * Returns null when valid/absent, else a human-readable error.
322
+ */
323
+ export function providerModelCostsConfigError(value: unknown, field = "modelCosts"): string | null {
324
+ if (value === undefined) return null;
325
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
326
+ return `${field} must be a plain object keyed by model id`;
327
+ }
328
+ for (const [modelId, entry] of Object.entries(value)) {
329
+ if (!modelId.trim()) return `${field} keys must be nonblank model ids`;
330
+ // Redact secret-shaped model ids and JSON-escape control characters so a
331
+ // malformed write cannot echo a pasted key/secret back through the
332
+ // management API response.
333
+ const safeModelId = JSON.stringify(redactSecretString(modelId));
334
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
335
+ return `${field}.${safeModelId} must be an object with input, output, cacheRead, and cacheWrite (USD per 1M tokens)`;
336
+ }
337
+ const rates = entry as Record<string, unknown>;
338
+ for (const key of COST4_RATE_KEYS) {
339
+ const rate = rates[key];
340
+ if (!isValidCost4Rate(rate)) {
341
+ return `${field}.${safeModelId}.${key} must be a non-negative finite number at most ${MAX_COST4_RATE} (USD per 1M tokens)`;
342
+ }
343
+ }
344
+ // Reject unknown fields: a misplaced apiKey/apiKeyPool under a cost row
345
+ // would otherwise be persisted and echoed verbatim by display paths that
346
+ // mask only top-level provider secrets.
347
+ const extraKeys = Object.keys(rates)
348
+ .filter((key) => !(COST4_RATE_KEYS as readonly string[]).includes(key));
349
+ if (extraKeys.length > 0) {
350
+ return `${field}.${safeModelId} has unexpected fields ${JSON.stringify(extraKeys.map(redactSecretString).join(", "))} — only input, output, cacheRead, and cacheWrite are allowed (USD per 1M tokens)`;
351
+ }
352
+ }
353
+ return null;
354
+ }
355
+
356
+ /**
357
+ * Serialize `providers.<name>.modelCosts` for display: copy ONLY the four
358
+ * numeric rate fields per model and DROP secret-shaped model ids, so a pasted
359
+ * API key in a key position cannot be echoed back by CLI/DTO display paths.
360
+ * The result uses a null prototype so "__proto__" remains an own row.
361
+ */
362
+ export function sanitizeModelCostsForDisplay(costs: unknown): Record<string, ProviderCostOverlay> | undefined {
363
+ if (!costs || typeof costs !== "object" || Array.isArray(costs)) return undefined;
364
+ const out = Object.create(null) as Record<string, ProviderCostOverlay>;
365
+ for (const [modelId, entry] of Object.entries(costs)) {
366
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
367
+ const rates = entry as Record<string, unknown>;
368
+ const input = rates.input;
369
+ const output = rates.output;
370
+ const cacheRead = rates.cacheRead;
371
+ const cacheWrite = rates.cacheWrite;
372
+ if (
373
+ isValidCost4Rate(input)
374
+ && isValidCost4Rate(output)
375
+ && isValidCost4Rate(cacheRead)
376
+ && isValidCost4Rate(cacheWrite)
377
+ ) {
378
+ // Secret-shaped ids are DROPPED rather than mapped to "[REDACTED]" so
379
+ // distinct rows cannot collapse into one placeholder key.
380
+ if (redactSecretString(modelId) !== modelId) continue;
381
+ out[modelId] = { input, output, cacheRead, cacheWrite };
382
+ }
383
+ }
384
+ return Object.keys(out).length > 0 ? out : undefined;
385
+ }
386
+
387
+ const SUPPORTED_PREFERRED_HOSTED_TOOLS = new Set(["image_generation"]);
388
+
389
+ export function modelPreferHostedToolsConfigError(
390
+ value: unknown,
391
+ field: string,
392
+ providerName: string,
393
+ provider: { adapter?: unknown; authMode?: unknown; modelAdapters?: unknown; baseUrl?: unknown },
394
+ ): string | null {
395
+ if (value === undefined) return null;
396
+ if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
397
+ const prototype = Object.getPrototypeOf(value);
398
+ if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
399
+ const entries = Object.entries(value);
400
+ const registry = getProviderRegistryEntry(providerName);
401
+ // Effective transport: a `preserveCustomDestination` registry row reused under a
402
+ // different endpoint keeps its own adapter AND its own auth at runtime, because
403
+ // `routedProviderConfig()` honors `providerMatchesRegistryTransport()`. Both the
404
+ // wire check below and the forward-auth check here have to start from the same
405
+ // decision, or validation accepts a preference the adapter never applies —
406
+ // `preferConfiguredHostedTools()` runs only on the non-forward branch.
407
+ const registryTransportMatches = typeof provider.baseUrl === "string"
408
+ && providerMatchesRegistryTransport(providerName, {
409
+ baseUrl: provider.baseUrl,
410
+ adapter: provider.adapter as OcxProviderConfig["adapter"],
411
+ ...(typeof provider.authMode === "string" ? { authMode: provider.authMode as OcxProviderConfig["authMode"] } : {}),
412
+ });
413
+ const effectiveForwardAuth = registryTransportMatches
414
+ ? registry?.authKind === "forward"
415
+ : provider.authMode === "forward";
416
+ if (entries.length > 0 && effectiveForwardAuth) {
417
+ return `${field} is not supported on forward-auth Responses providers`;
418
+ }
419
+ const requestedWireFor = (modelId: string): unknown => provider.modelAdapters
420
+ && typeof provider.modelAdapters === "object"
421
+ && !Array.isArray(provider.modelAdapters)
422
+ ? (provider.modelAdapters as Record<string, unknown>)[modelId]
423
+ : undefined;
424
+ const resolveEffectiveWire = (modelId: string, currentWire: unknown): unknown => {
425
+ const pinned = pinnedWireAdapter(providerName, modelId);
426
+ if (pinned) return pinned;
427
+ const requestedWire = requestedWireFor(modelId);
428
+ if (typeof requestedWire === "string" && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(requestedWire)) {
429
+ return requestedWire;
430
+ }
431
+ // No explicit override: fall back to the registry's per-model wire default before
432
+ // the provider-wide adapter, because that is the order `resolveModelAdapter()`
433
+ // uses at request time (src/server/adapter-resolve.ts:38-48). Skipping it rejected
434
+ // preferences the runtime would have honored — DeepSeek routes `deepseek-v4-flash`
435
+ // over native Responses for a Responses inbound while the provider-wide wire stays
436
+ // openai-chat. Hosted-tool preferences only apply to Responses traffic, so the
437
+ // inbound to ask about is "responses".
438
+ const registryDefault = typeof currentWire === "string" && typeof provider.baseUrl === "string"
439
+ ? providerModelWireDefault(
440
+ providerName,
441
+ {
442
+ baseUrl: provider.baseUrl,
443
+ adapter: currentWire,
444
+ ...(typeof provider.authMode === "string" ? { authMode: provider.authMode as OcxProviderConfig["authMode"] } : {}),
445
+ },
446
+ modelId,
447
+ MODEL_ADAPTER_OVERRIDE_ALLOWED,
448
+ "responses",
449
+ )
450
+ : undefined;
451
+ return registryDefault ?? currentWire;
452
+ };
453
+ for (const [key, entry] of entries) {
454
+ if (!key.trim()) return `${field} keys must be nonblank model ids`;
455
+ if (!Array.isArray(entry)) return `${field}.${key} must be an array`;
456
+ if (entry.length === 0) return `${field}.${key} must include image_generation`;
457
+ for (const tool of entry) {
458
+ if (typeof tool !== "string" || !SUPPORTED_PREFERRED_HOSTED_TOOLS.has(tool)) {
459
+ return `${field}.${key} supports only image_generation`;
460
+ }
461
+ if (isHostedToolUnsupportedForModel(key, tool)) {
462
+ return `${field}.${key} cannot prefer ${tool}: the model does not support it`;
463
+ }
464
+ }
465
+ // Same `registryTransportMatches` decision the forward-auth check above uses:
466
+ // start from the registry adapter only when this config still points at the
467
+ // registry's documented transport.
468
+ const baseWire = registryTransportMatches ? registry?.adapter ?? provider.adapter : provider.adapter;
469
+ let effectiveWire = resolveEffectiveWire(key, baseWire);
470
+ const virtualWireModel = resolveOpenAiVirtualModel(providerName, key)?.wireModelId;
471
+ if (virtualWireModel && virtualWireModel !== key) {
472
+ effectiveWire = resolveEffectiveWire(virtualWireModel, effectiveWire);
473
+ }
474
+ if (effectiveWire !== "openai-responses") {
475
+ return `${field}.${key} requires the openai-responses wire`;
476
+ }
477
+ }
478
+ return null;
479
+ }
480
+
481
+ const CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR =
482
+ "codexAccountNamespaces must be a plain object mapping account selectors to Codex account ids";
483
+ const CODEX_ACCOUNT_NAMESPACE_KEY_ERROR =
484
+ "account selectors must use 1-64 letters, numbers, dots, underscores, or hyphens and cannot be reserved JavaScript object keys";
485
+ const CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR =
486
+ "account selector targets must be @main or valid Codex pool-account ids";
487
+ export const CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR =
488
+ "account selectors must not collide with configured Codex pool-account ids or account selector targets";
489
+
490
+ export function configuredCodexPoolAccountIds(value: unknown): Set<string> {
491
+ const accountIds = new Set<string>();
492
+ if (!Array.isArray(value)) return accountIds;
493
+ for (const account of value) {
494
+ if (!account || typeof account !== "object" || Array.isArray(account)) continue;
495
+ const { id, isMain } = account as { id?: unknown; isMain?: unknown };
496
+ if (typeof id === "string" && isMain !== true) accountIds.add(id);
497
+ }
498
+ return accountIds;
499
+ }
500
+
501
+ export const codexAccountNamespacesSchema = z.custom<Record<string, unknown>>(
502
+ (value): value is Record<string, unknown> => !!value
503
+ && typeof value === "object"
504
+ && !Array.isArray(value)
505
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null),
506
+ { error: CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR },
507
+ ).superRefine((accountNamespaces, ctx) => {
508
+ // Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys.
509
+ for (const [namespace, accountId] of Object.entries(accountNamespaces)) {
510
+ if (!isValidProviderName(namespace)) {
511
+ ctx.addIssue({
512
+ code: "custom",
513
+ path: [namespace],
514
+ message: CODEX_ACCOUNT_NAMESPACE_KEY_ERROR,
515
+ });
516
+ }
517
+ if (!isValidCodexAccountNamespaceTarget(accountId)) {
518
+ ctx.addIssue({
519
+ code: "custom",
520
+ path: [namespace],
521
+ message: CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR,
522
+ });
523
+ }
524
+ }
525
+ }).pipe(z.record(z.string(), z.string()));
526
+
527
+ const CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR =
528
+ "codexAccountPriorities must be a plain object mapping Codex account ids to selection-order integers";
529
+ const CODEX_ACCOUNT_PRIORITY_KEY_ERROR =
530
+ "selection-order keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys";
531
+ const CODEX_ACCOUNT_PRIORITY_VALUE_ERROR =
532
+ "selection order must be an integer between -100 and 100";
533
+
534
+ export const CODEX_ACCOUNT_PIN_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/;
535
+
536
+ export const codexAccountPrioritiesSchema = z.custom<Record<string, unknown>>(
537
+ (value): value is Record<string, unknown> => !!value
538
+ && typeof value === "object"
539
+ && !Array.isArray(value)
540
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null),
541
+ { error: CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR },
542
+ ).superRefine((priorities, ctx) => {
543
+ // Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys.
544
+ for (const [accountId, priority] of Object.entries(priorities)) {
545
+ if (!isCodexAccountPriorityKey(accountId)) {
546
+ ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_KEY_ERROR });
547
+ }
548
+ if (parseAccountPriority(priority) === null) {
549
+ ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_VALUE_ERROR });
550
+ }
551
+ }
552
+ }).pipe(z.record(z.string(), z.number().int()));
553
+
554
+ const codexQuotaAutoRefreshEntrySchema = z.object({
555
+ fiveHour: z.boolean().optional(),
556
+ weekly: z.boolean().optional(),
557
+ lastFiveHourResetAt: z.number().finite().nonnegative().optional(),
558
+ lastWeeklyResetAt: z.number().finite().nonnegative().optional(),
559
+ nextFiveHourResetAt: z.number().finite().nonnegative().optional(),
560
+ nextWeeklyResetAt: z.number().finite().nonnegative().optional(),
561
+ }).strict();
562
+ const CODEX_QUOTA_AUTO_REFRESH_KEY_ERROR =
563
+ "quota auto-refresh keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys";
564
+
565
+ export const codexQuotaAutoRefreshSchema = z.custom<Record<string, unknown>>(
566
+ (value): value is Record<string, unknown> => !!value
567
+ && typeof value === "object"
568
+ && !Array.isArray(value)
569
+ && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null),
570
+ { error: "codexQuotaAutoRefresh must be a plain object" },
571
+ ).superRefine((settings, ctx) => {
572
+ // Inspect own entries before z.record parses them; Zod omits __proto__ record keys.
573
+ for (const [accountId, setting] of Object.entries(settings)) {
574
+ if (!isCodexAccountPriorityKey(accountId)) {
575
+ ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_QUOTA_AUTO_REFRESH_KEY_ERROR });
576
+ }
577
+ const parsed = codexQuotaAutoRefreshEntrySchema.safeParse(setting);
578
+ if (!parsed.success) {
579
+ ctx.addIssue({ code: "custom", path: [accountId], message: "invalid quota auto-refresh setting" });
580
+ }
581
+ }
582
+ }).pipe(z.record(z.string(), codexQuotaAutoRefreshEntrySchema));
583
+
584
+ /**
585
+ * Deliberately permissive. A user's config is not ours to invalidate: a strict
586
+ * entry fails the whole parse, and loadConfig's fallback then backs the file up
587
+ * and returns defaults — losing providers and pool accounts because one key name
588
+ * was too long. Length and charset rules live at the POST/PATCH boundary, where
589
+ * rejecting produces a 400 instead. `.passthrough()` keeps unknown per-key
590
+ * properties across a load -> mutate -> save round trip.
591
+ *
592
+ * Only `key` is load-bearing: admission compares that string and nothing else
593
+ * (src/server/auth-cors.ts isDataPlaneAdmissionSecret). So the secret is the one
594
+ * field that must be a usable string, and every piece of metadata around it
595
+ * degrades instead of taking the credential down with it. Dropping a working key
596
+ * because its `name` was hand-edited to a number would be a silent revocation —
597
+ * and on a remote bind, potentially a server that refuses to start.
598
+ *
599
+ * "Usable" matches admission exactly. The presented token is trimmed before the
600
+ * comparison but the stored value is not, so a key with surrounding whitespace
601
+ * can never match either form of itself. Keeping one would be worse than dropping
602
+ * it: `system-env.ts` and `cli/claude.ts` hand `apiKeys[0].key` to launched
603
+ * clients, so a junk first entry would mask a valid later one.
604
+ */
605
+ const pendingApiKeyRotationSchema = z.object({
606
+ id: z.string().trim().min(1).max(256),
607
+ key: z.string().refine(isUsableApiKeySecret),
608
+ createdAt: z.string().datetime({ offset: true }),
609
+ expiresAt: z.string().datetime({ offset: true }),
610
+ }).strict();
611
+
612
+ export const apiKeyEntrySchema = z.object({
613
+ key: z.string().refine(isUsableApiKeySecret),
614
+ // Degrades to "" here; every schema consumer then runs `normalizeApiKeyIds`,
615
+ // which fills it deterministically so the id is stable across loads.
616
+ id: z.string().catch(""),
617
+ name: z.string().catch(""),
618
+ createdAt: z.string().catch(""),
619
+ // A damaged overlap record must never discard the still-authoritative key.
620
+ pendingRotation: pendingApiKeyRotationSchema.optional().catch(undefined),
621
+ }).passthrough();
622
+
623
+ /**
624
+ * Durable per-client intent.
625
+ *
626
+ * `.passthrough()` is load-bearing: a binary that only knows `codex` must not
627
+ * erase a key a later version wrote during a field-scoped mutation. And each key
628
+ * degrades on its own — a hand edit of `{"codex": "false", "future": false}`
629
+ * drops `codex` to absent (which reads as ON) and keeps `future`, rather than
630
+ * invalidating the object or, worse, the whole config.
631
+ */
632
+ export const clientIntegrationsSchema = z.object({
633
+ codex: z.boolean().optional().catch(undefined),
634
+ grok: z.boolean().optional().catch(undefined),
635
+ "claude-desktop": z.boolean().optional().catch(undefined),
636
+ }).passthrough();
637
+
638
+ export const asideProfileSyncSchema = z.object({
639
+ allProfiles: z.boolean().optional(),
640
+ profiles: z.record(
641
+ z.string().regex(/^(0|[1-9][0-9]*)$/).refine(value => Number.isSafeInteger(Number(value))),
642
+ z.boolean(),
643
+ ).optional(),
644
+ legacyProfileId: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable().optional(),
645
+ }).passthrough();
646
+
647
+ export const agentTaskRecoverySchema = z.object({
648
+ enabled: z.boolean().optional(),
649
+ model: z.string().trim().min(1).optional(),
650
+ timeoutMs: z.number().int().min(1_000).max(120_000).optional(),
651
+ cacheEntries: z.number().int().min(1).max(512).optional(),
652
+ }).strict();
653
+
654
+ export const runtimeRoleSchema = z.enum(["standalone", "hub", "client"]);
655
+
656
+ function canonicalHttpOrigin(value: string): string | null {
657
+ try {
658
+ const parsed = new URL(value);
659
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
660
+ if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return null;
661
+ return parsed.origin;
662
+ } catch {
663
+ return null;
664
+ }
665
+ }
666
+
667
+ export const managementIngressSchema = z.union([
668
+ z.object({ enabled: z.literal(false) }).strict(),
669
+ z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }).strict(),
670
+ ]);
671
+
672
+ export const hubConfigSchema = z.object({
673
+ managementPublicOrigin: z.string().transform((value, ctx) => {
674
+ const origin = canonicalHttpOrigin(value);
675
+ if (!origin) {
676
+ ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" });
677
+ return z.NEVER;
678
+ }
679
+ return origin;
680
+ }).optional(),
681
+ // Same canonical-origin rule as managementPublicOrigin, and deliberately NOT `.catch`ed:
682
+ // a mistyped data origin must be rejected at write time, because silently dropping it
683
+ // makes `ocx hub invite` print the `http://<hostname>:<port>` fallback that the operator
684
+ // set this field precisely to replace.
685
+ dataPublicOrigin: z.string().transform((value, ctx) => {
686
+ const origin = canonicalHttpOrigin(value);
687
+ if (!origin) {
688
+ ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" });
689
+ return z.NEVER;
690
+ }
691
+ return origin;
692
+ }).optional(),
693
+ // A malformed hand edit disables only the optional ingress. Live writes are rejected by
694
+ // managementIngressConfigError before this load-time degradation can hide the mistake.
695
+ managementIngress: managementIngressSchema.optional().catch(undefined),
696
+ }).strict();
697
+
698
+ const tailscaleUserSchema = z.string().trim().min(1).superRefine((value, ctx) => {
699
+ if (new TextEncoder().encode(value).byteLength > 320) {
700
+ ctx.addIssue({ code: "custom", message: "must be at most 320 UTF-8 bytes" });
701
+ }
702
+ if (/[\x00-\x1f\x7f]/.test(value)) {
703
+ ctx.addIssue({ code: "custom", message: "must not contain ASCII control characters" });
704
+ }
705
+ });
706
+
707
+ export const remoteGuiConfigSchema = z.object({
708
+ allowedTailscaleUsers: z.array(tailscaleUserSchema).max(64).superRefine((users, ctx) => {
709
+ const seen = new Set<string>();
710
+ for (let index = 0; index < users.length; index++) {
711
+ const user = users[index]!;
712
+ if (seen.has(user)) {
713
+ ctx.addIssue({ code: "custom", path: [index], message: "must contain unique users after trimming" });
714
+ }
715
+ seen.add(user);
716
+ }
717
+ }).optional(),
718
+ // Retired (see OcxRemoteGuiConfig): accepted so an existing file still loads, ignored by
719
+ // the pairing path. Removing it from a strict schema would reject the whole config.
720
+ allowInsecureHttp: z.boolean().optional(),
721
+ }).strict();
722
+
723
+ const connectedClientIdSchema = z.enum(["codex", "claude"]);
724
+ const clientTimestampSchema = z.string().datetime({ offset: true });
725
+ const clientOriginSchema = z.string().transform((value, ctx) => {
726
+ const origin = canonicalHttpOrigin(value);
727
+ if (!origin) {
728
+ ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" });
729
+ return z.NEVER;
730
+ }
731
+ return origin;
732
+ });
733
+ export const clientConnectionSchema = z.object({
734
+ serverUrl: clientOriginSchema,
735
+ managementUrl: clientOriginSchema,
736
+ managementTransport: z.enum(["direct", "relay"]),
737
+ selectedClients: z.array(connectedClientIdSchema).min(1).max(2).superRefine((clients, ctx) => {
738
+ if (new Set(clients).size !== clients.length) {
739
+ ctx.addIssue({ code: "custom", message: "must contain unique client ids" });
740
+ }
741
+ }),
742
+ tokenEnv: z.literal("OPENCODEX_API_AUTH_TOKEN"),
743
+ apiKeyId: z.string().trim().min(1).max(256),
744
+ tokenFingerprint: z.string().regex(/^[a-f0-9]{64}$/),
745
+ protocolVersion: z.literal(1),
746
+ connectedAt: clientTimestampSchema,
747
+ catalogFingerprint: z.string().min(1).max(512).optional(),
748
+ // base64 of the pre-connect catalog, or "" for "there was none". Bounded above the
749
+ // catalog size cap so a legitimate snapshot round-trips.
750
+ priorCatalog: z.string().max(64 * 1024 * 1024).optional(),
751
+ catalogSyncedAt: clientTimestampSchema.optional(),
752
+ pendingOperation: z.object({
753
+ kind: z.literal("rotate"),
754
+ rotationId: z.string().trim().min(1).max(256),
755
+ newKeyIssuedAt: clientTimestampSchema,
756
+ oldKeyBackupPath: z.string().min(1),
757
+ }).strict().superRefine((operation, ctx) => {
758
+ const expected = join(getConfigDir(), "service-api-token.prev");
759
+ if (operation.oldKeyBackupPath !== expected) {
760
+ ctx.addIssue({ code: "custom", path: ["oldKeyBackupPath"], message: `must equal ${expected}` });
761
+ }
762
+ }).optional(),
763
+ }).strict();
764
+
765
+ /**
766
+ * Codex pool selection policy section.
767
+ *
768
+ * `.strict()` like its neighbour: a typo in an optional feature section should surface as a
769
+ * rejected write rather than a silently ignored key that leaves the operator believing they
770
+ * excluded something.
771
+ */
772
+ export const codexPoolSchema = z.object({
773
+ excludedPlans: z.array(z.string().trim().min(1)).optional(),
774
+ }).strict();
775
+
776
+ /**
777
+ * Shape guard for the cross-element checks below. Zod runs an array-level check even
778
+ * when an element failed its own validation, and a failed element is not the shape the
779
+ * checker expects — reading `credentials.length` off it would throw out of `safeParse`
780
+ * and take the whole config load with it. Those elements already carry their own issues.
781
+ */
782
+ export function isCredentialGroupShape(value: unknown): value is { id: string; credentials: string[] } {
783
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
784
+ const group = value as { id?: unknown; credentials?: unknown };
785
+ return typeof group.id === "string"
786
+ && Array.isArray(group.credentials)
787
+ && group.credentials.every(member => typeof member === "string");
788
+ }
789
+
790
+ /**
791
+ * Operator-declared quota domains (`pool.credentialGroups`).
792
+ *
793
+ * Loose enough to hand-write, strict enough that it cannot mean two things: unique group
794
+ * ids, a non-empty member list, provider-qualified members, and each credential in at
795
+ * most one group. Those are not tidiness rules. `classifyCredential` keys a declared
796
+ * domain by group id, so a duplicate id or a credential listed twice merges two quota
797
+ * domains the operator never said were one -- after which the pool counts real capacity
798
+ * once and declines to rotate into it. A bare credential id is ambiguous for the same
799
+ * reason ids are provider-scoped in the auth store, so members carry their provider.
800
+ * {@link credentialGroupIssues} is the single definition, shared with the classifier.
801
+ */
802
+ export const credentialGroupsSchema = z.array(z.object({
803
+ id: z.string().trim().min(1),
804
+ credentials: z.array(z.string().trim().min(1)).min(1),
805
+ note: z.string().optional(),
806
+ })).superRefine((groups, ctx) => {
807
+ if (!Array.isArray(groups) || !groups.every(isCredentialGroupShape)) return;
808
+ for (const message of credentialGroupIssues(groups)) {
809
+ ctx.addIssue({ code: "custom", message });
810
+ }
811
+ });
812
+
813
+ /**
814
+ * Quota-reset notification section.
815
+ *
816
+ * `.strict()` like its neighbour: a typo in an optional feature section should surface as a
817
+ * rejected write rather than a silently ignored key that leaves the operator believing they
818
+ * enabled something.
819
+ *
820
+ * `pollSeconds` admits 0 (passive-only, no timer) and the resolver clamps anything between 1
821
+ * and the 60-second floor. Bounds live in the resolver rather than here so a hand-edited value
822
+ * degrades to a sane one instead of discarding the whole section.
823
+ */
824
+ export const quotaResetNotifySchema = z.object({
825
+ enabled: z.boolean().optional(),
826
+ kinds: z.array(z.enum(["scheduled", "surprise"])).optional(),
827
+ pollSeconds: z.number().int().min(0).optional(),
828
+ // `z.string().url()` accepts any scheme. The payload carries account identity and the hook
829
+ // URL is frequently a bearer-equivalent secret, so an http: sink puts both in cleartext.
830
+ webhookUrl: z.string().url().refine(
831
+ value => { try { return new URL(value).protocol === "https:"; } catch { return false; } },
832
+ { message: "webhookUrl must use https" },
833
+ ).optional(),
834
+ allowPrivateNetwork: z.boolean().optional(),
835
+ timeoutMs: z.number().int().positive().optional(),
836
+ command: z.array(z.string()).optional(),
837
+ }).strict();
838
+
839
+ /**
840
+ * Catalog auto-refresh section (issue #3630).
841
+ *
842
+ * `.strict()` like its neighbour: a typo in an optional feature section should surface as a
843
+ * rejected write rather than a silently ignored key that leaves the operator believing they
844
+ * enabled something.
845
+ *
846
+ * `intervalMinutes` admits 0 (configured but dormant, no timer) and the resolver clamps
847
+ * anything between 1 and the 15-minute floor. Bounds live in the resolver rather than here
848
+ * so a hand-edited value degrades to a sane one instead of discarding the whole section.
849
+ * The 1440 ceiling keeps a hand edit from scheduling the refresh further out than a day,
850
+ * which is operator error far more often than intent.
851
+ */
852
+ export const catalogAutoRefreshSchema = z.object({
853
+ enabled: z.boolean().optional(),
854
+ intervalMinutes: z.number().int().min(0).max(1440).optional(),
855
+ }).strict();