@bitkyc08/opencodex 2.23.0-preview.20260816 → 2.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/gui/dist/assets/index-CFqJKF2L.js +102 -0
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -2
  4. package/src/adapters/anthropic.ts +39 -7
  5. package/src/adapters/cursor/tool-definitions.ts +48 -0
  6. package/src/adapters/google.ts +18 -12
  7. package/src/adapters/openai-chat.ts +106 -13
  8. package/src/adapters/tool-call-id.ts +119 -0
  9. package/src/adapters/tool-catalog-nudge.ts +3 -0
  10. package/src/bridge.ts +16 -5
  11. package/src/chat/inbound.ts +5 -11
  12. package/src/claude/context-windows.ts +20 -4
  13. package/src/claude/desktop-3p.ts +11 -6
  14. package/src/claude/inbound.ts +39 -1
  15. package/src/claude/model-info.ts +28 -8
  16. package/src/cli/account-api.ts +5 -1
  17. package/src/cli/claude-desktop.ts +3 -0
  18. package/src/cli/config-command.ts +37 -14
  19. package/src/codex/app-server-restart-service.ts +1 -1
  20. package/src/codex/auth-api.ts +5 -0
  21. package/src/codex/auth-context.ts +43 -2
  22. package/src/codex/catalog/metadata.ts +135 -12
  23. package/src/codex/catalog/native-models.ts +32 -2
  24. package/src/codex/catalog/parsing.ts +61 -13
  25. package/src/codex/catalog/provider-fetch.ts +37 -7
  26. package/src/codex/catalog/sync.ts +49 -25
  27. package/src/codex/catalog-refresh-status.ts +21 -3
  28. package/src/codex/catalog.ts +1 -1
  29. package/src/codex/convergence-types.ts +23 -2
  30. package/src/codex/desired-state.ts +1 -1
  31. package/src/codex/inject.ts +38 -7
  32. package/src/codex/injected-marker.ts +28 -0
  33. package/src/codex/journal.ts +40 -1
  34. package/src/codex/management-convergence.ts +55 -2
  35. package/src/codex/quota-rejection.ts +61 -1
  36. package/src/codex/quota.ts +60 -6
  37. package/src/codex/routing.ts +30 -3
  38. package/src/combos/failover.ts +20 -0
  39. package/src/config.ts +271 -4
  40. package/src/generated/compatibility-version.json +90 -78
  41. package/src/grok/sync.ts +3 -1
  42. package/src/lab/artifacts/sanitize.ts +1 -1
  43. package/src/lab/live/manifest.ts +1 -1
  44. package/src/lib/codex-restart-contract.ts +1 -1
  45. package/src/lib/config-ownership.ts +1 -0
  46. package/src/lib/errors.ts +9 -0
  47. package/src/lib/lab-activation.ts +1 -1
  48. package/src/lib/optional-shutdown-hooks.ts +1 -1
  49. package/src/lib/pinned-http.ts +7 -2
  50. package/src/lib/windows-elevation.ts +3 -3
  51. package/src/providers/quota.ts +10 -4
  52. package/src/providers/registry.ts +2 -2
  53. package/src/responses/parser.ts +42 -7
  54. package/src/responses/provider-opaque-metadata.ts +1 -1
  55. package/src/responses/thought-signature-replay.ts +261 -0
  56. package/src/router.ts +6 -1
  57. package/src/routing/compatibility/provider-slot.ts +1 -1
  58. package/src/routing/evaluator.ts +12 -2
  59. package/src/routing/health.ts +16 -5
  60. package/src/routing/history/schema.ts +1 -1
  61. package/src/routing/trace.ts +1 -1
  62. package/src/server/auth-cors.ts +96 -21
  63. package/src/server/chat-completions.ts +6 -2
  64. package/src/server/chat-native.ts +32 -6
  65. package/src/server/index.ts +5 -3
  66. package/src/server/management/agent-settings-routes.ts +26 -4
  67. package/src/server/management/config-routes.ts +79 -2
  68. package/src/server/management/context.ts +1 -1
  69. package/src/server/management/model-rows.ts +5 -0
  70. package/src/server/management/native-integration-routes.ts +4 -1
  71. package/src/server/management/provider-routes.ts +19 -0
  72. package/src/server/management/shared.ts +3 -3
  73. package/src/server/management-api.ts +13 -6
  74. package/src/server/passive-route-linker.ts +1 -1
  75. package/src/server/relay.ts +16 -0
  76. package/src/server/responses/compact.ts +10 -3
  77. package/src/server/responses/core.ts +160 -33
  78. package/src/server/responses/fetch-helpers.ts +34 -2
  79. package/src/server/responses/input-admission.ts +17 -9
  80. package/src/server/responses-undeclared-tool-guard.ts +153 -0
  81. package/src/server/system-env.ts +4 -2
  82. package/src/service.ts +22 -7
  83. package/src/types.ts +35 -1
  84. package/gui/dist/assets/index-Ch-YtWdA.js +0 -102
