diff --git a/sdk/typescript/src/api.ts b/sdk/typescript/src/api.ts index 888431f4..4d0f01bf 100644 --- a/sdk/typescript/src/api.ts +++ b/sdk/typescript/src/api.ts @@ -1428,9 +1428,15 @@ async function prepareDeepScanConfig( const value = options[name]; if (value !== undefined) overrides[key] = value; } - if (existing === undefined && Object.keys(overrides).length === 0) return; const destination = join(codexHome, "codex-security", "config.toml"); - if (destination === source && Object.keys(overrides).length === 0) return; + const hasOverrides = Object.keys(overrides).length > 0; + if (existing === undefined && !hasOverrides) { + if (destination !== source) { + await rm(destination, { force: true }); + } + return; + } + if (destination === source && !hasOverrides) return; await mkdir(dirname(destination), { recursive: true, mode: 0o700 }); await writeFile( destination, diff --git a/sdk/typescript/tests-ts/api.test.ts b/sdk/typescript/tests-ts/api.test.ts index ccebb70d..1e0c947c 100644 --- a/sdk/typescript/tests-ts/api.test.ts +++ b/sdk/typescript/tests-ts/api.test.ts @@ -1865,6 +1865,131 @@ describe("CodexSecurity orchestration", () => { await client.close(); }); + test.each([ + "removed", + "without deep settings", + ...(process.platform === "win32" + ? [] + : [ + "without deep settings and a dangling runtime link", + "without deep settings and a cyclic runtime link", + ]), + ])( + "clears stale runtime deep-scan configuration when ambient settings are %s", + async (ambientState) => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const ambientHome = join(root, "ambient-home"); + const runtimeHome = join(root, "runtime-home"); + const scanDir = join(root, "scan"); + const ambientConfig = join(ambientHome, "codex-security", "config.toml"); + const runtimeConfig = join(runtimeHome, "codex-security", "config.toml"); + const escapedConfig = join(root, "escaped-config.toml"); + await mkdir(repository); + await mkdir(join(ambientHome, "codex-security"), { recursive: true }); + await mkdir(runtimeHome); + await mkdir(scanDir, { mode: 0o700 }); + await writeFile( + ambientConfig, + "[deep_scan]\nworkers = 5\n[other]\nenabled = true\n", + ); + + const client = new TestClient( + {}, + { + environment: { CODEX_HOME: ambientHome }, + prepareRuntime: async () => preparedRuntime(runtimeHome), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + createCodex: () => ({ + startThread: () => ({ + id: null, + async runStreamed() { + throw new Error("deep scan settings captured"); + }, + }), + }), + }, + ); + + await expect( + client.run(repository, { mode: "deep", workers: 2 }), + ).rejects.toThrow("deep scan settings captured"); + expect(await readFile(runtimeConfig, "utf8")).toContain("workers = 2"); + + if (ambientState === "removed") { + await rm(ambientConfig); + } else { + await writeFile(ambientConfig, "[other]\nenabled = true\n"); + } + if ( + ambientState === "without deep settings and a dangling runtime link" + ) { + await rm(runtimeConfig); + await symlink(escapedConfig, runtimeConfig); + } else if ( + ambientState === "without deep settings and a cyclic runtime link" + ) { + await rm(runtimeConfig); + await symlink(runtimeConfig, runtimeConfig); + } + + await expect(client.run(repository, { mode: "deep" })).rejects.toThrow( + "deep scan settings captured", + ); + await expect(fsPromises.lstat(runtimeConfig)).rejects.toMatchObject({ + code: "ENOENT", + }); + + await writeFile(ambientConfig, "[deep_scan]\nworkers = 7\n"); + await expect(client.run(repository, { mode: "deep" })).rejects.toThrow( + "deep scan settings captured", + ); + expect(await readFile(runtimeConfig, "utf8")).toContain("workers = 7"); + expect(existsSync(escapedConfig)).toBe(false); + await client.close(); + }, + ); + + test("preserves ambient configuration when the deep-scan runtime uses the same home", async () => { + const root = await temporaryDirectory(); + const repository = join(root, "repository"); + const codexHome = join(root, "codex-home"); + const scanDir = join(root, "scan"); + const configPath = join(codexHome, "codex-security", "config.toml"); + const originalConfiguration = "[other]\nenabled = true\n"; + await mkdir(repository); + await mkdir(join(codexHome, "codex-security"), { recursive: true }); + await writeFile(configPath, originalConfiguration); + await mkdir(scanDir, { mode: 0o700 }); + + const client = new TestClient( + {}, + { + environment: { CODEX_HOME: codexHome }, + prepareRuntime: async () => preparedRuntime(codexHome), + resolvePluginPython: async () => "/managed/python", + prepareOutputDir: async () => scanDir, + repositoryRevision: async () => "deadbeef", + createCodex: () => ({ + startThread: () => ({ + id: null, + async runStreamed() { + throw new Error("deep scan settings captured"); + }, + }), + }), + }, + ); + + await expect(client.run(repository, { mode: "deep" })).rejects.toThrow( + "deep scan settings captured", + ); + expect(await readFile(configPath, "utf8")).toBe(originalConfiguration); + await client.close(); + }); + test("rejects a scan registration without an authoritative target contract", async () => { const root = await temporaryDirectory(); const repository = join(root, "repository");