@yansigit/opencodex 2.36.1-dev.20260829.47 → 2.36.1-dev.20260829.48

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 (43) hide show
  1. package/gui/dist/assets/{ApiKeys-DAuYQsGJ.js → ApiKeys-LOOFiZfv.js} +1 -1
  2. package/gui/dist/assets/{Claude-bN_80RT8.js → Claude-BwKzpXe3.js} +1 -1
  3. package/gui/dist/assets/{CodexSet-D3dWX_ay.js → CodexSet-B5qi9KeE.js} +1 -1
  4. package/gui/dist/assets/{FileIntegrationPage-BGyzkPs2.js → FileIntegrationPage-B0sUef6W.js} +1 -1
  5. package/gui/dist/assets/{Grok-CuaKd1TT.js → Grok-bsb4n-f4.js} +1 -1
  6. package/gui/dist/assets/{Integrations-C7NGQoxR.js → Integrations-CSMwFDvM.js} +2 -2
  7. package/gui/dist/assets/{IntegrationsOverview-DjVorYO5.js → IntegrationsOverview-CTORdKJA.js} +1 -1
  8. package/gui/dist/assets/{Logs-B21a-9xe.js → Logs-BWYbfELf.js} +1 -1
  9. package/gui/dist/assets/{Models-CN7Q3kuK.js → Models-CexEtdT1.js} +1 -1
  10. package/gui/dist/assets/{NumberStepper-BWg90tEs.js → NumberStepper-BapSFQnW.js} +1 -1
  11. package/gui/dist/assets/{Providers-CP_i-7LS.js → Providers-BwUFeAgA.js} +1 -1
  12. package/gui/dist/assets/{RestoreDialog-3M0NqCiD.js → RestoreDialog-CD8piq90.js} +1 -1
  13. package/gui/dist/assets/{Startup-BVPFoP2s.js → Startup-BgX731C2.js} +1 -1
  14. package/gui/dist/assets/{Storage-BdsG9ypz.js → Storage-Bgq7HphP.js} +1 -1
  15. package/gui/dist/assets/{Subagents-CguR_bGQ.js → Subagents-ChxItbeF.js} +1 -1
  16. package/gui/dist/assets/{Usage-C0kKi9_H.js → Usage-BkylzP50.js} +1 -1
  17. package/gui/dist/assets/{data-surface-BCycbObp.js → data-surface-pCXChiBf.js} +1 -1
  18. package/gui/dist/assets/{index-CU1jE0st.js → index-CNopOid3.js} +2 -2
  19. package/gui/dist/assets/{model-display-D2elXkuE.js → model-display-CiUpg8T9.js} +1 -1
  20. package/gui/dist/assets/{provider-payload-DfkXWE7v.js → provider-payload-b2jlS-On.js} +1 -1
  21. package/gui/dist/index.html +1 -1
  22. package/package.json +1 -1
  23. package/src/cli/config-command.ts +7 -3
  24. package/src/cli/init.ts +2 -2
  25. package/src/cli/provider.ts +58 -74
  26. package/src/codex/convergence.ts +110 -15
  27. package/src/config.ts +209 -11
  28. package/src/generated/compatibility-version.json +24 -20
  29. package/src/oauth/index.ts +143 -82
  30. package/src/oauth/login-cli.ts +53 -11
  31. package/src/providers/alibaba-region-startup.ts +31 -11
  32. package/src/providers/api-keys.ts +58 -34
  33. package/src/providers/key-failover.ts +59 -46
  34. package/src/providers/model-rename-startup.ts +25 -7
  35. package/src/providers/openai-tier-startup.ts +38 -14
  36. package/src/server/index.ts +10 -0
  37. package/src/server/management/context.ts +10 -3
  38. package/src/server/management/logs-usage-routes.ts +19 -16
  39. package/src/server/management/model-routes.ts +284 -165
  40. package/src/server/management/oauth-account-routes.ts +93 -15
  41. package/src/server/management/provider-routes.ts +200 -87
  42. package/src/storage/policy-input.ts +166 -0
  43. package/src/storage/policy.ts +59 -211
@@ -8,7 +8,7 @@
8
8
  * show <name> Show provider config details (secrets masked)
9
9
  * set-default <name> Change the default provider
10
10
  */
11
- import { hasOwnProvider, isValidProviderName, loadConfig, sanitizeModelCostsForDisplay, saveConfig } from "../config";
11
+ import { hasOwnProvider, isValidProviderName, loadConfig, mutatePersistedConfig, sanitizeModelCostsForDisplay } from "../config";
12
12
  import { apiKeyTransportConfigError } from "../config/provider-validation";
13
13
  import { hasHelpFlag } from "./help";
14
14
  import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../providers/registry";
@@ -57,19 +57,26 @@ function maskSecret(value: string): string {
57
57
  }
58
58
 
