Skip to content

Commit 7878afe

Browse files
committed
Fail closed instead of rewriting clobbered settings
1 parent 45996f9 commit 7878afe

4 files changed

Lines changed: 83 additions & 41 deletions

File tree

src/config/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -695,16 +695,16 @@ export async function loadConfig(
695695
};
696696
const settings =
697697
configPath !== undefined
698-
? await loadSettingsRecoveringClobberedOAuthSelection(
699-
configPath,
700-
projectedOAuthProviders,
701-
).then((s) => {
698+
? await loadSettingsRecoveringClobberedOAuthSelection(configPath, projectedOAuthProviders, {
699+
persist: false,
700+
}).then((s) => {
702701
if (s === null) throw new Error(`--config file not found or empty: ${configPath}`);
703702
return s;
704703
})
705704
: await loadSettingsRecoveringClobberedOAuthSelection(
706705
effectiveSettingsPath,
707706
projectedOAuthProviders,
707+
{ persist: true },
708708
);
709709

710710
// Track whether the effective value came from the persisted global default

src/config/settings.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -886,14 +886,14 @@ export async function loadSettings(path: string): Promise<Settings | null> {
886886
export async function loadSettingsRecoveringClobberedOAuthSelection(
887887
path: string,
888888
recoverableOAuthProviders: Record<string, ProviderSettings>,
889+
options: { persist: boolean },
889890
): Promise<Settings | null> {
890891
const parsed = await loadSettingsJSON(path);
891892
if (parsed === null) return null;
892893
if (isClobberedLocalSelection(parsed)) {
893-
const recovered = recoverClobberedOAuthSelection(parsed, recoverableOAuthProviders) ?? {
894-
providers: {},
895-
};
896-
await saveGlobalSettings(path, recovered);
894+
const recovered = recoverClobberedOAuthSelection(parsed, recoverableOAuthProviders);
895+
if (recovered === undefined) throw settingsSchemaError(path);
896+
if (options.persist) await saveGlobalSettings(path, recovered);
897897
return recovered;
898898
}
899899
return loadStrictSettings(path, parsed);

src/settings.test.ts

Lines changed: 64 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -628,7 +628,9 @@ describe("loaders", () => {
628628
]),
629629
);
630630

631-
const recovered = await loadSettingsRecoveringClobberedOAuthSelection(path, projected);
631+
const recovered = await loadSettingsRecoveringClobberedOAuthSelection(path, projected, {
632+
persist: true,
633+
});
632634
expect(recovered).toEqual({
633635
defaultProvider: "codex/work",
634636
providers: {
@@ -655,14 +657,18 @@ describe("loaders", () => {
655657
path,
656658
JSON.stringify({ provider: "codex/work", model: "gpt-special-custom" }),
657659
);
658-
const recovered = await loadSettingsRecoveringClobberedOAuthSelection(path, {
659-
"codex/work": {
660-
baseURL: "https://chatgpt.com/backend-api",
661-
apiKey: "oauth-token",
662-
models: ["gpt-5.2-codex", "gpt-5.1-codex"],
663-
defaultModel: "gpt-5.2-codex",
660+
const recovered = await loadSettingsRecoveringClobberedOAuthSelection(
661+
path,
662+
{
663+
"codex/work": {
664+
baseURL: "https://chatgpt.com/backend-api",
665+
apiKey: "oauth-token",
666+
models: ["gpt-5.2-codex", "gpt-5.1-codex"],
667+
defaultModel: "gpt-5.2-codex",
668+
},
664669
},
665-
});
670+
{ persist: true },
671+
);
666672
expect(recovered).toEqual({
667673
defaultProvider: "codex/work",
668674
providers: {
@@ -689,32 +695,67 @@ describe("loaders", () => {
689695
JSON.stringify({ provider: "codex/work", model: "gpt-5.1-codex", apiKey: "nope" }),
690696
);
691697
await expect(
692-
loadSettingsRecoveringClobberedOAuthSelection(path, {
693-
"codex/work": {
694-
baseURL: "https://chatgpt.com/backend-api",
695-
apiKey: "oauth-token",
696-
models: ["gpt-5.1-codex"],
698+
loadSettingsRecoveringClobberedOAuthSelection(
699+
path,
700+
{
701+
"codex/work": {
702+
baseURL: "https://chatgpt.com/backend-api",
703+
apiKey: "oauth-token",
704+
models: ["gpt-5.1-codex"],
705+
},
697706
},
698-
}),
707+
{ persist: true },
708+
),
709+
).rejects.toThrow(/Invalid settings schema/);
710+
} finally {
711+
await rm(dir, { recursive: true, force: true });
712+
}
713+
});
714+
715+
test("loadSettings fails closed on unmatched OAuth selections without touching the file", async () => {
716+
const dir = await mkdtemp(join(tmpdir(), "ic-settings-"));
717+
try {
718+
const path = join(dir, "settings.json");
719+
const original = JSON.stringify({ provider: "codex/missing", model: "gpt-5.1-codex" });
720+
await writeFile(path, original);
721+
await expect(
722+
loadSettingsRecoveringClobberedOAuthSelection(
723+
path,
724+
{
725+
"codex/work": {
726+
baseURL: "https://chatgpt.com/backend-api",
727+
apiKey: "oauth-token",
728+
models: ["gpt-5.1-codex"],
729+
},
730+
},
731+
{ persist: true },
732+
),
699733
).rejects.toThrow(/Invalid settings schema/);
734+
expect(await readFile(path, "utf8")).toBe(original);
700735
} finally {
701736
await rm(dir, { recursive: true, force: true });
702737
}
703738
});
704739

705-
test("loadSettings does not recover unmatched OAuth selections", async () => {
740+
test("loadSettingsRecoveringClobberedOAuthSelection leaves the file unchanged when persist is false", async () => {
706741
const dir = await mkdtemp(join(tmpdir(), "ic-settings-"));
707742
try {
708743
const path = join(dir, "settings.json");
709-
await writeFile(path, JSON.stringify({ provider: "codex/missing", model: "gpt-5.1-codex" }));
710-
const recovered = await loadSettingsRecoveringClobberedOAuthSelection(path, {
711-
"codex/work": {
712-
baseURL: "https://chatgpt.com/backend-api",
713-
apiKey: "oauth-token",
714-
models: ["gpt-5.1-codex"],
744+
const original = JSON.stringify({ provider: "codex/work", model: "gpt-5.1-codex" });
745+
await writeFile(path, original);
746+
const recovered = await loadSettingsRecoveringClobberedOAuthSelection(
747+
path,
748+
{
749+
"codex/work": {
750+
baseURL: "https://chatgpt.com/backend-api",
751+
apiKey: "oauth-token",
752+
models: ["gpt-5.1-codex"],
753+
},
715754
},
716-
});
717-
expect(recovered).toEqual({ providers: {} });
755+
{ persist: false },
756+
);
757+
expect(recovered?.defaultProvider).toBe("codex/work");
758+
expect(await readFile(path, "utf8")).toBe(original);
718759
} finally {
719760
await rm(dir, { recursive: true, force: true });
720761
}

tests/unit/config.test.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { test, expect } from "bun:test";
2-
import { mkdtemp, mkdir, writeFile, rm, symlink } from "node:fs/promises";
2+
import { mkdtemp, mkdir, readFile, writeFile, rm, symlink } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { loadConfig } from "../../src/config/index.js";
@@ -153,25 +153,26 @@ test("symlink alias of the default global settings path is not a programmatic ov
153153
});
154154

155155
test("loadSettings recovery helper recovers only an exact clobbered local selection", async () => {
156-
const { loadSettings, loadSettingsRecoveringClobberedOAuthSelection } =
156+
const { loadSettingsRecoveringClobberedOAuthSelection } =
157157
await import("../../src/config/settings.js");
158158
const cwd = await mkdtemp(join(tmpdir(), "ic-unit-config-recovery-"));
159159
try {
160160
const clobberedPath = join(cwd, "clobbered.json");
161-
await writeFile(clobberedPath, JSON.stringify({ provider: "openai", model: "gpt-5" }));
162-
expect(await loadSettingsRecoveringClobberedOAuthSelection(clobberedPath, {})).toEqual({
163-
providers: {},
164-
});
165-
expect(await loadSettings(clobberedPath)).toEqual({ providers: {} });
161+
const clobbered = JSON.stringify({ provider: "openai", model: "gpt-5" });
162+
await writeFile(clobberedPath, clobbered);
163+
await expect(
164+
loadSettingsRecoveringClobberedOAuthSelection(clobberedPath, {}, { persist: true }),
165+
).rejects.toThrow(/Invalid settings schema/);
166+
expect(await readFile(clobberedPath, "utf8")).toBe(clobbered);
166167

167168
const malformedPath = join(cwd, "malformed.json");
168169
await writeFile(
169170
malformedPath,
170171
JSON.stringify({ provider: "openai", model: "gpt-5", apiKey: "not-recoverable" }),
171172
);
172-
await expect(loadSettingsRecoveringClobberedOAuthSelection(malformedPath, {})).rejects.toThrow(
173-
/Invalid settings schema/,
174-
);
173+
await expect(
174+
loadSettingsRecoveringClobberedOAuthSelection(malformedPath, {}, { persist: true }),
175+
).rejects.toThrow(/Invalid settings schema/);
175176
} finally {
176177
await rm(cwd, { recursive: true, force: true });
177178
}

0 commit comments

Comments
 (0)