Skip to content

Commit 4efa5cb

Browse files
committed
fix: harden full catalog installer safeguards
1 parent 5a650a2 commit 4efa5cb

2 files changed

Lines changed: 143 additions & 15 deletions

File tree

scripts/install-opencode-codex-auth.js

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,30 @@ function formatJson(obj) {
105105
return `${JSON.stringify(obj, null, 2)}\n`;
106106
}
107107

108+
function mergeFullTemplate(modernTemplate, legacyTemplate) {
109+
const modernModels = modernTemplate.provider?.openai?.models ?? {};
110+
const legacyModels = legacyTemplate.provider?.openai?.models ?? {};
111+
const overlappingKeys = Object.keys(modernModels).filter((key) => Object.hasOwn(legacyModels, key));
112+
113+
if (overlappingKeys.length > 0) {
114+
throw new Error(`Full config template collision for model keys: ${overlappingKeys.join(", ")}`);
115+
}
116+
117+
return {
118+
...modernTemplate,
119+
provider: {
120+
...modernTemplate.provider,
121+
openai: {
122+
...modernTemplate.provider.openai,
123+
models: {
124+
...modernModels,
125+
...legacyModels,
126+
},
127+
},
128+
},
129+
};
130+
}
131+
108132
async function readJson(filePath) {
109133
const content = await readFile(filePath, "utf-8");
110134
return JSON.parse(content);
@@ -161,19 +185,29 @@ async function loadTemplate(mode, paths) {
161185
readJson(paths.legacyTemplatePath),
162186
]);
163187

164-
return {
165-
...modernTemplate,
166-
provider: {
167-
...modernTemplate.provider,
168-
openai: {
169-
...modernTemplate.provider.openai,
170-
models: {
171-
...modernTemplate.provider.openai.models,
172-
...legacyTemplate.provider.openai.models,
173-
},
174-
},
175-
},
176-
};
188+
return mergeFullTemplate(modernTemplate, legacyTemplate);
189+
}
190+
191+
async function copyFileWithWindowsRetry(sourcePath, destinationPath) {
192+
let lastError = null;
193+
194+
for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
195+
try {
196+
await copyFile(sourcePath, destinationPath);
197+
return;
198+
} catch (error) {
199+
if (isWindowsLockError(error)) {
200+
lastError = error;
201+
await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt);
202+
continue;
203+
}
204+
throw error;
205+
}
206+
}
207+
208+
if (lastError) {
209+
throw lastError;
210+
}
177211
}
178212