@@ -127,7 +127,8 @@ let lastReconciledGeneration = 0;
127
127
  let liveHealthAccountIds = new Set<string>();
128
128
 
129
129
  export type CodexUpstreamOutcome = number | "connect_error" | "timeout" | "connect_neutral";
130
- export type CodexUpstreamOutcomeClass = "success" | "credential" | "quota" | "transient" | "caller" | "neutral" | "unknown";
130
+ export type CodexUpstreamOutcomeClass = "success" | "credential"
131
+ | "workspace" | "quota" | "transient" | "caller" | "neutral" | "unknown";
131
132
  export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
132
133
  /**
133
134
  * Native Codex quota groups known to be independent upstream. Keep the mapping
@@ -193,6 +194,12 @@ export type CodexUpstreamOutcomeMeta = {
193
194
  now?: number;
194
195
  /** (provider, host) ledger key for account-neutral reachability failures (#914). */
195
196
  hostKey?: string;
197
+ /**
198
+ * Upstream denial evidence for a 403. A workspace/entitlement denial means the CREDENTIAL
199
+ * is fine and the account simply cannot reach this workspace, so it must not be quarantined
200
+ * for reauthentication (#1789). Absent evidence keeps the historical credential handling.
201
+ */
202
+ denial?: "workspace" | "entitlement";
196
203
  /** Stable transport code recorded alongside a neutral host failure. */
197
204
  lastFailureCode?: string;
198
205
  /** Native model selected for this request; used only for confirmed scoped quotas. */
