diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index cd1d449b..b1235282 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -164,6 +164,16 @@ when the dedicated home does not already contain stored credentials. Logging out prevents later scans from automatically reimporting that ambient sign-in until you explicitly log in again. +Scan runtime preparation uses a process-owned lock on this home. Pausing a +process does not release its lock; exiting or crashing does. The internal +`.codex-security-scan.sqlite3` file stays in the home between operations. Do not +remove it while any operation is running. + +Older releases recorded only a PID. If an old `.codex-security-scan.lock` +directory names a PID that has been reused, automatic recovery cannot safely +distinguish it from a live owner. Stop all operations using that credential home +before removing the old lock directory manually. + An environment API key takes precedence over a stored sign-in by default. When both a stored ChatGPT sign-in and an environment API key are available, an interactive scan asks which credential to use. JSON output, dry runs, CI, and diff --git a/sdk/typescript/scripts/fixtures/credential-lock.mjs b/sdk/typescript/scripts/fixtures/credential-lock.mjs new file mode 100644 index 00000000..6fb6cb2a --- /dev/null +++ b/sdk/typescript/scripts/fixtures/credential-lock.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { readFile, utimes, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createInterface } from "node:readline"; + +const [runtimeUrl, directory, mode] = process.argv.slice(2); +const { + acquireCodexSecurityCredentialHomeLock: acquire, + prepareCodexSecurityCredentialHome: prepare, +} = await import(runtimeUrl); + +if (mode === "hold") { + const release = await acquire(directory); + process.stdout.write("locked\n"); + await once(process.stdin, "data"); + await new Promise((resolve) => process.stdout.write("blocked\n", resolve)); + // Stall the actual owner, including any JavaScript heartbeat it might run. + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); + await release(); +} else { + const home = await prepare({ CODEX_SECURITY_STATE_DIR: directory }); + const lock = join(home, ".codex-security-scan.lock"); + const ownerPath = join(lock, "owner.json"); + const holder = spawn( + process.execPath, + [process.argv[1], runtimeUrl, home, "hold"], + { + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + timeout: 20_000, + killSignal: "SIGKILL", + }, + ); + const exited = once(holder, "exit"); + let stderr = ""; + holder.stderr.setEncoding("utf8").on("data", (chunk) => { + stderr += chunk; + }); + const lines = createInterface({ input: holder.stdout }); + const output = lines[Symbol.asyncIterator](); + async function expectOutput(expected) { + const line = await Promise.race([ + output.next(), + exited.then(() => { + throw new Error(`Credential-lock holder exited early: ${stderr}`); + }), + ]); + assert.equal(line.value, expected); + } + + let release; + try { + await expectOutput("locked"); + holder.stdin.write("block\n"); + await expectOutput("blocked"); + const owner = JSON.parse(await readFile(ownerPath, "utf8")); + const stale = new Date(Date.now() - 60_000); + await utimes(lock, stale, stale); + + const controller = new AbortController(); + // Wait longer than the former five-second stale-heartbeat grace period. + const timeout = setTimeout(() => controller.abort(), 6_000); + try { + await assert.rejects( + async () => { + release = await acquire(home, controller.signal); + }, + { name: "AbortError" }, + ); + } finally { + clearTimeout(timeout); + } + assert.deepEqual(JSON.parse(await readFile(ownerPath, "utf8")), owner); + + holder.kill("SIGKILL"); + await exited; + // Reuse a known live PID without relying on the OS to recycle one in a test. + await writeFile(ownerPath, JSON.stringify({ ...owner, pid: process.pid })); + const recovery = new AbortController(); + const recoveryTimeout = setTimeout(() => recovery.abort(), 5_000); + try { + release = await acquire(home, recovery.signal); + } finally { + clearTimeout(recoveryTimeout); + } + await release(); + release = undefined; + console.log( + "Paused owner protected; crashed owner with reused PID recovered.", + ); + } finally { + holder.kill("SIGKILL"); + await exited; + lines.close(); + await release?.(); + } +} diff --git a/sdk/typescript/scripts/smoke-package.mjs b/sdk/typescript/scripts/smoke-package.mjs index bafd80ff..7ba2326b 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -518,10 +518,20 @@ try { /lin_api_|security@example\.test/u, ); + run( + process.execPath, + [ + join(packageRoot, "scripts", "fixtures", "credential-lock.mjs"), + pathToFileURL(join(installedRoot, "dist", "runtime.js")).href, + join(consumer, "credential-lock-state"), + ], + { cwd: consumer }, + ); + await smokeNestedDeepScanWorker(installedRoot, consumer); console.log( - `Validated installed ${packageManifest.name}@${packageManifest.version}: public import, NodeNext types, CLI, ${expectedPluginFiles.length} bundled plugin files, bundled Codex version, and a nested worker without global codex.`, + `Validated installed ${packageManifest.name}@${packageManifest.version}: public import, NodeNext types, CLI, credential locking, ${expectedPluginFiles.length} bundled plugin files, bundled Codex version, and a nested worker without global codex.`, ); } finally { await rm(consumer, { diff --git a/sdk/typescript/src/runtime.ts b/sdk/typescript/src/runtime.ts index 2ce27a75..772c1aa1 100644 --- a/sdk/typescript/src/runtime.ts +++ b/sdk/typescript/src/runtime.ts @@ -24,7 +24,6 @@ import { rm, rmdir, stat, - utimes, writeFile, } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; @@ -74,8 +73,8 @@ const MAX_ZIP_ENTRY_SIZE = 128 * 1024 * 1024; const MAX_ZIP_EXPANDED_SIZE = 512 * 1024 * 1024; const MODEL_UNSAFE_PATH = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u; const CREDENTIAL_LOCK_NAME = ".codex-security-scan.lock"; +const CREDENTIAL_LOCK_DATABASE = ".codex-security-scan.sqlite3"; const CREDENTIAL_LOGOUT_MARKER = ".codex-security-logged-out"; -const CREDENTIAL_LOCK_HEARTBEAT_MILLISECONDS = 5_000; const CREDENTIAL_LOCK_POLL_MILLISECONDS = 25; const INCOMPLETE_CREDENTIAL_LOCK_MILLISECONDS = 30_000; const MAX_PROCESS_ID = 2_147_483_647; @@ -1044,6 +1043,7 @@ export async function acquireCodexSecurityCredentialHomeLock( secureWindowsHome?: (path: string) => Promise; } = {}, ): Promise<() => Promise> { + throwIfSignalAborted(signal); const homeMetadata = await requireSecureCredentialHome( codexHome, securityOptions, @@ -1053,84 +1053,140 @@ export async function acquireCodexSecurityCredentialHomeLock( const lock = join(codexHome, CREDENTIAL_LOCK_NAME); const ownerPath = join(lock, "owner.json"); const token = randomUUID(); - - while (true) { - throwIfSignalAborted(signal); - await requireSecureCredentialHome(codexHome, { - ...securityOptions, - expectedDevice, - expectedInode, - validateWindowsAcl: false, - }); - const existingLock = await lstat(lock).catch((error: unknown) => { - if (nodeErrorCode(error) === "ENOENT") return null; - throw error; - }); - if (existingLock !== null) { - if (await recoverStaleCredentialHomeLock(lock, signal)) continue; - await delay(CREDENTIAL_LOCK_POLL_MILLISECONDS, undefined, { signal }); - continue; - } - await requireSecureCredentialHome(codexHome, { - ...securityOptions, - expectedDevice, - expectedInode, - }); - try { - await mkdir(lock, { mode: 0o700 }); - } catch (error) { + const databasePath = join(codexHome, CREDENTIAL_LOCK_DATABASE); + // Keep this file across releases so every contender locks the same inode. + await writeFile(databasePath, "", { flag: "wx", mode: 0o600 }).catch( + (error: unknown) => { if (nodeErrorCode(error) !== "EEXIST") throw error; - if (await recoverStaleCredentialHomeLock(lock, signal)) continue; - await delay(CREDENTIAL_LOCK_POLL_MILLISECONDS, undefined, { signal }); - continue; - } - - try { - await writeFile( - ownerPath, - `${JSON.stringify({ pid: process.pid, token })}\n`, - { encoding: "utf8", flag: "wx", mode: 0o600 }, - ); - } catch (error) { - await rm(lock, { recursive: true, force: true }).catch(() => undefined); - throw error; - } + }, + ); + const databaseMetadata = await lstat(databasePath); + if (!databaseMetadata.isFile() || databaseMetadata.nlink !== 1) { + throw new OutputDirectoryError( + `Codex Security credential-home lock must be a regular file, not a symlink or hard link: ${databasePath}`, + ); + } + requirePrivateCredentialFile(databaseMetadata, databasePath); + const require = createRequire(import.meta.url); + // Both supported runtimes bundle SQLite. Keep the transaction in the process + // doing the protected work, so pausing it cannot expire its lock. + const Database = process.versions["bun"] + ? (require("bun:sqlite") as { Database: CredentialLockDatabaseConstructor }) + .Database + : ( + require("node:sqlite") as { + DatabaseSync: CredentialLockDatabaseConstructor; + } + ).DatabaseSync; + const database = new Database(databasePath); + let databaseLocked = false; - const heartbeat = setInterval(async () => { - try { - const now = new Date(); - await utimes(lock, now, now); - } catch {} - }, CREDENTIAL_LOCK_HEARTBEAT_MILLISECONDS); - heartbeat.unref(); - - let released = false; - return async () => { - if (released) return; - clearInterval(heartbeat); + try { + database.exec("PRAGMA busy_timeout = 0"); + while (true) { + throwIfSignalAborted(signal); await requireSecureCredentialHome(codexHome, { ...securityOptions, expectedDevice, expectedInode, + validateWindowsAcl: false, }); - const owner = JSON.parse(await readFile(ownerPath, "utf8")) as { - token?: unknown; - }; - if (owner.token !== token) { - throw new PluginBootstrapError( - "The Codex Security credential-home lock is no longer owned by this scan.", + if (!databaseLocked) { + try { + database.exec("BEGIN EXCLUSIVE"); + } catch (error) { + if ( + !isRecord(error) || + (error["errcode"] !== 5 && error["code"] !== "SQLITE_BUSY") + ) { + throw error; + } + await delay(CREDENTIAL_LOCK_POLL_MILLISECONDS, undefined, { signal }); + continue; + } + const currentDatabase = await lstat(databasePath); + if ( + currentDatabase.dev !== databaseMetadata.dev || + currentDatabase.ino !== databaseMetadata.ino + ) { + throw new OutputDirectoryError( + `Codex Security credential-home lock changed while acquiring it: ${databasePath}`, + ); + } + databaseLocked = true; + } + const existingLock = await lstat(lock).catch((error: unknown) => { + if (nodeErrorCode(error) === "ENOENT") return null; + throw error; + }); + if (existingLock !== null) { + if (await recoverStaleCredentialHomeLock(lock)) continue; + await delay(CREDENTIAL_LOCK_POLL_MILLISECONDS, undefined, { signal }); + continue; + } + await requireSecureCredentialHome(codexHome, { + ...securityOptions, + expectedDevice, + expectedInode, + }); + try { + await mkdir(lock, { mode: 0o700 }); + } catch (error) { + if (nodeErrorCode(error) !== "EEXIST") throw error; + if (await recoverStaleCredentialHomeLock(lock)) continue; + await delay(CREDENTIAL_LOCK_POLL_MILLISECONDS, undefined, { signal }); + continue; + } + + try { + await writeFile( + ownerPath, + `${JSON.stringify({ pid: process.pid, token, protocol: "sqlite" })}\n`, + { encoding: "utf8", flag: "wx", mode: 0o600 }, ); + } catch (error) { + await rm(lock, { recursive: true, force: true }).catch(() => undefined); + throw error; } - await rm(lock, { recursive: true, force: true }); - released = true; - }; + + let released = false; + return async () => { + if (released) return; + released = true; + try { + await requireSecureCredentialHome(codexHome, { + ...securityOptions, + expectedDevice, + expectedInode, + }); + const owner = JSON.parse(await readFile(ownerPath, "utf8")) as { + token?: unknown; + }; + if (owner.token !== token) { + throw new PluginBootstrapError( + "The Codex Security credential-home lock is no longer owned by this scan.", + ); + } + await rm(lock, { recursive: true, force: true }); + } finally { + database.close(); + } + }; + } + } catch (error) { + database.close(); + throw error; } } -async function recoverStaleCredentialHomeLock( - lock: string, - signal?: AbortSignal, -): Promise { +interface CredentialLockDatabaseConstructor { + new (path: string): { + exec(sql: string): unknown; + close(): void; + }; +} + +async function recoverStaleCredentialHomeLock(lock: string): Promise { const metadata = await lstat(lock).catch((error: unknown) => { if (nodeErrorCode(error) === "ENOENT") return null; throw error; @@ -1151,49 +1207,31 @@ async function recoverStaleCredentialHomeLock( } } - // Only positive signed-32-bit PIDs identify an owner. Other values can name - // process groups or fail argument validation, so use the stale-age check. - const ownerPid = isRecord(owner) ? owner["pid"] : undefined; - let ownerExited = false; - let ownerIsAlive = false; - if ( - typeof ownerPid === "number" && - Number.isInteger(ownerPid) && - ownerPid > 0 && - ownerPid <= MAX_PROCESS_ID - ) { - try { - process.kill(ownerPid, 0); - ownerIsAlive = true; - } catch (error) { - if (nodeErrorCode(error) === "ESRCH") ownerExited = true; - else if (nodeErrorCode(error) === "EPERM") ownerIsAlive = true; - else throw error; - } - } - if ( - !ownerExited && - Date.now() - metadata.mtimeMs < INCOMPLETE_CREDENTIAL_LOCK_MILLISECONDS - ) { - return false; - } - - if (ownerIsAlive) { - await delay(CREDENTIAL_LOCK_HEARTBEAT_MILLISECONDS, undefined, { signal }); - const refreshedMetadata = await lstat(lock).catch((error: unknown) => { - if (nodeErrorCode(error) === "ENOENT") return null; - throw error; - }); - if (refreshedMetadata === null) return true; + // We hold the SQLite transaction, so a record from that protocol is orphaned. + // Older clients only record a PID: a live one must be respected at any age. + if (!isRecord(owner) || owner["protocol"] !== "sqlite") { + // Only positive signed-32-bit PIDs identify an owner. Other values can name + // process groups or fail argument validation, so use the stale-age check. + const ownerPid = isRecord(owner) ? owner["pid"] : undefined; if ( - !refreshedMetadata.isDirectory() || - refreshedMetadata.isSymbolicLink() + typeof ownerPid === "number" && + Number.isInteger(ownerPid) && + ownerPid > 0 && + ownerPid <= MAX_PROCESS_ID ) { - throw new OutputDirectoryError( - `Codex Security credential-home lock is not a directory: ${lock}`, - ); + try { + process.kill(ownerPid, 0); + return false; + } catch (error) { + if (nodeErrorCode(error) === "EPERM") return false; + if (nodeErrorCode(error) !== "ESRCH") throw error; + } + } else if ( + Date.now() - metadata.mtimeMs < + INCOMPLETE_CREDENTIAL_LOCK_MILLISECONDS + ) { + return false; } - if (refreshedMetadata.mtimeMs > metadata.mtimeMs) return false; } const quarantine = `${lock}.stale-${randomUUID()}`; diff --git a/sdk/typescript/tests-ts/runtime.test.ts b/sdk/typescript/tests-ts/runtime.test.ts index 179db5b5..f1b00577 100644 --- a/sdk/typescript/tests-ts/runtime.test.ts +++ b/sdk/typescript/tests-ts/runtime.test.ts @@ -3,6 +3,7 @@ import { existsSync, renameSync, symlinkSync } from "node:fs"; import { chmod, copyFile, + link, lstat, mkdir, mkdtemp, @@ -29,7 +30,7 @@ import { sep, } from "node:path"; import { promisify } from "node:util"; -import * as timersPromises from "node:timers/promises"; +import { fileURLToPath } from "node:url"; import { brotliDecompressSync } from "node:zlib"; import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { strToU8, zipSync } from "fflate"; @@ -133,69 +134,6 @@ async function acquireCredentialHomeLockWithTimeout( } } -function abortCredentialLockWaitWhenOwnerIsInspected( - controller: AbortController, -): ReturnType { - const killProcess = process.kill.bind(process); - return spyOn(process, "kill").mockImplementation((( - pid: number, - signal: number, - ) => { - const result = killProcess(pid, signal); - controller.abort(new DOMException("canceled", "AbortError")); - return result; - }) as typeof process.kill); -} - -function captureCredentialLockHeartbeatTimer() { - const realSetInterval = globalThis.setInterval.bind(globalThis); - const realClearInterval = globalThis.clearInterval.bind(globalThis); - const unref = mock(() => undefined); - const interval = { unref } as unknown as NodeJS.Timeout; - let callback: (() => Promise) | undefined; - let milliseconds: number | undefined; - let cleared = false; - const setIntervalSpy = spyOn(globalThis, "setInterval").mockImplementation((( - candidate: (...args: unknown[]) => void, - delay?: number, - ...args: unknown[] - ) => { - if (delay === 5_000 && callback === undefined) { - callback = candidate as () => Promise; - milliseconds = delay; - return interval; - } - return realSetInterval(candidate, delay, ...args); - }) as typeof setInterval); - const clearIntervalSpy = spyOn( - globalThis, - "clearInterval", - ).mockImplementation(((timer: NodeJS.Timeout) => { - if (timer === interval) { - cleared = true; - return; - } - realClearInterval(timer); - }) as typeof clearInterval); - - return { - get callback() { - return callback; - }, - get cleared() { - return cleared; - }, - get milliseconds() { - return milliseconds; - }, - restore() { - clearIntervalSpy.mockRestore(); - setIntervalSpy.mockRestore(); - }, - unref, - }; -} - async function plugin(root: string, version = "1.2.3"): Promise { const path = join(root, "plugin"); await mkdir(join(path, ".codex-plugin"), { recursive: true }); @@ -2290,6 +2228,9 @@ describe("runtime directories and plugin Python boundary", () => { await mkdir(home, { recursive: true, mode: 0o700 }); await chmod(home, 0o700); await expect(release()).rejects.toThrow("credential home was replaced"); + const releaseRecovered = + await acquireCredentialHomeLockWithTimeout(stolen); + await releaseRecovered(); }, ); @@ -2363,6 +2304,8 @@ describe("runtime directories and plugin Python boundary", () => { CODEX_SECURITY_STATE_DIR: join(root, "state"), }); const releaseFirst = await acquireCodexSecurityCredentialHomeLock(home); + const database = join(home, ".codex-security-scan.sqlite3"); + const original = await stat(database); let secondAcquired = false; const second = acquireCodexSecurityCredentialHomeLock(home).then( (release) => { @@ -2378,6 +2321,8 @@ describe("runtime directories and plugin Python boundary", () => { expect(secondAcquired).toBe(true); await releaseSecond(); expect(existsSync(join(home, ".codex-security-scan.lock"))).toBe(false); + // Removing this file would let waiters lock different inodes. + expect((await stat(database)).ino).toBe(original.ino); }); test("keeps a fresh live credential-home lock and cancels the waiter", async () => { @@ -2387,8 +2332,7 @@ describe("runtime directories and plugin Python boundary", () => { }); const release = await acquireCodexSecurityCredentialHomeLock(home); const controller = new AbortController(); - const inspectOwner = - abortCredentialLockWaitWhenOwnerIsInspected(controller); + const timeout = setTimeout(() => controller.abort(), 100); try { const waiting = acquireCodexSecurityCredentialHomeLock( @@ -2396,54 +2340,44 @@ describe("runtime directories and plugin Python boundary", () => { controller.signal, ); await expect(waiting).rejects.toMatchObject({ name: "AbortError" }); - expect(inspectOwner).toHaveBeenCalledWith(process.pid, 0); expect(existsSync(join(home, ".codex-security-scan.lock"))).toBe(true); } finally { - inspectOwner.mockRestore(); + clearTimeout(timeout); await release(); } expect(existsSync(join(home, ".codex-security-scan.lock"))).toBe(false); + const releaseAgain = await acquireCredentialHomeLockWithTimeout(home); + await releaseAgain(); }); - test("heartbeats a held credential-home lock across the stale horizon", async () => { + test("releases the native credential lock when legacy-lock inspection fails", async () => { const root = await temporaryDirectory(); const home = await prepareCodexSecurityCredentialHome({ CODEX_SECURITY_STATE_DIR: join(root, "state"), }); const lock = join(home, ".codex-security-scan.lock"); - const heartbeatTimer = captureCredentialLockHeartbeatTimer(); - let release: (() => Promise) | undefined; - let inspectOwner: ReturnType | undefined; - - try { - release = await acquireCodexSecurityCredentialHomeLock(home); - expect(heartbeatTimer.milliseconds).toBe(5_000); - expect(heartbeatTimer.unref).toHaveBeenCalledTimes(1); - expect(heartbeatTimer.callback).toBeDefined(); - - const stale = new Date(Date.now() - 60_000); - await utimes(lock, stale, stale); - await heartbeatTimer.callback!(); - expect(Date.now() - (await stat(lock)).mtimeMs).toBeLessThan(30_000); + await writeFile(lock, "not a directory", { mode: 0o600 }); + await expect(acquireCodexSecurityCredentialHomeLock(home)).rejects.toThrow( + "not a directory", + ); + await rm(lock); + const release = await acquireCredentialHomeLockWithTimeout(home); + await release(); + }); - const controller = new AbortController(); - inspectOwner = abortCredentialLockWaitWhenOwnerIsInspected(controller); - const waiting = acquireCodexSecurityCredentialHomeLock( - home, - controller.signal, - ); - await expect(waiting).rejects.toMatchObject({ name: "AbortError" }); - expect(existsSync(lock)).toBe(true); - } finally { - inspectOwner?.mockRestore(); - try { - if (release !== undefined) await release(); - } finally { - heartbeatTimer.restore(); - } - } - expect(heartbeatTimer.cleared).toBe(true); - expect(existsSync(lock)).toBe(false); + test("protects a stalled credential-lock owner and recovers after a crash with a reused PID", async () => { + const root = await temporaryDirectory(); + await promisify(execFile)( + process.execPath, + [ + fileURLToPath( + new URL("../scripts/fixtures/credential-lock.mjs", import.meta.url), + ), + new URL("../src/runtime.ts", import.meta.url).href, + join(root, "state"), + ], + { timeout: 25_000, windowsHide: true }, + ); }); test("does not rewrite Windows credential ACLs while polling a held lock", async () => { @@ -2508,117 +2442,87 @@ describe("runtime directories and plugin Python boundary", () => { expect(existsSync(lock)).toBe(false); }); - test("recovers a stale credential-home lock naming a live process", async () => { + test("preserves a legacy live owner beyond the stale-heartbeat grace period", async () => { const root = await temporaryDirectory(); const home = await prepareCodexSecurityCredentialHome({ CODEX_SECURITY_STATE_DIR: join(root, "state"), }); const stale = new Date(Date.now() - 10 * 60_000); const lock = await plantCredentialHomeLock(home, process.pid, stale); + const owner = await readFile(join(lock, "owner.json"), "utf8"); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 6_000); let release: (() => Promise) | undefined; try { - release = await acquireCredentialHomeLockWithTimeout(home); - expect(existsSync(lock)).toBe(true); + await expect( + (async () => { + release = await acquireCodexSecurityCredentialHomeLock( + home, + controller.signal, + ); + })(), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(await readFile(join(lock, "owner.json"), "utf8")).toBe(owner); } finally { + clearTimeout(timeout); if (release !== undefined) await release(); } - expect(existsSync(lock)).toBe(false); }); - test("recovers a stale credential-home lock when PID inspection is denied", async () => { + test("preserves a legacy owner when PID inspection is denied", async () => { const root = await temporaryDirectory(); const home = await prepareCodexSecurityCredentialHome({ CODEX_SECURITY_STATE_DIR: join(root, "state"), }); const stale = new Date(Date.now() - 10 * 60_000); const lock = await plantCredentialHomeLock(home, process.pid, stale); + const controller = new AbortController(); const inspectOwner = spyOn(process, "kill").mockImplementation((() => { + controller.abort(); const error = new Error( "operation not permitted", ) as NodeJS.ErrnoException; error.code = "EPERM"; throw error; }) as typeof process.kill); - let release: (() => Promise) | undefined; try { - release = await acquireCredentialHomeLockWithTimeout(home); + await expect( + acquireCodexSecurityCredentialHomeLock(home, controller.signal), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(inspectOwner).toHaveBeenCalledWith(process.pid, 0); expect(existsSync(lock)).toBe(true); } finally { inspectOwner.mockRestore(); - if (release !== undefined) await release(); } - expect(existsSync(lock)).toBe(false); }); - test("abandons stale recovery when the owner heartbeat advances", async () => { - if ( - runTestInSubprocess( - import.meta.path, - "abandons stale recovery when the owner heartbeat advances", - ) - ) { - return; - } - const root = await temporaryDirectory(); - const home = await prepareCodexSecurityCredentialHome({ - CODEX_SECURITY_STATE_DIR: join(root, "state"), - }); - const lock = join(home, ".codex-security-scan.lock"); - const controller = new AbortController(); - const heartbeatTimer = captureCredentialLockHeartbeatTimer(); - const realDelay = timersPromises.setTimeout; - let waitedForHeartbeat = false; - let observedDelaySignal: AbortSignal | undefined; - mock.module("node:timers/promises", () => ({ - ...timersPromises, - setTimeout: (async ( - milliseconds: number, - value?: unknown, - options?: { signal?: AbortSignal }, - ) => { - observedDelaySignal = options?.signal; - if (milliseconds === 5_000) { - waitedForHeartbeat = true; - await heartbeatTimer.callback!(); - return value; - } - controller.abort(new DOMException("canceled", "AbortError")); - return await realDelay(0, value, { signal: controller.signal }); - }) as typeof timersPromises.setTimeout, - })); - let release: (() => Promise) | undefined; - - try { - release = await acquireCodexSecurityCredentialHomeLock(home); - expect(heartbeatTimer.milliseconds).toBe(5_000); - expect(heartbeatTimer.unref).toHaveBeenCalledTimes(1); - expect(heartbeatTimer.callback).toBeDefined(); - const stale = new Date(Date.now() - 10 * 60_000); - await utimes(lock, stale, stale); - - const waiting = acquireCodexSecurityCredentialHomeLock( - home, - controller.signal, - ); - await expect(waiting).rejects.toMatchObject({ name: "AbortError" }); - expect(waitedForHeartbeat).toBe(true); - expect(observedDelaySignal).toBe(controller.signal); - expect(Date.now() - (await stat(lock)).mtimeMs).toBeLessThan(30_000); - expect(existsSync(lock)).toBe(true); - } finally { - mock.module("node:timers/promises", () => ({ - ...timersPromises, - setTimeout: realDelay, - })); - try { - if (release !== undefined) await release(); - } finally { - heartbeatTimer.restore(); + testPosix( + "rejects linked or non-private credential-lock database files", + async () => { + const root = await temporaryDirectory(); + const home = await prepareCodexSecurityCredentialHome({ + CODEX_SECURITY_STATE_DIR: join(root, "state"), + }); + const database = join(home, ".codex-security-scan.sqlite3"); + const target = join(home, "target"); + await writeFile(target, "unchanged", { mode: 0o600 }); + for (const createLink of [symlink, link]) { + await createLink(target, database); + await expect( + acquireCodexSecurityCredentialHomeLock(home), + ).rejects.toThrow("regular file"); + expect(await readFile(target, "utf8")).toBe("unchanged"); + await rm(database); } - } - }); + await writeFile(database, "", { mode: 0o600 }); + await chmod(database, 0o644); + await expect( + acquireCodexSecurityCredentialHomeLock(home), + ).rejects.toThrow("must not be accessible to other users"); + }, + ); test("recovers credential-home locks whose owner names no process", async () => { const root = await temporaryDirectory();