@yansigit/opencodex 2.36.1-dev.20260829.47 → 2.36.1-dev.20260829.50
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/{ApiKeys-DAuYQsGJ.js → ApiKeys-BLPOvK4z.js} +1 -1
- package/gui/dist/assets/{Claude-bN_80RT8.js → Claude-BbHv3EU7.js} +1 -1
- package/gui/dist/assets/{CodexSet-D3dWX_ay.js → CodexSet-hgu5hES-.js} +1 -1
- package/gui/dist/assets/{FileIntegrationPage-BGyzkPs2.js → FileIntegrationPage-RCmDvvsx.js} +1 -1
- package/gui/dist/assets/{Grok-CuaKd1TT.js → Grok-DH-ggRqA.js} +1 -1
- package/gui/dist/assets/{Integrations-C7NGQoxR.js → Integrations-D6m5E2-T.js} +2 -2
- package/gui/dist/assets/{IntegrationsOverview-DjVorYO5.js → IntegrationsOverview-C1rbnXV7.js} +1 -1
- package/gui/dist/assets/{Logs-B21a-9xe.js → Logs-Bqb0vYRk.js} +1 -1
- package/gui/dist/assets/{Models-CN7Q3kuK.js → Models-gMnzHf0c.js} +1 -1
- package/gui/dist/assets/{NumberStepper-BWg90tEs.js → NumberStepper-awaoZNlj.js} +1 -1
- package/gui/dist/assets/{Providers-CP_i-7LS.js → Providers-Bo6wuQMa.js} +1 -1
- package/gui/dist/assets/{RestoreDialog-3M0NqCiD.js → RestoreDialog-DX7SGWIk.js} +1 -1
- package/gui/dist/assets/{Startup-BVPFoP2s.js → Startup-B1htiLU9.js} +1 -1
- package/gui/dist/assets/{Storage-BdsG9ypz.js → Storage-B3MfosmS.js} +1 -1
- package/gui/dist/assets/{Subagents-CguR_bGQ.js → Subagents-L6Q2V9PZ.js} +1 -1
- package/gui/dist/assets/{Usage-C0kKi9_H.js → Usage-xbZnkDoj.js} +1 -1
- package/gui/dist/assets/{codex-stale-banner-D4gvSWnO.js → codex-stale-banner-W4FhikN7.js} +1 -1
- package/gui/dist/assets/{data-surface-BtO3lwam.js → data-surface-B8PX8rjm.js} +1 -1
- package/gui/dist/assets/{data-surface-BCycbObp.js → data-surface-BbBMp0Wj.js} +1 -1
- package/gui/dist/assets/{index-CU1jE0st.js → index-JEC_6CUW.js} +3 -3
- package/gui/dist/assets/{model-display-D2elXkuE.js → model-display-BvLWy9D5.js} +1 -1
- package/gui/dist/assets/{provider-payload-DfkXWE7v.js → provider-payload-BRfSsonS.js} +1 -1
- package/gui/dist/assets/{section-tabs-BP7gEPK6.js → section-tabs-B8sLMiz6.js} +1 -1
- package/gui/dist/assets/shared-DI18-uF7.js +69 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -1
- package/src/adapters/base.ts +26 -0
- package/src/adapters/cursor/catalog.ts +541 -0
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +34 -41
- package/src/adapters/cursor/envelope-echo.ts +128 -0
- package/src/adapters/cursor/request-builder.ts +19 -12
- package/src/adapters/cursor/tool-definitions.ts +2 -1
- package/src/adapters/cursor/tool-result-normalize.ts +23 -31
- package/src/adapters/cursor.ts +21 -2
- package/src/adapters/exec-tool-result-normalize.ts +99 -0
- package/src/adapters/google-antigravity-replay.ts +71 -2
- package/src/adapters/google.ts +19 -4
- package/src/adapters/kiro-constants.ts +12 -0
- package/src/adapters/kiro.ts +128 -11
- package/src/adapters/openai-chat.ts +16 -2
- package/src/adapters/openai-responses.ts +15 -2
- package/src/adapters/tool-catalog-nudge.ts +2 -1
- package/src/adapters/xai-web-search.ts +10 -14
- package/src/claude/outbound.ts +14 -3
- package/src/cli/access.ts +46 -3
- package/src/cli/account-api.ts +93 -16
- package/src/cli/account-extended.ts +262 -36
- package/src/cli/account-main.ts +12 -12
- package/src/cli/account.ts +40 -10
- package/src/cli/agent.ts +8 -1
- package/src/cli/capabilities-command.ts +94 -0
- package/src/cli/capabilities.ts +535 -0
- package/src/cli/claude-desktop.ts +31 -11
- package/src/cli/config-command.ts +7 -3
- package/src/cli/dispatch.ts +195 -27
- package/src/cli/doctor.ts +100 -1
- package/src/cli/help.ts +11 -2
- package/src/cli/index.ts +19 -3
- package/src/cli/init.ts +2 -2
- package/src/cli/inspect.ts +230 -0
- package/src/cli/observe.ts +11 -3
- package/src/cli/provider.ts +58 -74
- package/src/cli/registry.ts +34 -2
- package/src/cli/runtime-api.ts +51 -7
- package/src/cli/status.ts +16 -0
- package/src/cli/storage.ts +234 -0
- package/src/cli/system-command.ts +16 -0
- package/src/cli/usage-report.ts +52 -2
- package/src/cli/version-skew.ts +46 -0
- package/src/codex/account-label.ts +21 -0
- package/src/codex/catalog/provider-fetch.ts +4 -0
- package/src/codex/convergence.ts +110 -15
- package/src/codex/transition-state.ts +12 -3
- package/src/compatibility/openai-responses.ts +9 -1
- package/src/config.ts +209 -11
- package/src/generated/compatibility-version.json +115 -75
- package/src/integrations/ownership-policy.ts +24 -5
- package/src/integrations/ownership.ts +36 -2
- package/src/integrations/state.ts +40 -7
- package/src/integrations/writer.ts +21 -3
- package/src/lib/admin-secrets.ts +24 -0
- package/src/lib/errors.ts +25 -1
- package/src/lib/service-secrets.ts +15 -0
- package/src/oauth/index.ts +143 -82
- package/src/oauth/login-cli.ts +53 -11
- package/src/oauth/store.ts +16 -6
- package/src/providers/alibaba-region-startup.ts +31 -11
- package/src/providers/api-keys.ts +58 -34
- package/src/providers/key-failover.ts +59 -46
- package/src/providers/label.ts +34 -1
- package/src/providers/model-rename-startup.ts +25 -7
- package/src/providers/openai-tier-startup.ts +38 -14
- package/src/responses/turn-termination.ts +107 -0
- package/src/server/index.ts +10 -0
- package/src/server/management/context.ts +10 -3
- package/src/server/management/logs-usage-routes.ts +24 -43
- package/src/server/management/model-routes.ts +284 -165
- package/src/server/management/oauth-account-routes.ts +93 -15
- package/src/server/management/provider-routes.ts +200 -87
- package/src/server/management/route-registry.ts +317 -0
- package/src/server/proxy-liveness.ts +27 -4
- package/src/server/request-log.ts +37 -17
- package/src/server/responses/core.ts +80 -4
- package/src/server/responses/policy-fallback.ts +1 -1
- package/src/service.ts +34 -0
- package/src/storage/policy-input.ts +166 -0
- package/src/storage/policy-job.ts +14 -4
- package/src/storage/policy.ts +115 -221
- package/src/usage/log.ts +32 -6
- package/src/usage/summary.ts +22 -34
- package/gui/dist/assets/shared-BdYGV-yJ.js +0 -69
|
@@ -9,7 +9,7 @@ const FIXTURE_ID = "openai-codex-forward-gpt56-sol-v1";
|
|
|
9
9
|
export const OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST = defineCompatibilityManifest({
|
|
10
10
|
schemaVersion: 1,
|
|
11
11
|
id: "openai.codex-forward.gpt-5-6-sol.responses",
|
|
12
|
-
version: "1.
|
|
12
|
+
version: "1.3.0",
|
|
13
13
|
subject: {
|
|
14
14
|
providerId: "openai",
|
|
15
15
|
baseUrl: "https://chatgpt.com/backend-api/codex",
|
|
@@ -109,5 +109,13 @@ export const OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST = defineCompatibilityManife
|
|
|
109
109
|
limitation: "The field is removed without inventing a replacement prompt_cache_options value.",
|
|
110
110
|
evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["prompt-cache-retention-removed"] }],
|
|
111
111
|
},
|
|
112
|
+
{
|
|
113
|
+
id: "prompt-cache-options",
|
|
114
|
+
feature: "request.prompt_cache_options",
|
|
115
|
+
disposition: "unsupported",
|
|
116
|
+
summary: "The ChatGPT Codex forward route does not receive public prompt cache options.",
|
|
117
|
+
limitation: "The field is removed only for the canonical forward destination; public and custom Responses providers keep it.",
|
|
118
|
+
evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["prompt-cache-options-removed"] }],
|
|
119
|
+
},
|
|
112
120
|
],
|
|
113
121
|
} as const);
|
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
|
-
|
|
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
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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.
|