59
59
  // ---------------------------------------------------------------------------
60
- // Validation helper (F1 fix: validate before saveConfig)
60
+ // Validation helper
61
61
  // ---------------------------------------------------------------------------
62
62
 
63
- function validateAndSave(config: ReturnType<typeof loadConfig>): void {
63
+ function validateProviderConfig(config: ReturnType<typeof loadConfig>): void {
64
64
  if (!config.providers || Object.keys(config.providers).length === 0) {
65
- console.error("Error: config would have no providers. Aborting.");
66
- process.exit(1);
65
+ throw new Error("config would have no providers");
67
66
  }
68
67
  if (!hasOwnProvider(config.providers, config.defaultProvider)) {
69
- console.error(`Error: defaultProvider "${config.defaultProvider}" does not exist in providers. Aborting.`);
70
- process.exit(1);
68
+ throw new Error(`defaultProvider "${config.defaultProvider}" does not exist in providers`);
69
+ }
70
+ }
71
+
72
+ function mutateProviderConfig<T>(mutate: (config: ReturnType<typeof loadConfig>) => { changed: boolean; value: T }): T {
73
+ const outcome = mutatePersistedConfig(mutate);
74
+ if (outcome.status === "unavailable") {
75
+ throw new Error(outcome.reason === "conflict"
76
+ ? "config changed while applying this provider update; retry"
77
+ : `config is ${outcome.reason}`);
71
78
  }
72
- saveConfig(config);
79
+ return outcome.value;
73
80
  }
74
81
 
75
82
  // ---------------------------------------------------------------------------
@@ -154,19 +161,6 @@ async function handleAdd(args: string[]): Promise<void> {
154
161
  const defaultModel = consumeFlagValue(restArgs, "--default-model");
155
162
  rejectUnknownArgs(restArgs, ADD_USAGE);
156
163
 
157
- const config = loadConfig();
158
-
159
- const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name);
160
- if (namespaceCollision) {
161
- console.error(`Error: ${namespaceCollision}.`);
162
- process.exit(1);
163
- }
164
-
165
- if (hasOwnProvider(config.providers, name) && !force) {
166
- console.error(`Provider "${name}" already exists. Use --force to overwrite.`);
167
- process.exit(1);
168
- }
169
-
170
164
  let provConfig: OcxProviderConfig;
171
165
  const registryEntry = getProviderRegistryEntry(name);
172
166
 
@@ -211,18 +205,21 @@ async function handleAdd(args: string[]): Promise<void> {
211
205
  provConfig.apiKeyTransport = apiKeyTransport;
212
206
  }
213
207
 
214
- const existingProvider = config.providers[name];
215
- config.providers[name] = provConfig;
216
- // A --force overwrite rotates the key/endpoint but must not drop a
217
- // user-configured price overlay (same rule as the /api/providers path and
218
- // the login paths); there is no explicit clear/replace flag yet.
219
- if (existingProvider?.modelCosts !== undefined && provConfig.modelCosts === undefined) {
220
- provConfig.modelCosts = existingProvider.modelCosts;
221
- }
222
208
  if (allowPrivateNetwork) provConfig.allowPrivateNetwork = true;
223
- if (setDefault) config.defaultProvider = name;
224
-
225
- validateAndSave(config);
209
+ const saved = mutateProviderConfig(config => {
210
+ const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name);
211
+ if (namespaceCollision) throw new Error(`${namespaceCollision}.`);
212
+ const existingProvider = config.providers[name];
213
+ if (existingProvider && !force) throw new Error(`Provider "${name}" already exists. Use --force to overwrite.`);
214
+ const next = structuredClone(provConfig);
215
+ // A --force overwrite rotates the key/endpoint but must not drop a user-configured price overlay.
216
+ if (existingProvider?.modelCosts !== undefined && next.modelCosts === undefined) next.modelCosts = existingProvider.modelCosts;
217
+ config.providers[name] = next;
218
+ if (setDefault) config.defaultProvider = name;
219
+ validateProviderConfig(config);
220
+ return { changed: JSON.stringify(existingProvider) !== JSON.stringify(next) || (setDefault && config.defaultProvider === name), value: { provider: next, defaultProvider: config.defaultProvider } };
221
+ });
222
+ provConfig = saved.provider;
226
223
 