179213
async function backupConfig(sourcePath, dryRun) {
@@ -184,7 +218,7 @@ async function backupConfig(sourcePath, dryRun) {
184218
.replace("Z", "");
185219
const backupPath = `${sourcePath}.bak-${timestamp}`;
186220
if (!dryRun) {
187-
await copyFile(sourcePath, backupPath);
221+
await copyFileWithWindowsRetry(sourcePath, backupPath);
188222
}
189223
return backupPath;
190224
}
@@ -331,6 +365,9 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) {
331365

332366
export const __test = {
333367
buildPaths,
368+
backupConfig,
369+
copyFileWithWindowsRetry,
370+
mergeFullTemplate,
334371
parseCliArgs,
335372
writeFileAtomic,
336373
renameWithWindowsRetry,

test/install-opencode-codex-auth.test.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promis
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55

6+
type OpenAiTemplate = {
7+
provider: {
8+
openai: {
9+
models: Record<string, unknown>;
10+
};
11+
};
12+
};
13+
614
async function createTempHome() {
715
return mkdtemp(join(tmpdir(), "oc-chatgpt-install-"));
816
}
@@ -12,6 +20,7 @@ describe("install-opencode-codex-auth script", () => {
1220

1321
afterEach(async () => {
1422
vi.restoreAllMocks();
23+
vi.doUnmock("node:fs/promises");
1524
if (tempHome) {
1625
await rm(tempHome, { recursive: true, force: true });
1726
tempHome = null;
@@ -84,7 +93,15 @@ describe("install-opencode-codex-auth script", () => {
8493
expect(saved.customSetting).toBe(true);
8594
expect(saved.plugin).toEqual(["existing-plugin", "oc-chatgpt-multi-auth"]);
8695
expect(saved.provider.anthropic).toEqual({ baseURL: "https://example.invalid" });
87-
expect(Object.keys(saved.provider.openai.models)).toHaveLength(43);
96+
const modernTemplate = JSON.parse(
97+
await readFile(new URL("../config/opencode-modern.json", import.meta.url), "utf-8"),
98+
) as OpenAiTemplate;
99+
const legacyTemplate = JSON.parse(
100+
await readFile(new URL("../config/opencode-legacy.json", import.meta.url), "utf-8"),
101+
) as OpenAiTemplate;
102+
const expectedCount = Object.keys(modernTemplate.provider.openai.models).length
103+
+ Object.keys(legacyTemplate.provider.openai.models).length;
104+
expect(Object.keys(saved.provider.openai.models)).toHaveLength(expectedCount);
88105
expect(saved.provider.openai.models["gpt-5.4"]).toBeDefined();
89106
expect(saved.provider.openai.models["gpt-5.4-high"]).toBeDefined();
90107
const configEntries = await readdir(configDir);
@@ -148,4 +165,78 @@ describe("install-opencode-codex-auth script", () => {
148165
expect(cachePackage.dependencies["oc-chatgpt-multi-auth"]).toBeUndefined();
149166
expect(cachePackage.dependencies.other).toBe("^1.0.0");
150167
});
168+
169+
it("rejects full-mode merges when modern and legacy templates overlap", async () => {
170+
vi.resetModules();
171+
const { __test } = await import("../scripts/install-opencode-codex-auth.js");
172+
173+
const modernTemplate = {
174+
provider: {
175+
openai: {
176+
models: {
177+
"gpt-5.4": { name: "base" },
178+
},
179+
},
180+
},
181+
};
182+
const legacyTemplate = {
183+
provider: {
184+
openai: {
185+
models: {
186+
"gpt-5.4": { name: "preset" },
187+
},
188+
},
189+
},
190+
};
191+
192+
expect(() => __test.mergeFullTemplate(modernTemplate, legacyTemplate)).toThrow(
193+
/Full config template collision/,
194+
);
195+
});
196+
197+
it("retries backup copies after transient Windows lock errors", async () => {
198+
vi.resetModules();
199+
tempHome = await createTempHome();
200+
const sourcePath = join(tempHome, "opencode.json");
201+
const copyFileMock = vi.fn()
202+
.mockRejectedValueOnce(Object.assign(new Error("busy"), { code: "EBUSY" }))
203+
.mockResolvedValue(undefined);
204+
205+
vi.doMock("node:fs/promises", async () => {
206+
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
207+
return {
208+
...actual,
209+
copyFile: copyFileMock,
210+
};
211+
});
212+
213+
const { __test } = await import("../scripts/install-opencode-codex-auth.js");
214+
const backupPath = await __test.backupConfig(sourcePath, false);
215+
216+
expect(copyFileMock).toHaveBeenCalledTimes(2);
217+
expect(copyFileMock).toHaveBeenNthCalledWith(1, sourcePath, backupPath);
218+
expect(copyFileMock).toHaveBeenNthCalledWith(2, sourcePath, backupPath);
219+
expect(backupPath).toMatch(/opencode\.json\.bak-/);
220+
});
221+
222+
it("retries atomic rename after transient Windows lock errors", async () => {
223+
vi.resetModules();
224+
const renameMock = vi.fn()
225+
.mockRejectedValueOnce(Object.assign(new Error("locked"), { code: "EPERM" }))
226+
.mockResolvedValue(undefined);
227+
228+
vi.doMock("node:fs/promises", async () => {
229+
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
230+
return {
231+
...actual,
232+
rename: renameMock,
233+
};
234+
});
235+
236+
const { __test } = await import("../scripts/install-opencode-codex-auth.js");
237+
await expect(__test.renameWithWindowsRetry("from.tmp", "to.json")).resolves.toBeUndefined();
238+
expect(renameMock).toHaveBeenCalledTimes(2);
239+
expect(renameMock).toHaveBeenNthCalledWith(1, "from.tmp", "to.json");
240+
expect(renameMock).toHaveBeenNthCalledWith(2, "from.tmp", "to.json");
241+
});
151242
});

0 commit comments

Comments
 (0)