@yansigit/opencodex 2.35.2-dev.20260829.15 → 2.36.1-dev.20260829.19

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.
@@ -20,6 +20,13 @@ export interface ManagementApiDeps {
20
20
  /** Platform seam for capability projections; does not alter host-level startup behavior. */
21
21
  platform?: NodeJS.Platform;
22
22
  toggleCodexMultiAgentV2?: (enabled: boolean) => void;
23
+ /** Test seam for ordered V2 scalar side effects. */
24
+ v2ScalarWriters?: Partial<{
25
+ setAgentsEnabled: (value: boolean | null) => { ok: true; changed: boolean } | { ok: false; error: string };
26
+ setAgentsMaxDepth: (value: number | null) => { ok: true; changed: boolean } | { ok: false; error: string };
27
+ setSubagentDeveloperInstructions: (value: string | null) => { ok: true; changed: boolean } | { ok: false; error: string };
28
+ setMultiAgentModeHintText: (value: string | null) => { ok: true; changed: boolean } | { ok: false; error: string };
29
+ }>;
23
30
  toggleDefaultModeRequestUserInput?: (enabled: boolean) => void;
24
31
  createManagementConvergeCodex?: (config: Readonly<OcxConfig>) => ConvergeCodex;
25
32
  /** Startup-health seam keeps route tests from launching platform probes. */
@@ -102,6 +109,49 @@ export interface ManagementApiDeps {
102
109
  codexPromptPaths?: CodexPromptPaths;
103
110
  }
104
111
 
112
+ /** A direct route dispatch has no authority to write the operator's config. */
113
+ export class MissingManagementPersistenceError extends Error {
114
+ constructor() {
115
+ super("Management config persistence is unavailable.");
116
+ this.name = "MissingManagementPersistenceError";
117
+ }
118
+ }
119
+
120
+ /** Marks a failed management persistence dependency so dispatch can restore its live snapshot. */
121
+ export class ManagementPersistenceError extends Error {
122
+ readonly code: unknown;
123
+ response?: Response;
124
+
125
+ constructor(cause: unknown) {
126
+ super("Management config persistence failed.", { cause });
127
+ this.name = "ManagementPersistenceError";
128
+ this.code = cause && typeof cause === "object" ? (cause as { code?: unknown }).code : undefined;
129
+ }
130
+ }
131
+
132
+ /** The only whole-live-config persistence boundary available to management routes. */
133
+ export function saveManagementConfig(deps: ManagementApiDeps, config: OcxConfig): void {
134
+ if (!deps.saveConfigPreservingClaudeCode) throw new MissingManagementPersistenceError();
135
+ try {
136
+ deps.saveConfigPreservingClaudeCode(config);
137
+ } catch (error) {
138
+ throw new ManagementPersistenceError(error);
139
+ }
140
+ }
141
+
142
+ /** The only locked, field-scoped on-disk mutation boundary available to management routes. */
143
+ export function mutateManagementConfig<T>(
144
+ deps: ManagementApiDeps,
145
+ mutate: Parameters<NonNullable<ManagementApiDeps["mutatePersistedConfig"]>>[0],
146
+ ): ReturnType<NonNullable<ManagementApiDeps["mutatePersistedConfig"]>> {
147
+ if (!deps.mutatePersistedConfig) throw new MissingManagementPersistenceError();
148
+ try {
149
+ return deps.mutatePersistedConfig(mutate) as ReturnType<NonNullable<ManagementApiDeps["mutatePersistedConfig"]>>;
150
+ } catch (error) {
151
+ throw new ManagementPersistenceError(error);
152
+ }
153
+ }
154
+
105
155
 
106
156
  export interface ManagementContext {
107
157
  req: Request;
@@ -10,7 +10,6 @@ import {
10
10
  multiAgentGuidanceEnabled,
11
11
  providerBaseUrlConfigError,
12
12
  providerHeadersConfigError,
13
- saveConfigPreservingClaudeCode,
14
13
  } from "../../config";
15
14
  import {
16
15
  clearLoginState,
@@ -81,7 +81,6 @@ import {
81
81
  multiAgentGuidanceEnabled,
82
82
  providerBaseUrlConfigError,
83
83
  providerHeadersConfigError,
84
- saveConfigPreservingClaudeCode,
85
84
  } from "../../config";
86
85
  import {
87
86
  clearLoginState,
@@ -148,7 +147,7 @@ import type {
148
147
 
149
148
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
150
149
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
151
- import type { ManagementContext } from "./context";
150
+ import { saveManagementConfig, type ManagementContext } from "./context";
152
151
  import { listManagementModelRows, loadExportModels } from "./model-rows";
153
152
  import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
154
153
  import {
@@ -177,7 +176,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
177
176
  // the real store; tests that pass an in-memory fixture inject a no-op/spy. Do not
178
177
  // bypass this seam with a dynamic config import — doing so replaced a user's
179
178
  // ~/.opencodex/config.json with the `existing-uuid` test fixture.
180
- const persistConfig = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
179
+ const persistConfig = (candidate: OcxConfig) => saveManagementConfig(deps, candidate);
181
180
 
182
181
  if (url.pathname === "/api/model-discovery" && req.method === "GET") {
183
182
  const providers = Object.fromEntries(Object.entries(config.providers).map(([name, provider]) => [
@@ -17,7 +17,7 @@
17
17
  * Design of record: devlog/_fin/260803_integrations_toggle_all/030 (routes),
18
18
  * 011 (Claude Code), 012 (Grok).
19
19
  */
20
- import { loadConfig, saveConfigPreservingClaudeCode } from "../../config";
20
+ import { loadConfig } from "../../config";
21
21
  import { readRuntimePort } from "../../config/process-state";
22
22
  import { desktopVisibleNativeSlugs, filterCatalogVisibleModels, nativeContextLimits, nativeOpenAiContextWindow, visibleNativeSlugs } from "../../codex/catalog";
23
23
  import { providerContextCap } from "../../providers/context-cap";
@@ -31,7 +31,7 @@ import type { CodexNativeRestoreResult } from "../../codex/inject";
31
31
  import type { OcxConfig } from "../../types";
32
32
  import { jsonResponse } from "../auth-cors";
33
33
  import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
34
- import type { ManagementContext } from "./context";
34
+ import { ManagementPersistenceError, saveManagementConfig, type ManagementContext } from "./context";
35
35
 
36
36
  export type NativeIntegrationClientId = "claude" | "grok" | "codex" | "claude-desktop";
37
37
 
@@ -221,9 +221,10 @@ function grokStatus(config: ManagementContext["config"]): NativeStatus {
221
221
  * cannot open, which fails identically forever (audit r8 #2).
222
222
  */
223
223
  function isLockContention(error: unknown): boolean {
224
- if (!error || typeof error !== "object") return false;
225
- const cause = (error as { cause?: { code?: unknown } }).cause;
226
- return cause?.code === "SQLITE_BUSY";
224
+ for (let current = error; current && typeof current === "object"; current = (current as { cause?: unknown }).cause) {
225
+ if ((current as { code?: unknown }).code === "SQLITE_BUSY") return true;
226
+ }
227
+ return false;
227
228
  }
228
229
 
229
230
  function isConfigLockError(error: unknown): boolean {
@@ -732,10 +733,20 @@ export async function handleNativeIntegrationRoutes(ctx: ManagementContext): Pro
732
733
  * `deps.` first: ManagementApiDeps carries this seam so route tests with an
733
734
  * in-memory fixture cannot overwrite the developer's real OPENCODEX_HOME.
734
735
  */
735
- const persist = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
736
+ const persist = (candidate: OcxConfig) => saveManagementConfig(deps, candidate);
736
737
  try {
737
738
  persist(config);
738
739
  } catch (error) {
740
+ if (error instanceof ManagementPersistenceError) {
741
+ if (isConfigLockError(error)) {
742
+ error.response = isLockContention(error)
743
+ ? refusal(409, "claude", "config_busy",
744
+ "Another process is saving the configuration right now. Try again in a moment.")
745
+ : refusal(500, "claude", "write_failed",
746
+ `The configuration lock could not be acquired: ${error.message}`);
747
+ }
748
+ throw error;
749
+ }
739
750
  if (isConfigLockError(error)) {
740
751
  return isLockContention(error)
741
752
  ? refusal(409, "claude", "config_busy",
@@ -12,7 +12,6 @@ import {
12
12
  providerHeadersConfigError,
13
13
  readConfigDiagnostics,
14
14
  reconcileLiveConfigFromDisk,
15
- saveConfigPreservingClaudeCode,
16
15
  } from "../../config";
17
16
  import {
18
17
  clearLoginState,
@@ -68,7 +67,7 @@ import { isAzureIdentityProvider } from "../../config/provider-validation";
68
67
 
69
68
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
70
69
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
71
- import type { ManagementContext } from "./context";
70
+ import { saveManagementConfig, type ManagementContext } from "./context";
72
71
  import { readManagementJsonBody, readManagementJsonBodyOr, rethrowManagementBodyTooLarge } from "./body";
73
72
  import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match";
74
73
  import { ACCOUNT_IMPORT_DEADLINE_MS, ACCOUNT_IMPORT_MAX_REQUEST_BYTES } from "../../oauth/account-import";
@@ -374,7 +373,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
374
373
  enabled = body.enabled;
375
374
  }
376
375
  config.cursorAccountPool = { enabled };
377
- saveConfigPreservingClaudeCode(config);
376
+ saveManagementConfig(deps, config);
378
377
  reconcileLiveStateStores();
379
378
  return jsonResponse({
380
379
  ok: true,
@@ -422,7 +421,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
422
421
  ...(strategy !== undefined ? { strategy } : {}),
423
422
  ...(stickyLimit !== undefined ? { stickyLimit } : {}),
424
423
  };
425
- saveConfigPreservingClaudeCode(config);
424
+ saveManagementConfig(deps, config);
426
425
  reconcileLiveStateStores();
427
426
  return jsonResponse({
428
427
  ok: true,
@@ -667,7 +666,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
667
666
  const key = "ocx_data_" + randomBytes(20).toString("hex");
668
667
  const entry = { id: randomUUID(), name, key, createdAt: new Date().toISOString() };
669
668
  config.apiKeys = [...(config.apiKeys ?? []), entry];
670
- saveConfigPreservingClaudeCode(config);
669
+ saveManagementConfig(deps, config);
671
670
  reconcileLiveStateStores();
672
671
  return jsonResponse({ id: entry.id, name: entry.name, key: entry.key, createdAt: entry.createdAt }, 201, req, config);
673
672
  }
@@ -681,7 +680,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
681
680
  const entry = (config.apiKeys ?? []).find(k => k.id === body.id);
682
681
  if (!entry) return jsonResponse({ error: "key not found" }, 404, req, config);
683
682
  entry.name = nameField.value;
684
- saveConfigPreservingClaudeCode(config);
683
+ saveManagementConfig(deps, config);
685
684
  reconcileLiveStateStores();
686
685
  // Never echo key material from a rename.
687
686
  return jsonResponse({ id: entry.id, name: entry.name, createdAt: entry.createdAt }, 200, req, config);
@@ -695,7 +694,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
695
694
  config.apiKeys = (config.apiKeys ?? []).filter(k => k.id !== body.id);
696
695
  // A stale id must not read as a successful revocation.
697
696
  if (config.apiKeys.length === before) return jsonResponse({ error: "key not found" }, 404, req, config);
698
- saveConfigPreservingClaudeCode(config);
697
+ saveManagementConfig(deps, config);
699
698
  reconcileLiveStateStores();
700
699
  return jsonResponse({ success: true }, 200, req, config);
701
700
  }
@@ -16,7 +16,6 @@ import {
16
16
  providerHeadersConfigError,
17
17
  requestPacingConfigError,
18
18
  readConfigAdmissionSnapshot,
19
- saveConfigPreservingClaudeCode,
20
19
  upstreamHttpVersionConfigError,
21
20
  withConfigMutationLockSync,
22
21
  } from "../../config";
@@ -95,7 +94,7 @@ import {
95
94
 
96
95
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
97
96
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
98
- import type { ManagementContext } from "./context";
97
+ import { saveManagementConfig, type ManagementContext } from "./context";
99
98
  import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
100
99
  import { resolveAiStudioCredentials } from "../../oauth/aistudio-credentials";
101
100
  import { buildAiStudioHeaders, parseGoogleCookieJar } from "../../oauth/google-aistudio-auth";
@@ -743,7 +742,6 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
743
742
  const submittedModelAutoCompactTokenLimits = Object.hasOwn(prov, "modelAutoCompactTokenLimits");
744
743
  const submittedRequestPacing = Object.hasOwn(prov, "requestPacing");
745
744
  enrichProviderFromCatalog(name, prov);
746
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
747
745
  // Overwriting an existing provider must not drop its multi-key pool: carry it over, then
748
746
  // let the (possibly new) apiKey join the pool as the active entry.
749
747
  const existingPool = config.providers[name]?.apiKeyPool;
@@ -786,7 +784,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
786
784
  }
787
785
  config.providers[name] = stripRegistryOnlyStaticHeaders(name, prov);
788
786
  if (body.setDefault === true) config.defaultProvider = name;
789
- save(config);
787
+ saveManagementConfig(deps, config);
790
788
  reconcileLiveStateStores();
791
789
  if (prov.apiKey && prov.apiKeyPool) {
792
790
  const { addProviderApiKey } = await import("../../providers/api-keys");
@@ -826,9 +824,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
826
824
  if (!provider || !isCanonicalOpenAiForwardProvider(provider)) {
827
825
  return jsonResponse({ error: "provider openai must be the canonical built-in provider" }, 400);
828
826
  }
829
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
830
827
  config.providers.openai = { ...provider, codexAccountMode: mode };
831
- save(config);
828
+ saveManagementConfig(deps, config);
832
829
  reconcileLiveStateStores();
833
830
  (deps.clearProviderQuotaCache ?? clearProviderQuotaCache)();
834
831
  (deps.clearThreadAccountMap ?? clearThreadAccountMap)();
@@ -853,9 +850,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
853
850
  if (config.providers[name]!.disabled) {
854
851
  return jsonResponse({ error: "cannot set a disabled provider as default", code: "default_provider_disabled" }, 400);
855
852
  }
856
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
857
853
  config.defaultProvider = name;
858
- save(config);
854
+ saveManagementConfig(deps, config);
859
855
  reconcileLiveStateStores();
860
856
  return jsonResponse({ success: true, name, defaultProvider: name });
861
857
  }
@@ -926,7 +922,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
926
922
  // A PATCH that managed headers owns the resulting block: the clear path restores
927
923
  // registry static headers, so exact-match stripping must not erase them again.
928
924
  config.providers[name] = replay.headersTouched ? replay.next : stripRegistryOnlyStaticHeaders(name, replay.next);
929
- saveConfigPreservingClaudeCode(config);
925
+ saveManagementConfig(deps, config);
930
926
  });
931
927
  if (replayError !== undefined) return jsonResponse({ error: replayError }, 409);
932
928
  reconcileLiveStateStores();
@@ -1125,13 +1121,12 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1125
1121
  combos: dependentCombos,
1126
1122
  }, 409);
1127
1123
  }
1128
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
1129
1124
  if (fallbackDefault) config.defaultProvider = fallbackDefault;
1130
1125
  delete config.providers[name];
1131
1126
  const { dropProviderCustomModels } = await import("../../providers/provider-id-rewrite");
1132
1127
  const droppedCustomModels = dropProviderCustomModels(config, name);
1133
1128
  setProviderContextCap(config, name, false);
1134
- save(config);
1129
+ saveManagementConfig(deps, config);
1135
1130
  await replaceProviderAccountSet(name, null);
1136
1131
  reconcileLiveStateStores();
1137
1132
  const { clearModelCache: clearCache } = await import("../../codex/model-cache");
@@ -1156,7 +1151,6 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1156
1151
  // property access, with the route's consistent 400 response.
1157
1152
  if (!isPlainRecord(rawBody)) return jsonResponse({ error: "provider-context-caps body must be a plain object" }, 400);
1158
1153
  const body = rawBody as { provider?: unknown; enabled?: unknown; value?: unknown; setAll?: unknown };
1159
- const { saveConfigPreservingClaudeCode: save } = await import("../../config");
1160
1154
  const { clearModelCache } = await import("../../codex/model-cache");
1161
1155
  const respond = (catalogRefresh: Awaited<ReturnType<typeof convergeCodexCatalog>>) => jsonResponse({
1162
1156
  ok: true,
@@ -1202,7 +1196,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1202
1196
  return jsonResponse({ error: "value must be a positive number" }, 400);
1203
1197
  }
1204
1198
  setProviderContextCap(config, provider, body.enabled, perProviderValue);
1205
- save(config);
1199
+ saveManagementConfig(deps, config);
1206
1200
  reconcileLiveStateStores();
1207
1201
  clearModelCache(provider);
1208
1202
  const catalogRefresh = await convergeCodexCatalog();
@@ -1228,7 +1222,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1228
1222
  const affected = Object.keys(providerContextCaps(config));
1229
1223
  const applyToAll = body.setAll === true;
1230
1224
  setGlobalContextCapValue(config, normalizedValue, applyToAll);
1231
- save(config);
1225
+ saveManagementConfig(deps, config);
1232
1226
  reconcileLiveStateStores();
1233
1227
  if (applyToAll) {
1234
1228
  for (const provider of affected) clearModelCache(provider);
@@ -1245,7 +1239,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1245
1239
  const before = Object.keys(providerContextCaps(config));
1246
1240
  const names = Object.keys(config.providers);
1247
1241
  setAllProviderContextCaps(config, names, body.setAll);
1248
- save(config);
1242
+ saveManagementConfig(deps, config);
1249
1243
  reconcileLiveStateStores();
1250
1244
  for (const provider of new Set([...before, ...names])) clearModelCache(provider);
1251
1245
  const catalogRefresh = await convergeCodexCatalog();
@@ -9,6 +9,8 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
9
9
  return typeof value === "object" && value !== null && !Array.isArray(value);
10
10
  }
11
11
 
12
+ const unavailableMutation = <T,>() => ({ status: "unavailable" as const, reason: "missing" as const });
13
+
12
14
  export async function handleReplitProviderRoutes(ctx: ManagementContext): Promise<Response | null> {
13
15
  const { req, url, config, deps, convergeCodexCatalog } = ctx;
14
16
 
@@ -41,7 +43,6 @@ export async function handleReplitProviderRoutes(ctx: ManagementContext): Promis
41
43
  if (body.setDefault !== undefined && typeof body.setDefault !== "boolean") {
42
44
  return jsonResponse({ error: "setDefault must be a boolean" }, 400);
43
45
  }
44
-
45
46
  const result = await installReplitProviderPair(config, {
46
47
  origin,
47
48
  gatewayKey,
@@ -49,10 +50,15 @@ export async function handleReplitProviderRoutes(ctx: ManagementContext): Promis
49
50
  replace: body.replace === true,
50
51
  setDefault: body.setDefault === true,
51
52
  }, {
52
- mutatePersistedConfig: deps.mutatePersistedConfig,
53
+ // Direct management dispatch has no authority to activate the service's CLI fallback.
54
+ mutatePersistedConfig: deps.mutatePersistedConfig ?? unavailableMutation,
53
55
  probeFetch: deps.probeFetch,
54
56
  });
55
57
 
58
+ if (!deps.mutatePersistedConfig) {
59
+ return jsonResponse({ error: "management persistence unavailable" }, 500, req, config);
60
+ }
61
+
56
62
  if (!result.ok) {
57
63
  const status = result.code === "provider_collision"
58
64
  ? 409
@@ -20,12 +20,12 @@ import { assemblePolicyCandidateEvidence } from "../../routing/compatibility/ass
20
20
  import { activateLab, labActivationRequired } from "../../lib/lab-activation";
21
21
  import { quotaEvidenceForCandidate } from "../../routing/quota";
22
22
  import { routedProviderConfig } from "../../router";
23
- import { deleteConfigTopLevelKey, saveConfigPreservingClaudeCode, getConfigDir } from "../../config";
23
+ import { deleteConfigTopLevelKey, getConfigDir } from "../../config";
24
24
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
25
25
  import { isPlainRecord } from "./shared";
26
26
  import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
27
27
  import { jsonResponse } from "../auth-cors";
28
- import type { ManagementContext } from "./context";
28
+ import { saveManagementConfig, type ManagementContext } from "./context";
29
29
  import type { OcxConfig, OcxRoutingProfileConfig } from "../../types";
30
30
 
31
31
  function profileDto(config: Parameters<typeof getRoutingProfile>[0], id: string): Record<string, unknown> | null {
@@ -310,8 +310,7 @@ export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promis
310
310
  const newPublicModel = policyPublicModelId(id, getRoutingProfile(config, id)!);
311
311
  shouldSyncClaudeAgentDefs = migrateProfileModelReferences(config, oldPublicModel, newPublicModel);
312
312
  }
313
- const save = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
314
- save(config);
313
+ saveManagementConfig(deps, config);
315
314
  reconcileLiveStateStores();
316
315
  const catalogRefresh = await convergeCodexCatalog();
317
316
  if (shouldSyncClaudeAgentDefs) await syncClaudeAgentDefsBestEffort();
@@ -338,8 +337,7 @@ export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promis
338
337
  delete nextProfiles[id];
339
338
  if (Object.keys(nextProfiles).length > 0) config.routingProfiles = nextProfiles;
340
339
  else deleteConfigTopLevelKey(config, "routingProfiles");
341
- const saveConfigPreservingClaudeCodeSafe = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
342
- saveConfigPreservingClaudeCodeSafe(config);
340
+ saveManagementConfig(deps, config);
343
341
  reconcileLiveStateStores();
344
342
  const catalogRefresh = await convergeCodexCatalog();
345
343
  return jsonResponse({ success: true, id, catalogRefresh }, 200, req, config);
@@ -10,7 +10,6 @@ import {
10
10
  multiAgentGuidanceEnabled,
11
11
  providerBaseUrlConfigError,
12
12
  providerHeadersConfigError,
13
- saveConfigPreservingClaudeCode,
14
13
  } from "../../config";
15
14
  import {
16
15
  clearLoginState,
@@ -75,6 +75,7 @@ import { handleIntegrationRoutes } from "./management/integration-routes";
75
75
  import { handleNativeIntegrationRoutes } from "./management/native-integration-routes";
76
76
  import type { ManagementContext } from "./management/context";
77
77
  import type { ManagementPrincipal } from "./management-auth";
78
+ import { ManagementPersistenceError, MissingManagementPersistenceError } from "./management/context";
78
79
  export type { ManagementApiDeps } from "./management/context";
79
80
  import { fetchAllModels } from "./management/shared";
80
81
  import { CatalogGatherBusyError } from "../codex/catalog/provider-fetch";
@@ -216,6 +217,10 @@ export async function handleManagementAPI(
216
217
  } catch { /* best-effort */ }
217
218
  }
218
219
  const ctx: ManagementContext = { req, url, config, deps, principal, convergeCodexCatalog, syncClaudeAgentDefsBestEffort };
220
+ const configBeforeDispatch = (["GET", "HEAD", "OPTIONS"].includes(req.method)
221
+ || (req.method === "POST" && url.pathname === "/api/providers/test"))
222
+ ? undefined
223
+ : structuredClone(config);
219
224
  let routed: Response | null;
220
225
  try {
221
226
  routed = (await handleConfigRoutes(ctx))
@@ -239,6 +244,13 @@ export async function handleManagementAPI(
239
244
  } catch (error) {
240
245
  const tooLarge = managementBodyTooLargeResponse(error, req, config);
241
246
  if (tooLarge) return tooLarge;
247
+ if (error instanceof MissingManagementPersistenceError || error instanceof ManagementPersistenceError) {
248
+ if (configBeforeDispatch === undefined) throw error;
249
+ const response = error instanceof ManagementPersistenceError ? error.response : undefined;
250
+ for (const key of Object.keys(config)) delete (config as unknown as Record<string, unknown>)[key];
251
+ Object.assign(config, configBeforeDispatch);
252
+ return response ?? jsonResponse({ error: "management persistence unavailable" }, 500, req, config);
253
+ }
242
254
  if (error instanceof OAuthMutationBusyError) {
243
255
  return new Response(JSON.stringify({ error: { type: "server_error", code: "oauth_mutation_busy", message: error.message } }), {
244
256
  status: 503,