@@ -327,7 +334,10 @@ export function computeCodexUsageScore(quota: {
327
334
  return values.length > 0 ? Math.max(...values) : CODEX_UNKNOWN_USAGE_SCORE;
328
335
  }
329
336
 
330
- export function classifyCodexUpstreamOutcome(outcome: CodexUpstreamOutcome): CodexUpstreamOutcomeClass {
337
+ export function classifyCodexUpstreamOutcome(
338
+ outcome: CodexUpstreamOutcome,
339
+ denial?: "workspace" | "entitlement",
340
+ ): CodexUpstreamOutcomeClass {
331
341
  if (outcome === "connect_neutral") return "neutral";
332
342
  if (outcome === "connect_error" || outcome === "timeout") return "transient";
333
343
  if (!Number.isFinite(outcome)) return "unknown";
@@ -337,6 +347,11 @@ export function classifyCodexUpstreamOutcome(outcome: CodexUpstreamOutcome): Cod
337
347
  // and says nothing about the credential. Relayed as the neutral class so a
338
348
  // stray 3xx cannot increment an account's transient streak.
339
349
  if (outcome >= 300 && outcome < 400) return "neutral";
350
+ // 401 is always a credential problem. A 403 is only a credential problem when nothing
351
+ // tells us otherwise: a workspace/entitlement denial (#1789) means the credential is valid
352
+ // and the account simply lacks access here, so quarantining it for reauth is wrong advice.
353
+ // Absent denial evidence the historical mapping stands, so the change fails safe.
354
+ if (outcome === 403 && denial !== undefined) return "workspace";
340
355
  if (outcome === 401 || outcome === 403) return "credential";
341
356
  // 402 Payment Required is treated as quota exhaustion for pool cooldown/failover
342
357
  // (same-request alternate retry records this outcome for the depleted account).
@@ -1596,7 +1611,7 @@ export function recordCodexUpstreamOutcome(
1596
1611
  const writerGeneration = meta.writerGeneration ?? captureConfigGeneration();
1597
1612
  if (writerGeneration < lastReconciledGeneration && !liveHealthAccountIds.has(accountId)) return;
1598
1613
  const now = meta.now ?? Date.now();
1599
- const outcomeClass = classifyCodexUpstreamOutcome(outcome);
1614
+ const outcomeClass = classifyCodexUpstreamOutcome(outcome, meta.denial);
1600
1615
  const quotaScope = codexQuotaScopeForModel(meta.modelId);
1601
1616
  if (outcomeClass === "success") {
1602
1617
  const scopedProbe = meta.probeQuotaScope
@@ -1676,6 +1691,18 @@ export function recordCodexUpstreamOutcome(
1676
1691
  }
1677
1692
 
1678
1693
  const lastFailureStatus = typeof outcome === "number" ? outcome : 0;
1694
+ if (outcomeClass === "workspace") {
1695
+ // The credential is valid; this account just cannot reach this workspace (#1789).
1696
+ // Record the failure so routing stops preferring it, but do not mark it for
1697
+ // reauthentication and do not sweep its thread affinities: telling the user to
1698
+ // re-login is wrong advice that cannot fix a workspace grant.
1699
+ upstreamHealth.set(accountId, {
1700
+ consecutiveFailures: (upstreamHealth.get(accountId)?.consecutiveFailures ?? 0) + 1,
1701
+ lastFailureStatus,
1702
+ lastFailureAt: now,
1703
+ });
1704
+ return;
1705
+ }
1679
1706
  if (outcomeClass === "credential") {
1680
1707
  // 401/403 quarantines the account for reauth. That supersedes quota state
1681
1708
  // entirely: a cooldown (and any probe lease) on an unusable account is moot.
@@ -121,6 +121,26 @@ export function comboFailureDecision(
121
121
  if (isCyberPolicyCode(options?.code)) return "stop";
122
122
  const error = classifyError(status, "upstream_error", message);
123
123
  if (isCyberPolicyCode(error.code)) return "stop";
124
+ // A local input-admission refusal (#1524) says "this candidate cannot fit the request",
125
+ // not "the request is impossible": the next candidate may have a larger context window.
126
+ //
127
+ // This MUST be tested before the generic stop list below. Our own refusal message says
128
+ // "context window" -- that is what it refuses on -- and the classifier remaps that phrase,
129
+ // so checking the stop list first swallowed the signal and ended the chain. An UPSTREAM
130
+ // `context_length_exceeded` carries no admission code and still falls through to stop.
131
+ //
132
+ // Matched on the STRUCTURED code only, which classifyError now preserves for our own
133
+ // refusal. A raw substring test would additionally let any upstream override a terminal
134
+ // verdict by echoing the token in prose we do not control.
135
+ //
136
+ // Precise about what this is NOT: an upstream can still SET this code deliberately, since
137
+ // both extractors read the upstream error object. That is bounded rather than dangerous --
138
+ // an upstream already controls other hop signals (429, 5xx), and traversal is finite: policy
139
+ // tries each candidate once via `tried`, and combo excludes each attempted target. So this is
140
+ // structured-code-only, not provably local.
141
+ if (options?.code === "input_admission_refused" || error.code === "input_admission_refused") {
142
+ return "hop";
143
+ }
124
144
  if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) {
125
145
  return "stop";
126
146
  }
package/src/config.ts CHANGED
@@ -60,6 +60,7 @@ import {
60
60
  OPENAI_PROVIDER_TIER_VERSION,
61
61
  pinnedWireAdapter,
62
62
  REASONING_SUMMARY_DELIVERY_VALUES,
63
+ UPSTREAM_HTTP_VERSION_VALUES,
63
64
  type OcxClaudeCodeConfig,
64
65
  type OcxConfig,
65
66
  type OcxApiKeyEntry,
@@ -734,6 +735,12 @@ const providerConfigSchema = z.object({
734
735
  modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(),
735
736
  preserveResponsesReasoningContent: z.boolean().optional(),
736
737
  allowPrivateNetwork: z.boolean().optional(),
738
+ // The management API accepts `null` as "clear this", so a config written before the POST
739
+ // canonicalization below can hold one on disk. Rejecting it here would send the operator
740
+ // through invalid-config recovery for a value the API told them was fine.
741
+ upstreamHttpVersion: z.enum(UPSTREAM_HTTP_VERSION_VALUES)
742
+ .nullish()
743
+ .transform(value => value ?? undefined),
737
744
  noStructuredOutputModels: z.array(z.string().min(1))
738
745
  .transform(normalizeNonBlankStringArray)
739
746
  .optional(),
@@ -906,6 +913,20 @@ export function apiKeyTransportConfigError(
906
913
  return null;
907
914
  }
908
915
 
916
+ /**
917
+ * Shared runtime boundary for the per-provider upstream HTTP-version pin (#1668). Used by
918
+ * the management write path (providerManagementConfigError / PATCH) so it can never disagree
919
+ * with the strict zod load schema: a value that survives POST/PATCH is always loadable, and
920
+ * a value the loader rejects is rejected at write time too.
921
+ */
922
+ export function upstreamHttpVersionConfigError(value: unknown): string | null {
923
+ if (value === undefined || value === null) return null;
924
+ if (typeof value !== "string" || !(UPSTREAM_HTTP_VERSION_VALUES as readonly string[]).includes(value)) {
925
+ return 'upstreamHttpVersion must be one of "auto", "http1.1", "h1", "http2", "h2", or null to clear';
926
+ }
927
+ return null;
928
+ }
929
+
909
930
  export function positiveIntegerRecordConfigError(value: unknown, field: string): string | null {
910
931
  if (value === undefined) return null;
911
932
  if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
@@ -1997,12 +2018,30 @@ function normalizePersistedClaudeCode(claudeCode: unknown): OcxConfig["claudeCod
1997
2018
  if (Object.hasOwn(normalized, "subagentEffort") && !isClaudeSubagentEffort(normalized.subagentEffort)) {
1998
2019
  delete normalized.subagentEffort;
1999
2020
  }
2021
+ // A hand-authored config never passes through the management validator, so coerce here too.
2022
+ // A malformed classifierFallbacks (a bare string, or an array with non-string entries) would
2023
+ // otherwise reach the resolver unchecked.
2024
+ if (Object.hasOwn(normalized, "classifierModel")) {
2025
+ const value = typeof normalized.classifierModel === "string" ? normalized.classifierModel.trim() : "";
2026
+ if (value.length > 0) normalized.classifierModel = value;
2027
+ else delete normalized.classifierModel;
2028
+ }
2029
+ if (Object.hasOwn(normalized, "classifierFallbacks")) {
2030
+ const raw = normalized.classifierFallbacks;
2031
+ const kept = Array.isArray(raw)
2032
+ ? raw.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map(entry => entry.trim())
2033
+ : [];
2034
+ if (kept.length > 0) normalized.classifierFallbacks = kept;
2035
+ else delete normalized.classifierFallbacks;
2036
+ }
2000
2037
  return normalized as OcxConfig["claudeCode"];
2001
2038
  }
2002
2039
 
2003
- function normalizeClaudeSubagentEffort(config: OcxConfig, rawParsed: unknown): OcxConfig {
2004
- const rawEffort = rawClaudeSubagentEffort(rawParsed);
2005
- if (rawEffort === undefined || isClaudeSubagentEffort(rawEffort)) return config;
2040
+ function normalizeClaudeSubagentEffort(config: OcxConfig, _rawParsed: unknown): OcxConfig {
2041
+ // Unconditional. This used to short-circuit when `subagentEffort` was absent or already valid,
2042
+ // which meant a config whose ONLY defect was elsewhere in `claudeCode` was never normalized.
2043
+ // The specialized subagentEffort WARNING is a separate concern and stays exactly as it is.
2044
+ if (!config.claudeCode) return config;
2006
2045
  return { ...config, claudeCode: normalizePersistedClaudeCode(config.claudeCode) };
2007
2046
  }
2008
2047
 
@@ -2172,6 +2211,26 @@ export function loadConfig(): OcxConfig {
2172
2211
  warnDegradedAgentTaskRecovery(parsed);
2173
2212
  return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
2174
2213
  }
2214
+ // Still failing, but if every complaint is about one or more named entries
2215
+ // in an independent section, drop exactly those and keep the rest. Falling
2216
+ // back to defaults here would silently retire the operator's providers,
2217
+ // keys and prices over a mistake in one routing profile.
2218
+ const salvaged = salvageConfigCandidate(merged, retryResult.error);
2219
+ if (salvaged) {
2220
+ {
2221
+ warnDroppedConfigSections(configPath, salvaged.dropped, salvaged.issues);
2222
+ const config = normalizeApiKeyIds(salvaged.parsed);
2223
+ warnDegradedHostname(parsed, config);
2224
+ warnDegradedApiKeys(parsed, config);
2225
+ warnDegradedCodexAccountPriorities(parsed, config);
2226
+ warnDegradedClaudeSubagentEffort(parsed);
2227
+ warnDegradedNativeSubagentConfig(parsed, config);
2228
+ warnDegradedCodexAccountPicker(parsed);
2229
+ warnDegradedUpstreamHostCircuitThreshold(parsed);
2230
+ warnDegradedAgentTaskRecovery(parsed);
2231
+ return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
2232
+ }
2233
+ }
2175
2234
  // Merge couldn't fix it — truly broken config
2176
2235
  warnAndBackupInvalidConfig(configPath, result.error);
2177
2236
  return getDefaultConfig();
@@ -2450,11 +2509,31 @@ function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics {
2450
2509
  return validFileConfigDiagnostics(normalizeApiKeyIds(result.data as OcxConfig), parsed);
2451
2510
  }
2452
2511
 
2453
- const retryResult = configSchema.safeParse(mergeConfigDefaults(parsed));
2512
+ const merged = mergeConfigDefaults(parsed);
2513
+ const retryResult = configSchema.safeParse(merged);
2454
2514
  if (retryResult.success) {
2455
2515
  return validFileConfigDiagnostics(normalizeApiKeyIds(retryResult.data as OcxConfig), parsed);
2456
2516
  }
2457
2517
 
2518
+ // #1785: one invalid routing profile must not make diagnostics report the built-in
2519
+ // defaults AS the config, because a later config write persists those defaults over the
2520
+ // operator's providers, keys and prices.
2521
+ //
2522
+ // The failure is still reported. `source` stays "fallback" and `error` keeps the real
2523
+ // schema message -- diagnostics is the surface that tells callers the file is invalid,
2524
+ // and every consumer that must refuse an invalid config (provider reload, catalog sync,
2525
+ // cost reconcile, codex admission) gates on exactly those two fields. Only `config`
2526
+ // changes: it carries the salvaged document instead of factory defaults, so a caller
2527
+ // that ignores the error and writes it back preserves what the operator configured.
2528
+ const salvaged = salvageConfigCandidate(merged, retryResult.error);
2529
+ if (salvaged) {
2530
+ return {
2531
+ config: normalizeApiKeyIds(salvaged.parsed),
2532
+ source: "fallback",
2533
+ error: schemaDiagnosticsError(result.error),
2534
+ };
2535
+ }
2536
+
2458
2537
  return { config: getDefaultConfig(), source: "fallback", error: schemaDiagnosticsError(result.error) };
2459
2538
  } catch {
2460
2539
  return { config: getDefaultConfig(), source: "fallback", error: "invalid_json" };
@@ -3428,6 +3507,194 @@ function warnConfigRepaired(configPath: string, error: z.ZodError): void {
3428
3507
  console.error(`opencodex config at ${configPath}: repaired missing field(s) [${fields}] with defaults. Your providers and accounts are preserved.`);
3429
3508
  }
3430
3509
 
3510
+ /**
3511
+ * Sections whose entries are independent of one another, so one bad entry is
3512
+ * safe to drop without changing what the rest mean.
3513
+ *
3514
+ * Both are validated entry-by-entry in the `superRefine` above, which raises
3515
+ * every finding as a *document*-level issue. That is what made a single routing
3516
+ * candidate naming a disabled provider discard the operator's whole config —
3517
+ * all eleven providers, every API key, and the entire `modelCosts` table —
3518
+ * while the proxy carried on serving from built-in defaults and reporting
3519
+ * healthy.
3520
+ */
3521
+ const SALVAGEABLE_CONFIG_SECTIONS = ["routingProfiles", "combos"] as const;
3522
+
3523
+ /**
3524
+ * Drop just the named entries a parse failure blamed, so the rest of the
3525
+ * document survives.
3526
+ *
3527
+ * Returns `null` when the failure was not confined to those sections — the
3528
+ * caller then keeps its existing behaviour rather than guessing.
3529
+ *
3530
+ * The whole entry goes, not the individual offending candidate. A routing
3531
+ * profile that quietly loses one candidate still routes, just not where the
3532
+ * operator said it should, and a policy that silently changed shape is a worse
3533
+ * outcome than one that is plainly absent. Absent is also the loud option: a
3534
+ * dry-run against it answers `unknown_profile`, which — paired with the warning
3535
+ * this emits — points at the real mistake.
3536
+ */
3537
+ function dropInvalidConfigSections(
3538
+ parsed: unknown,
3539
+ error: z.ZodError,
3540
+ ): { candidate: Record<string, unknown>; dropped: string[] } | null {
3541
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
3542
+
3543
+ const doomed = new Map<string, Set<string>>();
3544
+ for (const issue of error.issues) {
3545
+ if (isUnsalvageableIssue(issue)) return null;
3546
+ const [section, id] = issue.path;
3547
+ if (typeof section !== "string" || typeof id !== "string") return null;
3548
+ if (!(SALVAGEABLE_CONFIG_SECTIONS as readonly string[]).includes(section)) return null;
3549
+ // A complaint about the container itself ("combos must be an object") is
3550
+ // not about one entry, so there is nothing selective to drop.
3551
+ if (issue.path.length < 2) return null;
3552
+ let ids = doomed.get(section);
3553
+ if (!ids) doomed.set(section, ids = new Set());
3554
+ ids.add(id);
3555
+ }
3556
+ if (doomed.size === 0) return null;
3557
+
3558
+ const candidate: Record<string, unknown> = { ...(parsed as Record<string, unknown>) };
3559
+ const dropped: string[] = [];
3560
+ for (const [section, ids] of doomed) {
3561
+ const current = candidate[section];
3562
+ if (!current || typeof current !== "object" || Array.isArray(current)) return null;
3563
+ const kept: Record<string, unknown> = {};
3564
+ for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
3565
+ if (ids.has(key)) dropped.push(`${section}.${key}`);
3566
+ else kept[key] = value;
3567
+ }
3568
+ candidate[section] = kept;
3569
+ }
3570
+ return dropped.length > 0 ? { candidate, dropped } : null;
3571
+ }
3572
+
3573
+ /**
3574
+ * Salvage until the document parses, not just once.
3575
+ *
3576
+ * One pass is not enough because the sections depend on each other: routing
3577
+ * profiles are validated against the combo map, so dropping an invalid combo can
3578
+ * expose a profile that referenced it. A single-pass salvage sees that second
3579
+ * failure and gives up, discarding the whole config -- the exact outcome this
3580
+ * code exists to prevent.
3581
+ *
3582
+ * `rawDocument` is the operator's document before defaults were merged in. When
3583
+ * supplied, the same entries are deleted from it too, so a diagnostics caller can
3584
+ * still tell an absent optional setting from one we injected.
3585
+ */
3586
+
3587
+ /**
3588
+ * Findings that must never be salvaged away.
3589
+ *
3590
+ * Salvage removes the entry a finding blamed, which is right for an ordinary
3591
+ * validation mistake and wrong for a namespace collision: the collision is a
3592
+ * *relationship* between a combo/profile and a Codex account selector, and it is
3593
+ * reported on the combo. Dropping that combo makes the document parse and quietly
3594
+ * admits the account selector the schema just refused, turning a hard admission
3595
+ * boundary into a config that loads. Refuse the whole document instead.
3596
+ */
3597
+ const UNSALVAGEABLE_ISSUE_MESSAGES: readonly string[] = [
3598
+ CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
3599
+ ];
3600
+
3601
+ function isUnsalvageableIssue(issue: z.ZodIssue): boolean {
3602
+ return UNSALVAGEABLE_ISSUE_MESSAGES.some(message => issue.message.includes(message));
3603
+ }
3604
+ function salvageConfigCandidate(
3605
+ merged: unknown,
3606
+ initialError: z.ZodError,
3607
+ rawDocument?: unknown,
3608
+ ): {
3609
+ candidate: Record<string, unknown>;
3610
+ rawCandidate: unknown;
3611
+ parsed: OcxConfig;
3612
+ dropped: string[];
3613
+ issues: z.ZodIssue[];
3614
+ } | null {
3615
+ let candidate: unknown = merged;
3616
+ let rawCandidate: unknown = rawDocument;
3617
+ let error = initialError;
3618
+ const dropped: string[] = [];
3619
+ const issues: z.ZodIssue[] = [];
3620
+ // Bounded by construction: every pass must remove at least one entry, and there
3621
+ // are only so many entries to remove.
3622
+ const budget = countSalvageableEntries(merged) + 1;
3623
+ for (let pass = 0; pass < budget; pass++) {
3624
+ const step = dropInvalidConfigSections(candidate, error);
3625
+ if (!step || step.dropped.length === 0) return null;
3626
+ dropped.push(...step.dropped);
3627
+ issues.push(...error.issues);
3628
+ candidate = step.candidate;
3629
+ rawCandidate = deleteEntryPaths(rawCandidate, step.dropped);
3630
+ const result = configSchema.safeParse(candidate);
3631
+ if (result.success) {
3632
+ return { candidate: step.candidate, rawCandidate, parsed: result.data as OcxConfig, dropped, issues };
3633
+ }
3634
+ error = result.error;
3635
+ }
3636
+ return null;
3637
+ }
3638
+
3639
+ function countSalvageableEntries(document: unknown): number {
3640
+ if (!document || typeof document !== "object" || Array.isArray(document)) return 0;
3641
+ let total = 0;
3642
+ for (const section of SALVAGEABLE_CONFIG_SECTIONS) {
3643
+ const value = (document as Record<string, unknown>)[section];
3644
+ if (value && typeof value === "object" && !Array.isArray(value)) {
3645
+ total += Object.keys(value as Record<string, unknown>).length;
3646
+ }
3647
+ }
3648
+ return total;
3649
+ }
3650
+
3651
+ /** Delete `section.id` entries from a copy of the raw document. */
3652
+ function deleteEntryPaths(document: unknown, entryPaths: readonly string[]): unknown {
3653
+ if (!document || typeof document !== "object" || Array.isArray(document)) return document;
3654
+ const next: Record<string, unknown> = { ...(document as Record<string, unknown>) };
3655
+ for (const entryPath of entryPaths) {
3656
+ const separator = entryPath.indexOf(".");
3657
+ if (separator <= 0) continue;
3658
+ const section = entryPath.slice(0, separator);
3659
+ const id = entryPath.slice(separator + 1);
3660
+ const container = next[section];
3661
+ if (!container || typeof container !== "object" || Array.isArray(container)) continue;
3662
+ const kept: Record<string, unknown> = { ...(container as Record<string, unknown>) };
3663
+ delete kept[id];
3664
+ next[section] = kept;
3665
+ }
3666
+ return next;
3667
+ }
3668
+
3669
+ /**
3670
+ * Entry ids are operator-chosen and can be token-shaped, so nothing dynamic reaches
3671
+ * the log unredacted. Static section names stay readable -- they are the part that
3672
+ * tells the operator where to look.
3673
+ */
3674
+ function redactEntryPath(entryPath: string): string {
3675
+ const separator = entryPath.indexOf(".");
3676
+ if (separator <= 0) return redactSecretString(entryPath);
3677
+ return entryPath.slice(0, separator) + "." + redactSecretString(entryPath.slice(separator + 1));
3678
+ }
3679
+
3680
+ function redactIssuePath(path: readonly PropertyKey[]): string {
3681
+ return path
3682
+ .map((segment, index) => (index === 0 && typeof segment === "string" ? segment : redactSecretString(String(segment))))
3683
+ .join(".");
3684
+ }
3685
+
3686
+ function warnDroppedConfigSections(configPath: string, dropped: string[], issues: readonly z.ZodIssue[]): void {
3687
+ if (warnedConfigFallbacks.has(configPath)) return;
3688
+ warnedConfigFallbacks.add(configPath);
3689
+ const reasons = issues
3690
+ .map(issue => `${redactIssuePath(issue.path)}: ${redactSecretString(issue.message)}`)
3691
+ .join("; ");
3692
+ console.error(
3693
+ `opencodex config at ${configPath}: dropped [${dropped.map(redactEntryPath).join(", ")}] and loaded the rest — ${reasons}. `
3694
+ + "Everything else in your config, including providers and modelCosts, is preserved.",
3695
+ );
3696
+ }
3697
+
3431
3698
  export function readPidFileValue(): number | null {
3432
3699
  try {
3433
3700
  return parsePidFile(readFileSync(getPidPath(), "utf-8"));