@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.
- package/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +2 -1
- package/src/adapters/base.ts +21 -0
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-schema.ts +477 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +33 -1445
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +611 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +34 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +624 -0
- package/src/bridge/sse.ts +1444 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +71 -9
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +554 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +422 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +95 -28
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +304 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +706 -0
- package/src/codex/catalog/routed-gather.ts +858 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +540 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +81 -1436
- package/src/codex/lineage.ts +458 -0
- package/src/codex/pool-refresh-backoff.ts +152 -0
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cooldown-math.ts +275 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +703 -0
- package/src/codex/routing/thread-affinity.ts +538 -0
- package/src/codex/routing.ts +353 -2234
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +539 -39
- package/src/lib/request-execution-budget.ts +69 -20
- package/src/lib/spend-reservation-ledger.ts +940 -0
- package/src/lib/upstream-retry.ts +55 -11
- package/src/lib/workflow-budget.ts +553 -30
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1221 -0
- package/src/providers/registry/entries-extended.ts +1204 -0
- package/src/providers/registry/model-seeds.ts +908 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +449 -0
- package/src/routing/probe-lease.ts +511 -0
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +565 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +335 -0
- package/src/server/index.ts +40 -2547
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +245 -1
- package/src/server/responses/account-change-state.ts +233 -0
- package/src/server/responses/adapter-continuation.ts +514 -0
- package/src/server/responses/adapter-delivery.ts +214 -0
- package/src/server/responses/adapter-dispatch.ts +971 -0
- package/src/server/responses/compact.ts +59 -4
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +527 -0
- package/src/server/responses/core-codex-account.ts +859 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +707 -0
- package/src/server/responses/core-errors.ts +152 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +225 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/passthrough-delivery.ts +856 -0
- package/src/server/responses/passthrough-dispatch.ts +1476 -0
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +970 -0
- package/src/server/responses/request-send-budget.ts +164 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-transport.ts +744 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +448 -0
- package/src/server/responses/sidecar-execution.ts +469 -0
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/workflow-refusal.ts +84 -0
- package/src/types/config.ts +30 -0
- package/src/usage/log.ts +146 -0
- 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
|
+
}
|