@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,188 @@
1
+ import { join } from "node:path";
2
+ import { DEFAULT_SUBAGENT_MODELS, SUBAGENT_MODELS_VERSION } from "./subagent-models";
3
+ import { MULTI_AGENT_SURFACE_ADVISORY_VERSION } from "./multi-agent-surface";
4
+ import { DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES } from "../lib/app-owned-memory";
5
+ import { describeProxyForLog, readWindowsSystemProxy, type WindowsProxyRegistryReader } from "../lib/windows-system-proxy";
6
+ import { OPENAI_PROVIDER_TIER_VERSION, type OcxConfig } from "../types";
7
+ import type { OcxRuntimeRole } from "../types/config";
8
+
9
+ export function codexAutoStartEnabled(config: Pick<OcxConfig, "codexAutoStart">): boolean {
10
+ return config.codexAutoStart !== false;
11
+ }
12
+
13
+ export const CODEX_SHIM_AUTO_RESTORE_ENV = "OPENCODEX_CODEX_SHIM_AUTO_RESTORE";
14
+
15
+ export function codexShimAutoRestoreEnabled(
16
+ config: Pick<OcxConfig, "codexShimAutoRestore">,
17
+ env: NodeJS.ProcessEnv = process.env,
18
+ ): boolean {
19
+ return config.codexShimAutoRestore !== false && env[CODEX_SHIM_AUTO_RESTORE_ENV] !== "0";
20
+ }
21
+
22
+ export function multiAgentGuidanceEnabled(
23
+ config: Pick<OcxConfig, "multiAgentGuidanceEnabled">,
24
+ ): boolean {
25
+ return config.multiAgentGuidanceEnabled !== false;
26
+ }
27
+
28
+ export function runtimeRole(config: Pick<OcxConfig, "runtimeRole">): OcxRuntimeRole {
29
+ return config.runtimeRole ?? "standalone";
30
+ }
31
+
32
+ export function getDefaultConfig(): OcxConfig {
33
+ // Fresh-install default: works out of the box with Codex's ChatGPT OAuth (no API key).
34
+ // gpt-* requests forward the caller's incoming OAuth headers to the ChatGPT backend.
35
+ // Adding extra providers (e.g. opencode-go) and switching defaultProvider is a user/runtime choice.
36
+ return {
37
+ port: 10100,
38
+ emptyCompletionRetry: false,
39
+ dropCodexSafetyBuffering: false,
40
+ fastRows: true,
41
+ managementUsageMaxReadBytes: 64 * 1024 * 1024,
42
+ appOwnedMemoryBudgetMb: DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024),
43
+ // Fresh/re-initialized configs are already written in the current three-tier
44
+ // OpenAI shape. Mark them as such so startup does not mistake them for a
45
+ // legacy config and collide with an immutable backup from an earlier setup.
46
+ openaiProviderTierVersion: OPENAI_PROVIDER_TIER_VERSION,
47
+ providers: {
48
+ openai: {
49
+ adapter: "openai-responses",
50
+ baseUrl: "https://chatgpt.com/backend-api/codex",
51
+ authMode: "forward",
52
+ codexAccountMode: "pool",
53
+ },
54
+ },
55
+ defaultProvider: "openai",
56
+ subagentModels: [...DEFAULT_SUBAGENT_MODELS],
57
+ subagentModelsVersion: SUBAGENT_MODELS_VERSION,
58
+ // v1 is the shipped surface while a v2 native-to-routed task is undeliverable
59
+ // ciphertext. Written explicitly rather than left absent, because an absent key
60
+ // means base everywhere else. A fresh install starts already acknowledged: there is
61
+ // nothing to advise an operator who is on the recommended surface.
62
+ multiAgentMode: "v1",
63
+ multiAgentSurfaceAdvisoryVersion: MULTI_AGENT_SURFACE_ADVISORY_VERSION,
64
+ multiAgentGuidanceEnabled: true,
65
+ websockets: false,
66
+ codexAutoStart: true,
67
+ codexShimAutoRestore: true,
68
+ };
69
+ }
70
+
71
+ export function resolveEnvValue(value: string | undefined): string | undefined {
72
+ if (!value) return undefined;
73
+ const match = value.match(/^\$\{(\w+)\}$/);
74
+ if (match) return process.env[match[1]];
75
+ if (value.startsWith("$")) return process.env[value.slice(1)];
76
+ return value;
77
+ }
78
+
79
+ const warnedProxyConfigDiscards = new Set<"proxy" | "noProxy" | "noProxyElements">();
80
+
81
+ function warnProxyConfigDiscardOnce(kind: "proxy" | "noProxy" | "noProxyElements"): void {
82
+ if (warnedProxyConfigDiscards.has(kind)) return;
83
+ warnedProxyConfigDiscards.add(kind);
84
+ if (kind === "proxy") {
85
+ console.warn(
86
+ "⚠️ 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",
87
+ );
88
+ } else if (kind === "noProxy") {
89
+ console.warn(
90
+ "⚠️ config.json noProxy was discarded because it is not a string, string array, or resolved environment reference — existing NO_PROXY and loopback bypasses remain",
91
+ );
92
+ } else {
93
+ console.warn(
94
+ "⚠️ config.json noProxy contains invalid elements — invalid elements were ignored; valid entries, existing NO_PROXY, and loopback bypasses remain",
95
+ );
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Mirror `config.proxy` into HTTP(S)_PROXY env vars. Bun fetch consumes them natively; transports
101
+ * such as the ChatGPT upstream WebSocket select the same environment explicitly. User-set HTTP(S)_PROXY
102
+ * variables win; config fills missing scheme proxies, which take precedence over ALL_PROXY for WS.
103
+ * localhost/127.0.0.1 are appended to NO_PROXY so the CLI's own health checks and
104
+ * running-proxy API calls stay direct. Call once per process entry that makes outbound provider
105
+ * requests (server start, catalog sync).
106
+ */
107
+ export function applyProxyEnv(config: OcxConfig): void {
108
+ applyProxyEnvWith(config);
109
+ }
110
+
111
+ /** Test seam for `proxy: "auto"`: the registry reader and platform are injectable. */
112
+ export function applyProxyEnvWith(
113
+ config: OcxConfig,
114
+ auto: { reader?: WindowsProxyRegistryReader; platform?: NodeJS.Platform } = {},
115
+ ): void {
116
+ // `proxy` and `noProxy` are not declared in the top-level schema, which ends in
117
+ // `.passthrough()`, so whatever is on disk arrives here verbatim. A non-string value
118
+ // reached string-only methods and threw out of this function, and it runs once per
119
+ // process entry point — the failure was a startup crash, not a degraded proxy. Ignore
120
+ // malformed values with a privacy-safe warning instead: they cannot express a routing
121
+ // intent, and refusing to start is a worse answer than starting without them.
122
+ const rawProxy = config.proxy;
123
+ let proxy = typeof rawProxy === "string" ? resolveEnvValue(rawProxy) : undefined;
124
+ if (!proxy) {
125
+ if (rawProxy !== undefined) warnProxyConfigDiscardOnce("proxy");
126
+ return;
127
+ }
128
+ if (proxy.trim().toLowerCase() === "auto") {
129
+ // #1525 slice 1: one startup read of the Windows static proxy. Never copy the literal
130
+ // "auto" into HTTP_PROXY; every non-proxy outcome leaves outbound routing as it was.
131
+ if (process.env.HTTP_PROXY?.trim() || process.env.http_proxy?.trim()
132
+ || process.env.HTTPS_PROXY?.trim() || process.env.https_proxy?.trim()) {
133
+ console.log("[opencodex] proxy \"auto\": existing HTTP_PROXY/HTTPS_PROXY environment wins; system proxy not consulted");
134
+ proxy = undefined;
135
+ } else {
136
+ const found = readWindowsSystemProxy(auto.reader, auto.platform);
137
+ if (found.kind === "proxy") {
138
+ console.log(`[opencodex] proxy "auto": using Windows system proxy ${describeProxyForLog(found.url)}`);
139
+ proxy = found.url;
140
+ } else {
141
+ const reason = found.kind === "unsupported"
142
+ ? "only Windows system proxy discovery is supported; using direct egress on this OS"
143
+ : found.kind === "disabled"
144
+ ? "Windows system proxy is disabled; using direct egress"
145
+ : found.kind === "socks-only"
146
+ ? "Windows system proxy is SOCKS-only, which HTTP_PROXY cannot express; using direct egress"
147
+ : "Windows proxy settings could not be read; using direct egress";
148
+ console.log(`[opencodex] proxy "auto": ${reason}`);
149
+ proxy = undefined;
150
+ }
151
+ }
152
+ }
153
+ if (proxy) {
154
+ if (!process.env.HTTP_PROXY?.trim() && !process.env.http_proxy?.trim()) process.env.HTTP_PROXY = proxy;
155
+ if (!process.env.HTTPS_PROXY?.trim() && !process.env.https_proxy?.trim()) process.env.HTTPS_PROXY = proxy;
156
+ }
157
+ const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? "";
158
+ const entries = existing.split(",").map(s => s.trim()).filter(Boolean);
159
+ const seen = new Set(entries.map(e => e.toLowerCase()));
160
+ // Configured entries first, then loopback: loopback is unconditional, so appending it last
161
+ // keeps it present even when the operator lists a loopback host themselves.
162
+ const raw = config.noProxy;
163
+ let configuredEntries: string[];
164
+ if (Array.isArray(raw)) {
165
+ // One unusable element must not discard the operator's other entries.
166
+ if (raw.some(entry => typeof entry !== "string")) warnProxyConfigDiscardOnce("noProxyElements");
167
+ configuredEntries = raw.filter((entry): entry is string => typeof entry === "string");
168
+ } else if (typeof raw === "string") {
169
+ const resolved = resolveEnvValue(raw);
170
+ if (raw && resolved === undefined) warnProxyConfigDiscardOnce("noProxy");
171
+ configuredEntries = (resolved ?? "").split(",");
172
+ } else {
173
+ if (raw !== undefined) warnProxyConfigDiscardOnce("noProxy");
174
+ configuredEntries = [];
175
+ }
176
+ const configured = configuredEntries
177
+ .map(entry => entry.trim())
178
+ .filter(Boolean);
179
+ for (const host of [...configured, "localhost", "127.0.0.1", "::1", "[::1]"]) {
180
+ const key = host.toLowerCase();
181
+ if (!seen.has(key)) {
182
+ entries.push(host);
183
+ seen.add(key);
184
+ }
185
+ }
186
+ process.env.NO_PROXY = entries.join(",");
187
+ }
188
+
@@ -0,0 +1,244 @@
1
+ import { chmodSync, copyFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import * as z from "zod/v4";
4
+ import { CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR } from "../codex/account-namespace-match";
5
+ import { redactSecretString } from "../lib/redact";
6
+ import { hasWarnedConfigFallback, markWarnedConfigFallback } from "./warn-memo";
7
+ import { configSchema } from "./schema/config-schema";
8
+ import type { OcxConfig } from "../types";
9
+
10
+ export function warnConfigRepaired(configPath: string, error: z.ZodError): void {
11
+ if (hasWarnedConfigFallback(configPath)) return;
12
+ markWarnedConfigFallback(configPath);
13
+ const fields = error.issues.map(i => i.path.join(".") || "config").join(", ");
14
+ console.error(`opencodex config at ${configPath}: repaired missing field(s) [${fields}] with defaults. Your providers and accounts are preserved.`);
15
+ }
16
+
17
+ /**
18
+ * Sections whose entries are independent of one another, so one bad entry is
19
+ * safe to drop without changing what the rest mean.
20
+ *
21
+ * Both are validated entry-by-entry in the `superRefine` above, which raises
22
+ * every finding as a *document*-level issue. That is what made a single routing
23
+ * candidate naming a disabled provider discard the operator's whole config —
24
+ * all eleven providers, every API key, and the entire `modelCosts` table —
25
+ * while the proxy carried on serving from built-in defaults and reporting
26
+ * healthy.
27
+ */
28
+ const SALVAGEABLE_CONFIG_SECTIONS = ["routingProfiles", "combos"] as const;
29
+
30
+ /** Optional nested fields that can be dropped whole without changing the rest of the document. */
31
+ const SALVAGEABLE_OPTIONAL_FIELDS: ReadonlyArray<readonly [string, string]> = [
32
+ ["claudeCode", "desktopProfile"],
33
+ ];
34
+
35
+ function isSalvageableConfigPath(section: string, id: string): boolean {
36
+ if ((SALVAGEABLE_CONFIG_SECTIONS as readonly string[]).includes(section)) return true;
37
+ return SALVAGEABLE_OPTIONAL_FIELDS.some(path => path[0] === section && path[1] === id);
38
+ }
39
+
40
+ /**
41
+ * Drop just the named entries a parse failure blamed, so the rest of the
42
+ * document survives.
43
+ *
44
+ * Returns `null` when the failure was not confined to those sections — the
45
+ * caller then keeps its existing behaviour rather than guessing.
46
+ *
47
+ * The whole entry goes, not the individual offending candidate. A routing
48
+ * profile that quietly loses one candidate still routes, just not where the
49
+ * operator said it should, and a policy that silently changed shape is a worse
50
+ * outcome than one that is plainly absent. Absent is also the loud option: a
51
+ * dry-run against it answers `unknown_profile`, which — paired with the warning
52
+ * this emits — points at the real mistake.
53
+ */
54
+ function dropInvalidConfigSections(
55
+ parsed: unknown,
56
+ error: z.ZodError,
57
+ ): { candidate: Record<string, unknown>; dropped: string[] } | null {
58
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
59
+
60
+ const doomed = new Map<string, Set<string>>();
61
+ for (const issue of error.issues) {
62
+ if (isUnsalvageableIssue(issue)) return null;
63
+ const [section, id] = issue.path;
64
+ if (typeof section !== "string" || typeof id !== "string") return null;
65
+ if (!isSalvageableConfigPath(section, id)) return null;
66
+ // A complaint about the container itself ("combos must be an object") is
67
+ // not about one entry, so there is nothing selective to drop.
68
+ if (issue.path.length < 2) return null;
69
+ let ids = doomed.get(section);
70
+ if (!ids) doomed.set(section, ids = new Set());
71
+ ids.add(id);
72
+ }
73
+ if (doomed.size === 0) return null;
74
+
75
+ const candidate: Record<string, unknown> = { ...(parsed as Record<string, unknown>) };
76
+ const dropped: string[] = [];
77
+ for (const [section, ids] of doomed) {
78
+ const current = candidate[section];
79
+ if (!current || typeof current !== "object" || Array.isArray(current)) return null;
80
+ const kept: Record<string, unknown> = {};
81
+ for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
82
+ if (ids.has(key)) dropped.push(`${section}.${key}`);
83
+ else kept[key] = value;
84
+ }
85
+ candidate[section] = kept;
86
+ }
87
+ return dropped.length > 0 ? { candidate, dropped } : null;
88
+ }
89
+
90
+ /**
91
+ * Salvage until the document parses, not just once.
92
+ *
93
+ * One pass is not enough because the sections depend on each other: routing
94
+ * profiles are validated against the combo map, so dropping an invalid combo can
95
+ * expose a profile that referenced it. A single-pass salvage sees that second
96
+ * failure and gives up, discarding the whole config -- the exact outcome this
97
+ * code exists to prevent.
98
+ *
99
+ * `rawDocument` is the operator's document before defaults were merged in. When
100
+ * supplied, the same entries are deleted from it too, so a diagnostics caller can
101
+ * still tell an absent optional setting from one we injected.
102
+ */
103
+
104
+ /**
105
+ * Findings that must never be salvaged away.
106
+ *
107
+ * Salvage removes the entry a finding blamed, which is right for an ordinary
108
+ * validation mistake and wrong for a namespace collision: the collision is a
109
+ * *relationship* between a combo/profile and a Codex account selector, and it is
110
+ * reported on the combo. Dropping that combo makes the document parse and quietly
111
+ * admits the account selector the schema just refused, turning a hard admission
112
+ * boundary into a config that loads. Refuse the whole document instead.
113
+ */
114
+ const UNSALVAGEABLE_ISSUE_MESSAGES: readonly string[] = [
115
+ CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
116
+ ];
117
+
118
+ function isUnsalvageableIssue(issue: z.ZodIssue): boolean {
119
+ return UNSALVAGEABLE_ISSUE_MESSAGES.some(message => issue.message.includes(message));
120
+ }
121
+ export function salvageConfigCandidate(
122
+ merged: unknown,
123
+ initialError: z.ZodError,
124
+ rawDocument?: unknown,
125
+ ): {
126
+ candidate: Record<string, unknown>;
127
+ rawCandidate: unknown;
128
+ parsed: OcxConfig;
129
+ dropped: string[];
130
+ issues: z.ZodIssue[];
131
+ } | null {
132
+ let candidate: unknown = merged;
133
+ let rawCandidate: unknown = rawDocument;
134
+ let error = initialError;
135
+ const dropped: string[] = [];
136
+ const issues: z.ZodIssue[] = [];
137
+ // Bounded by construction: every pass must remove at least one entry, and there
138
+ // are only so many entries to remove.
139
+ const budget = countSalvageableEntries(merged) + 1;
140
+ for (let pass = 0; pass < budget; pass++) {
141
+ const step = dropInvalidConfigSections(candidate, error);
142
+ if (!step || step.dropped.length === 0) return null;
143
+ dropped.push(...step.dropped);
144
+ issues.push(...error.issues);
145
+ candidate = step.candidate;
146
+ rawCandidate = deleteEntryPaths(rawCandidate, step.dropped);
147
+ const result = configSchema.safeParse(candidate);
148
+ if (result.success) {
149
+ return { candidate: step.candidate, rawCandidate, parsed: result.data as OcxConfig, dropped, issues };
150
+ }
151
+ error = result.error;
152
+ }
153
+ return null;
154
+ }
155
+
156
+ function countSalvageableEntries(document: unknown): number {
157
+ if (!document || typeof document !== "object" || Array.isArray(document)) return 0;
158
+ let total = 0;
159
+ for (const section of SALVAGEABLE_CONFIG_SECTIONS) {
160
+ const value = (document as Record<string, unknown>)[section];
161
+ if (value && typeof value === "object" && !Array.isArray(value)) {
162
+ total += Object.keys(value as Record<string, unknown>).length;
163
+ }
164
+ }
165
+ for (const [section, id] of SALVAGEABLE_OPTIONAL_FIELDS) {
166
+ const container = (document as Record<string, unknown>)[section];
167
+ if (container && typeof container === "object" && !Array.isArray(container)
168
+ && Object.hasOwn(container as Record<string, unknown>, id)) {
169
+ total += 1;
170
+ }
171
+ }
172
+ return total;
173
+ }
174
+
175
+ /** Delete `section.id` entries from a copy of the raw document. */
176
+ function deleteEntryPaths(document: unknown, entryPaths: readonly string[]): unknown {
177
+ if (!document || typeof document !== "object" || Array.isArray(document)) return document;
178
+ const next: Record<string, unknown> = { ...(document as Record<string, unknown>) };
179
+ for (const entryPath of entryPaths) {
180
+ const separator = entryPath.indexOf(".");
181
+ if (separator <= 0) continue;
182
+ const section = entryPath.slice(0, separator);
183
+ const id = entryPath.slice(separator + 1);
184
+ const container = next[section];
185
+ if (!container || typeof container !== "object" || Array.isArray(container)) continue;
186
+ const kept: Record<string, unknown> = { ...(container as Record<string, unknown>) };
187
+ delete kept[id];
188
+ next[section] = kept;
189
+ }
190
+ return next;
191
+ }
192
+
193
+ /**
194
+ * Entry ids are operator-chosen and can be token-shaped, so nothing dynamic reaches
195
+ * the log unredacted. Static section names stay readable -- they are the part that
196
+ * tells the operator where to look.
197
+ */
198
+ function redactEntryPath(entryPath: string): string {
199
+ const separator = entryPath.indexOf(".");
200
+ if (separator <= 0) return redactSecretString(entryPath);
201
+ return entryPath.slice(0, separator) + "." + redactSecretString(entryPath.slice(separator + 1));
202
+ }
203
+
204
+ function redactIssuePath(path: readonly PropertyKey[]): string {
205
+ return path
206
+ .map((segment, index) => (index === 0 && typeof segment === "string" ? segment : redactSecretString(String(segment))))
207
+ .join(".");
208
+ }
209
+
210
+ export function warnDroppedConfigSections(configPath: string, dropped: string[], issues: readonly z.ZodIssue[]): void {
211
+ if (hasWarnedConfigFallback(configPath)) return;
212
+ markWarnedConfigFallback(configPath);
213
+ const reasons = issues
214
+ .map(issue => `${redactIssuePath(issue.path)}: ${redactSecretString(issue.message)}`)
215
+ .join("; ");
216
+ console.error(
217
+ `opencodex config at ${configPath}: dropped [${dropped.map(redactEntryPath).join(", ")}] and loaded the rest — ${reasons}. `
218
+ + "Everything else in your config, including providers and modelCosts, is preserved.",
219
+ );
220
+ }
221
+
222
+ export function warnAndBackupInvalidConfig(configPath: string, error: unknown): void {
223
+ if (hasWarnedConfigFallback(configPath)) return;
224
+ markWarnedConfigFallback(configPath);
225
+
226
+ const backupPath = backupInvalidConfig(configPath);
227
+ const reason = error instanceof z.ZodError
228
+ ? error.issues.map(issue => `${issue.path.join(".") || "config"}: ${issue.message}`).join("; ")
229
+ : error instanceof Error ? error.message : String(error);
230
+ const backupNote = backupPath ? ` A backup was written to ${backupPath}.` : "";
231
+ console.error(`Could not load opencodex config at ${configPath}: ${reason}. Using default config.${backupNote}`);
232
+ }
233
+
234
+ export function backupInvalidConfig(configPath: string): string | null {
235
+ if (!existsSync(configPath)) return null;
236
+ const backupPath = `${configPath}.invalid-${new Date().toISOString().replace(/[:.]/g, "-")}`;
237
+ try {
238
+ copyFileSync(configPath, backupPath);
239
+ try { chmodSync(backupPath, 0o600); } catch { /* best-effort */ }
240
+ return backupPath;
241
+ } catch {
242
+ return null;
243
+ }
244
+ }