227
224
  if (wantsJson) {
228
225
  console.log(JSON.stringify({
@@ -231,7 +228,7 @@ async function handleAdd(args: string[]): Promise<void> {
231
228
  adapter: provConfig.adapter,
232
229
  baseUrl: provConfig.baseUrl,
233
230
  defaultModel: provConfig.defaultModel ?? null,
234
- isDefault: config.defaultProvider === name,
231
+ isDefault: saved.defaultProvider === name,
235
232
  source: registryEntry ? "registry" : "custom",
236
233
  needsSync: true,
237
234
  }, null, 2));
@@ -285,42 +282,32 @@ function handleRemove(args: string[]): void {
285
282
  }
286
283
  rejectUnknownArgs(restArgs.slice(1), "Usage: ocx provider remove <name> [--json]");
287
284
 
288
- const config = loadConfig();
289
- if (!hasOwnProvider(config.providers, name)) {
290
- console.error(`Provider "${name}" is not configured.`);
291
- process.exit(1);
292
- }
293
-
294
- if (name === config.defaultProvider) {
295
- console.error(`Cannot remove "${name}" — it is the default provider. Change the default first: ocx provider set-default <other>`);
296
- process.exit(1);
297
- }
298
-
299
- if (Object.keys(config.providers).length <= 1) {
300
- console.error("Cannot remove the last provider.");
301
- process.exit(1);
302
- }
303
-
304
- const dependentCombos = Object.entries(config.combos ?? {})
305
- .filter(([, combo]) => combo.targets.some(target => target.provider === name))
306
- .map(([id]) => id)
307
- .sort();
308
- if (dependentCombos.length > 0) {
309
- console.error(`Cannot remove "${name}" — combo(s) depend on it: ${dependentCombos.join(", ")}`);
310
- process.exit(1);
311
- }
312
-
313
- delete config.providers[name];
314
- const droppedCustomModels = dropProviderCustomModels(config, name);
315
- validateAndSave(config);
285
+ const saved = mutateProviderConfig(config => {
286
+ if (!hasOwnProvider(config.providers, name)) throw new Error(`Provider "${name}" is not configured.`);
287
+ if (name === config.defaultProvider) throw new Error(`Cannot remove "${name}" — it is the default provider. Change the default first: ocx provider set-default <other>`);
288
+ if (Object.keys(config.providers).length <= 1) throw new Error("Cannot remove the last provider.");
289
+ const dependentCombos = Object.entries(config.combos ?? {})
290
+ .filter(([, combo]) => combo.targets.some(target => target.provider === name))
291
+ .map(([id]) => id).sort();
292
+ if (dependentCombos.length > 0) throw new Error(`Cannot remove "${name}" — combo(s) depend on it: ${dependentCombos.join(", ")}`);
293
+ const dependentProfiles = Object.entries(config.routingProfiles ?? {})
294
+ .filter(([, profile]) => profile.candidates.some(candidate => candidate.provider === name))
295
+ .map(([id]) => id).sort();
296
+ if (dependentProfiles.length > 0) throw new Error(`Cannot remove "${name}" — routing profile(s) depend on it: ${dependentProfiles.join(", ")}`);
297
+ delete config.providers[name];
298
+ const droppedCustomModels = dropProviderCustomModels(config, name);
299
+ validateProviderConfig(config);
300
+ return { changed: true, value: { droppedCustomModels, providers: Object.keys(config.providers), defaultProvider: config.defaultProvider } };
301
+ });
302
+ const { droppedCustomModels } = saved;
316
303
 
317
304
 
318
305
  if (wantsJson) {
319
306
  console.log(JSON.stringify({
320
307
  action: "removed",
321
308
  provider: name,
322
- remainingProviders: Object.keys(config.providers),
323
- defaultProvider: config.defaultProvider,
309
+ remainingProviders: saved.providers,
310
+ defaultProvider: saved.defaultProvider,
324
311
  needsSync: true,
325
312
  ...(droppedCustomModels > 0 ? { droppedCustomModels } : {}),
326
313
  }, null, 2));
@@ -396,13 +383,14 @@ function handleSetDefault(args: string[]): void {
396
383
  }
397
384
  rejectUnknownArgs(restArgs.slice(1), "Usage: ocx provider set-default <name> [--json]");
398
385
 
399
- const config = loadConfig();
400
- if (!hasOwnProvider(config.providers, name)) {
401
- console.error(`Provider "${name}" is not configured. Add it first: ocx provider add ${name}`);
402
- process.exit(1);
403
- }
404
-
405
- if (config.defaultProvider === name) {
386
+ const changed = mutateProviderConfig(config => {
387
+ if (!hasOwnProvider(config.providers, name)) throw new Error(`Provider "${name}" is not configured. Add it first: ocx provider add ${name}`);
388
+ if (config.defaultProvider === name) return { changed: false, value: false };
389
+ config.defaultProvider = name;
390
+ validateProviderConfig(config);
391
+ return { changed: true, value: true };
392
+ });
393
+ if (!changed) {
406
394
  if (wantsJson) {
407
395
  console.log(JSON.stringify({ action: "noop", provider: name, defaultProvider: name, needsSync: false }, null, 2));
408
396
  } else {
@@ -411,10 +399,6 @@ function handleSetDefault(args: string[]): void {
411
399
  return;
412
400
  }
413
401
 
414
- config.defaultProvider = name;
415
- validateAndSave(config);
416
-
417
-
418
402
  if (wantsJson) {
419
403
  console.log(JSON.stringify({ action: "set-default", provider: name, defaultProvider: name, needsSync: true }, null, 2));
420
404
  return;
@@ -1,10 +1,13 @@
1
1
  import { join } from "node:path";
2
2
 
3
- import { getConfigDir, saveConfigPreservingClaudeCode, websocketsEnabled, withExpectedConfigGenerationSync } from "../config";
4
- import { reconcileSuccessfulModelDiscoveries } from "../providers/new-model-policy";
3
+ import { getConfigDir, mutatePersistedConfig, websocketsEnabled, withExpectedConfigGenerationSync } from "../config";
4
+ import {
5
+ reconcileSuccessfulModelDiscoveries,
6
+ type KnownModelBaseline,
7
+ } from "../providers/new-model-policy";
5
8
  import { COMBO_NAMESPACE } from "../combos";
6
9
  import { getAuthStorePath } from "../oauth/store";
7
- import type { OcxConfig } from "../types";
10
+ import type { OcxConfig, OcxProviderConfig } from "../types";
8
11
  import { captureCatalogAdmissionSnapshot } from "./catalog-admission";
9
12
  import { legacyCustomModelCatalogSlugs } from "./custom-model-catalog-migration";
10
13
  import {
@@ -137,7 +140,22 @@ interface CandidateState {
137
140
  readonly changed: boolean;
138
141
  readonly notices: readonly CatalogNotice[];
139
142
  readonly modelEntitlements: CodexModelEntitlementSnapshot;
140
- readonly discoveryConfig?: OcxConfig;
143
+ readonly discovery: DiscoveryEvidence;
144
+ }
145
+
146
+ interface DiscoveryEvidence {
147
+ readonly models: readonly { provider: string; id: string; custom?: boolean }[];
148
+ readonly authoritativeProviders: readonly string[];
149
+ readonly providerIdentities: Readonly<Record<string, string>>;
150
+ readonly now: string;
151
+ }
152
+
153
+ interface DiscoveryProjection {
154
+ readonly providers: Readonly<Record<string, Readonly<{
155
+ knownModel?: KnownModelBaseline;
156
+ recentArrivals?: readonly { id: string; at: string }[];
157
+ }>>>;
158
+ readonly disabledModelsToAdd: readonly string[];
141
159
  }
142
160
 
143
161
  const candidateStates = new WeakMap<object, CandidateState>();
@@ -145,6 +163,16 @@ function same(left: unknown, right: unknown): boolean {
145
163
  return JSON.stringify(left) === JSON.stringify(right);
146
164
  }
147
165
 
166
+ function discoveryProviderIdentity(provider: OcxProviderConfig | undefined): string {
167
+ if (!provider) return "missing";
168
+ const row: Record<string, unknown> = { ...provider };
169
+ delete row.note;
170
+ delete row.newModelPolicy;
171
+ return JSON.stringify(row, (_key, value) => value && typeof value === "object" && !Array.isArray(value)
172
+ ? Object.fromEntries(Object.entries(value as Record<string, unknown>).sort(([left], [right]) => left.localeCompare(right)))
173
+ : value);
174
+ }
175
+
148
176
  function targetPath(identity: string): string {
149
177
  const parsed = JSON.parse(identity) as { path?: unknown };
150
178
  if (typeof parsed.path !== "string") throw new TypeError("Catalog target identity has no path.");
@@ -441,13 +469,15 @@ export async function gatherCodexCatalogCandidate(
441
469
  : !hasRoutedEntries(source.catalog) ? source.catalog : null)
442
470
  : null);
443
471
  const discoveryConfig = structuredClone(snapshot.config) as OcxConfig;
444
- const discoveryChanged = reconcileSuccessfulModelDiscoveries({
472
+ const authoritativeProviders = providerModelOutcomes
473
+ .filter(outcome => outcome.state === "authoritative")
474
+ .map(outcome => outcome.provider);
475
+ const discoveryNow = new Date().toISOString();
476
+ reconcileSuccessfulModelDiscoveries({
445
477
  config: discoveryConfig,
446
478
  models: routedModels,
447
- authoritativeProviders: providerModelOutcomes
448
- .filter(outcome => outcome.state === "authoritative")
449
- .map(outcome => outcome.provider),
450
- now: new Date().toISOString(),
479
+ authoritativeProviders,
480
+ now: discoveryNow,
451
481
  });
452
482
  const preparedCatalog = prepareCatalog(
453
483
  discoveryConfig,
@@ -515,7 +545,15 @@ export async function gatherCodexCatalogCandidate(
515
545
  || Buffer.from(cacheBytes ?? []).toString("utf8") !== preparedCacheBytes,
516
546
  notices: Object.freeze([...notices]),
517
547
  modelEntitlements,
518
- ...(discoveryChanged ? { discoveryConfig } : {}),
548
+ discovery: {
549
+ models: routedModels,
550
+ authoritativeProviders,
551
+ providerIdentities: Object.fromEntries(authoritativeProviders.map(provider => [
552
+ provider,
553
+ discoveryProviderIdentity(snapshot.config.providers[provider]),
554
+ ])),
555
+ now: discoveryNow,
556
+ },
519
557
  });
520
558
  return { kind: "candidate", candidate };
521
559
  } catch (error) {
@@ -532,6 +570,63 @@ export async function gatherCodexCatalogCandidate(
532
570
  }
533
571
  }
534
572
 
573
+ function reconcilePersistedDiscovery(state: CandidateState) {
574
+ return mutatePersistedConfig(config => {
575
+ if (state.discovery.authoritativeProviders.some(provider => (
576
+ discoveryProviderIdentity(config.providers[provider]) !== state.discovery.providerIdentities[provider]
577
+ ))) {
578
+ return { changed: false, value: null };
579
+ }
580
+ const disabledBefore = new Set(config.disabledModels ?? []);
581
+ const changed = reconcileSuccessfulModelDiscoveries({
582
+ config,
583
+ ...state.discovery,
584
+ });
585
+ const providers = Object.fromEntries(state.discovery.authoritativeProviders.flatMap(provider => {
586
+ const configured = config.providers[provider];
587
+ if (!configured || configured.liveModels === false) return [];
588
+ const knownModel = config.modelDiscovery?.knownModels?.[provider];
589
+ const recentArrivals = config.modelDiscovery?.recentArrivals?.[provider];
590
+ return [[provider, {
591
+ ...(knownModel ? { knownModel: structuredClone(knownModel) } : {}),
592
+ ...(recentArrivals ? { recentArrivals: structuredClone(recentArrivals) } : {}),
593
+ }]];
594
+ }));
595
+ return {
596
+ changed,
597
+ value: {
598
+ providers,
599
+ disabledModelsToAdd: (config.disabledModels ?? []).filter(slug => !disabledBefore.has(slug)),
600
+ } satisfies DiscoveryProjection,
601
+ };
602
+ });
603
+ }
604
+
605
+ function adoptDiscoveryProjection(config: OcxConfig, projection: DiscoveryProjection): void {
606
+ for (const [provider, discovered] of Object.entries(projection.providers)) {
607
+ if (discovered.knownModel) {
608
+ const discovery = config.modelDiscovery ??= {};
609
+ const knownModels = discovery.knownModels ??= {};
610
+ knownModels[provider] = structuredClone(discovered.knownModel);
611
+ } else if (config.modelDiscovery?.knownModels) {
612
+ delete config.modelDiscovery.knownModels[provider];
613
+ }
614
+ if (discovered.recentArrivals) {
615
+ const discovery = config.modelDiscovery ??= {};
616
+ const recentArrivals = discovery.recentArrivals ??= {};
617
+ recentArrivals[provider] = discovered.recentArrivals.map(arrival => ({ ...arrival }));
618
+ } else if (config.modelDiscovery?.recentArrivals) {
619
+ delete config.modelDiscovery.recentArrivals[provider];
620
+ }
621
+ }
622
+ if (projection.disabledModelsToAdd.length > 0) {
623
+ const disabledModels = config.disabledModels ??= [];
624
+ for (const slug of projection.disabledModelsToAdd) {
625
+ if (!disabledModels.includes(slug)) disabledModels.push(slug);
626
+ }
627
+ }
628
+ }
629
+
535
630
  function revalidateCandidate(state: CandidateState): CodexCatalogCommitResult | null {
536
631
  if (!isCodexModelEntitlementSnapshotCurrent(state.modelEntitlements)) {
537
632
  return { kind: "stale", reason: "account-entitlement" };
@@ -663,11 +758,11 @@ export async function convergeCodexCatalog(
663
758
  const state = candidateStates.get(gathered.candidate as object)!;
664
759
  lifecycle.onCommitBegin?.();
665
760
  const committed = await commitCodexCatalogCandidate(gathered.candidate, request.deadlineMs);
666
- if (committed.kind === "committed" && state.discoveryConfig) {
667
- const mutable = snapshot.config as OcxConfig;
668
- mutable.modelDiscovery = state.discoveryConfig.modelDiscovery;
669
- mutable.disabledModels = state.discoveryConfig.disabledModels;
670
- saveConfigPreservingClaudeCode(mutable);
761
+ if (committed.kind === "committed") {
762
+ const persisted = reconcilePersistedDiscovery(state);
763
+ if ((persisted.status === "committed" || persisted.status === "unchanged") && persisted.value) {
764
+ adoptDiscoveryProjection(snapshot.config as OcxConfig, persisted.value);
765
+ }
671
766
  }
672
767
  return {
673
768
  changed: committed.kind === "committed" ? committed.changed : false,
package/src/config.ts CHANGED
@@ -96,7 +96,6 @@ import {
96
96
  isValidCost4Rate,
97
97
  refreshPreservedProviderOwner,
98
98
  refreshUserCostOverlays,
99
- withPreservedDiskOnlyProviders,
100
99
  } from "./usage/user-cost-overlays";
101
100
  import { MAX_COST4_RATE } from "./usage/expected-prices";
102
101
  import {
@@ -106,6 +105,8 @@ import {
106
105
  } from "./lib/app-owned-memory";
107
106
  import { isHostedToolUnsupportedForModel } from "./responses/hosted-tool-policy";
108
107
  import {
108
+ AtomicWriteResidualTempError,
109
+ AtomicWriteSecretResidualError,
109
110
  atomicWriteFile,
110
111
  isMissingPathError,
111
112
  nextAtomicTempSequence,
@@ -2732,23 +2733,34 @@ export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync
2732
2733
  * cost-overlay registry from the persisted config so runtime estimates follow
2733
2734
  * every save path.
2734
2735
  */
2735
- function persistConfigUnlocked(config: OcxConfig): boolean {
2736
+ type PersistConfigAuthority = "ordinary" | "mutation" | "replacement";
2737
+
2738
+ function persistConfigUnlocked(config: OcxConfig, authority: PersistConfigAuthority = "ordinary"): boolean {
2736
2739
  const configPath = getConfigPath();
2737
2740
  // Check the resolved file target before reading it: a symlink can point from an
2738
2741
  // isolated test home into the protected real home, where another write guard
2739
2742
  // must not mask this refusal based on the target's current contents.
2740
2743
  assertNotRealHomeUnderTest(dirname(resolveWriteTarget(configPath)));
2741
2744
  const raw = readRawConfigJson();
2742
- if (raw && configNeedsProviderRepair(raw)) {
2745
+ if (authority !== "replacement" && raw && configNeedsProviderRepair(raw)) {
2743
2746
  throw new Error("refusing to overwrite a config repaired with defaults; fix the persisted config first");
2744
2747
  }
2745
- // External editors can add provider rows the live config deliberately does
2746
- // not route with yet; merge them at the serialization boundary so an
2747
- // unrelated in-process save cannot erase the provider or its overlay.
2748
- // Provider preservation reads symbol-keyed live-owner state, which structuredClone
2749
- // intentionally drops. Resolve that ownership before projecting JSON provenance.
2748
+ const snapshot = readConfigFileSnapshot();
2749
+ if (authority !== "replacement" && snapshot.diagnostics.source === "fallback") {
2750
+ throw new Error("refusing to overwrite an invalid persisted config; fix the persisted config first");
2751
+ }
2752
+ // Automatic whole-config writes own non-provider settings only. A valid disk
2753
+ // registry is authoritative; explicit locked mutations pass replacement
2754
+ // authority for intentional provider/default changes.
2755
+ const base = snapshot.diagnostics.source === "file" && authority === "ordinary"
2756
+ ? {
2757
+ ...config,
2758
+ providers: snapshot.diagnostics.config.providers,
2759
+ defaultProvider: snapshot.diagnostics.config.defaultProvider,
2760
+ }
2761
+ : config;
2750
2762
  const provenanceProjection = projectConfigRebaseProvenance(config);
2751
- const persisted = withPreservedDiskOnlyProviders(config);
2763
+ const persisted = base;
2752
2764
  if (provenanceProjection.configRebaseProvenance === undefined) delete persisted.configRebaseProvenance;
2753
2765
  else persisted.configRebaseProvenance = provenanceProjection.configRebaseProvenance;
2754
2766
  const bytes = JSON.stringify(persisted, null, 2) + "\n";
@@ -2790,6 +2802,183 @@ export function saveConfig(config: OcxConfig): void {
2790
2802
  });
2791
2803
  }
2792
2804
 
2805
+ /** Replace a validated config under the shared lock for confirmed import/init flows. */
2806
+ export function replacePersistedConfig(config: OcxConfig): void {
2807
+ assertNotRealHomeUnderTest(getConfigDir());
2808
+ withConfigMutationLockSync(() => {
2809
+ const projected = projectCustomModelCatalogMigration(
2810
+ readRawConfigJson(),
2811
+ projectConfigRebaseProvenance(config),
2812
+ );
2813
+ if (persistConfigUnlocked(projected, "replacement")) bumpGenerationForCooperatingConfigWrite();
2814
+ adoptCustomModelCatalogMigration(config, projected);
2815
+ if (projected.configRebaseProvenance === undefined) delete config.configRebaseProvenance;
2816
+ else config.configRebaseProvenance = structuredClone(projected.configRebaseProvenance);
2817
+ clearPendingConfigTopLevelDeletions(config);
2818
+ });
2819
+ }
2820
+
2821
+ export type PersistedConfigInitializationOutcome = "created" | "exists" | "invalid";
2822
+
2823
+ export class PersistedConfigInitializationCleanupError extends Error {
2824
+ constructor(options?: ErrorOptions) {
2825
+ super("Initial config publication cleanup failed after rollback", options);
2826
+ this.name = "PersistedConfigInitializationCleanupError";
2827
+ }
2828
+ }
2829
+
2830
+ export class PersistedConfigInitializationRollbackError extends Error {
2831
+ constructor(options?: ErrorOptions) {
2832
+ super("Initial config publication rollback failed", options);
2833
+ this.name = "PersistedConfigInitializationRollbackError";
2834
+ }
2835
+ }
2836
+
2837
+ export interface PersistedConfigInitializationIO {
2838
+ createExclusive(path: string): void;
2839
+ write(path: string, bytes: string): void;
2840
+ harden(path: string): void;
2841
+ publishNoReplace(temp: string, target: string): void;
2842
+ truncate(path: string): void;
2843
+ unlink(path: string): void;
2844
+ }
2845
+
2846
+ let persistedConfigInitializationBeforePublishForTests: (() => void) | null = null;
2847
+
2848
+ /** Test-only one-shot seam: create a competing config after staging, before no-replace publication. */
2849
+ export function setPersistedConfigInitializationBeforePublishForTests(hook: (() => void) | null): void {
2850
+ persistedConfigInitializationBeforePublishForTests = hook;
2851
+ }
2852
+
2853
+ function publishInitialConfigNoReplace(
2854
+ config: OcxConfig,
2855
+ io: PersistedConfigInitializationIO,
2856
+ ): boolean {
2857
+ const configPath = getConfigPath();
2858
+ const target = resolveWriteTarget(configPath);
2859
+ assertNotRealHomeUnderTest(dirname(target));
2860
+ recordOwnedConfigPath(getConfigDir(), configPath);
2861
+ const persisted = projectConfigRebaseProvenance(config);
2862
+ const bytes = JSON.stringify(persisted, null, 2) + "\n";
2863
+ const temp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`;
2864
+ let staged = false;
2865
+ let hardened = false;
2866
+ let published = false;
2867
+ let cleanupAttempted = false;
2868
+
2869
+ const scrubUnpublishedTemp = (cause?: unknown): void => {
2870
+ cleanupAttempted = true;
2871
+ let scrubbed = false;
2872
+ try {
2873
+ io.truncate(temp);
2874
+ scrubbed = true;
2875
+ } catch (error) {
2876
+ if (isMissingPathError(error)) scrubbed = true;
2877
+ else {
2878
+ try { io.write(temp, ""); scrubbed = true; } catch { /* removal may still succeed */ }
2879
+ }
2880
+ }
2881
+ let removed = false;
2882
+ try {
2883
+ io.unlink(temp);
2884
+ removed = true;
2885
+ } catch (error) {
2886
+ if (isMissingPathError(error)) removed = true;
2887
+ else {
2888
+ try { io.unlink(temp); removed = true; }
2889
+ catch (retryError) { if (isMissingPathError(retryError)) removed = true; }
2890
+ }
2891
+ }
2892
+ if (removed) forgetEphemeralSecretPath(temp);
2893
+ if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(temp, { cause });
2894
+ if (!removed) throw new AtomicWriteResidualTempError(temp, hardened, { cause });
2895
+ };
2896
+
2897
+ try {
2898
+ io.createExclusive(temp);
2899
+ staged = true;
2900
+ io.write(temp, bytes);
2901
+ io.harden(temp);
2902
+ hardened = true;
2903
+ const hook = persistedConfigInitializationBeforePublishForTests;
2904
+ persistedConfigInitializationBeforePublishForTests = null;
2905
+ hook?.();
2906
+ try {
2907
+ io.publishNoReplace(temp, target);
2908
+ } catch (cause) {
2909
+ if (!isAlreadyExistsError(cause)) throw cause;
2910
+ scrubUnpublishedTemp(cause);
2911
+ return false;
2912
+ }
2913
+ published = true;
2914
+ try {
2915
+ io.unlink(temp);
2916
+ forgetEphemeralSecretPath(temp);
2917
+ } catch (firstError) {
2918
+ if (isMissingPathError(firstError)) {
2919
+ forgetEphemeralSecretPath(temp);
2920
+ } else try {
2921
+ io.unlink(temp);
2922
+ forgetEphemeralSecretPath(temp);
2923
+ } catch (secondError) {
2924
+ if (isMissingPathError(secondError)) {
2925
+ forgetEphemeralSecretPath(temp);
2926
+ } else {
2927
+ // Both names point to one inode. Remove the published name before scrubbing.
2928
+ try { io.unlink(target); }
2929
+ catch (cause) { throw new PersistedConfigInitializationRollbackError({ cause }); }
2930
+ published = false;
2931
+ scrubUnpublishedTemp(secondError);
2932
+ throw new PersistedConfigInitializationCleanupError({ cause: secondError });
2933
+ }
2934
+ }
2935
+ }
2936
+ refreshUserCostOverlays(persisted);
2937
+ return true;
2938
+ } catch (cause) {
2939
+ if (staged && !published && !cleanupAttempted) scrubUnpublishedTemp(cause);
2940
+ throw cause;
2941
+ }
2942
+ }
2943
+
2944
+ function defaultPersistedConfigInitializationIO(configPath: string): PersistedConfigInitializationIO {
2945
+ return {
2946
+ createExclusive: target => { writeFileSync(target, "", { flag: "wx", mode: 0o600 }); },
2947
+ write: (target, bytes) => writeFileSync(target, bytes),
2948
+ harden: target => {
2949
+ try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
2950
+ if (process.platform === "win32") hardenSecretPath(target, { required: true, timeoutMemoKey: configPath });
2951
+ },
2952
+ publishNoReplace: (temp, target) => linkSync(temp, target),
2953
+ truncate: target => truncateSync(target, 0),
2954
+ unlink: unlinkSync,
2955
+ };
2956
+ }
2957
+
2958
+ /** Create the initial config under the shared lock, but never replace existing bytes. */
2959
+ export function initializePersistedConfigIfMissing(
2960
+ config: OcxConfig,
2961
+ io = defaultPersistedConfigInitializationIO(getConfigPath()),
2962
+ ): PersistedConfigInitializationOutcome {
2963
+ assertNotRealHomeUnderTest(getConfigDir());
2964
+ return withConfigMutationLockSync(() => {
2965
+ const snapshot = readConfigFileSnapshot();
2966
+ if (snapshot.diagnostics.source === "file") return "exists";
2967
+ if (snapshot.diagnostics.source !== "default") return "invalid";
2968
+ const projected = projectCustomModelCatalogMigration(
2969
+ readRawConfigJson(),
2970
+ projectConfigRebaseProvenance(config),
2971
+ );
2972
+ if (!publishInitialConfigNoReplace(projected, io)) {
2973
+ const winner = readConfigFileSnapshot();
2974
+ return winner.diagnostics.source === "file" ? "exists" : "invalid";
2975
+ }
2976
+ bumpGenerationForCooperatingConfigWrite();
2977
+ adoptCustomModelCatalogMigration(config, projected);
2978
+ return "created";
2979
+ });
2980
+ }
2981
+
2793
2982
  export type PersistedConfigMutation<T> = {
2794
2983
  changed: boolean;
2795
2984
  value: T;
@@ -2799,6 +2988,13 @@ export type PersistedConfigMutationOutcome<T> =
2799
2988
  | { status: "committed" | "unchanged"; value: T }
2800
2989
  | { status: "unavailable"; reason: "missing" | "invalid" | "conflict" };
2801
2990
 
2991
+ export class ConfigMutationValidationError extends Error {
2992
+ constructor(readonly validationError: string) {
2993
+ super(`Config mutation rejected: ${validationError}`);
2994
+ this.name = "ConfigMutationValidationError";
2995
+ }
2996
+ }
2997
+
2802
2998
  const CONFIG_MUTATION_MAX_REBASE_ATTEMPTS = 3;
2803
2999
  let persistedConfigMutationBeforeCommitForTests: (() => void) | null = null;
2804
3000
 
@@ -2871,7 +3067,9 @@ export function mutatePersistedConfig<T>(
2871
3067
  commitBase.diagnostics.config,
2872
3068
  confirmedConfig,
2873
3069
  );
2874
- if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite();
3070
+ const validation = validateConfigCandidate(projected);
3071
+ if (!validation.ok) throw new ConfigMutationValidationError(validation.error);
3072
+ if (persistConfigUnlocked(projected, "mutation")) bumpGenerationForCooperatingConfigWrite();
2875
3073
  return { status: "committed", value: confirmed.value };
2876
3074
  }
2877
3075
  return { status: "unavailable", reason: "conflict" };
@@ -3193,7 +3391,7 @@ function readPersistedServerBinding(
3193
3391
  * conflict keeps the live value;
3194
3392
  * - a provider or custom-model row deleted on disk stays deleted even if stale
3195
3393
  * live state edited that same row;
3196
- * - file missing/unreadable → save what we have, no throw.
3394
+ * - missing file → save what we have; invalid existing file → fail closed.
3197
3395
  *
3198
3396
  * Custom-model rows are merged by their stable `id`, preserving independent
3199
3397
  * edits and deletions across stale whole-config saves.