Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,12 @@ 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 locks this home even if the process pauses; exiting or
crashing releases the lock. Keep `.codex-security-scan.sqlite3` between operations
and never remove it while an operation is running. PID reuse can make older
PID-only locks look live and block recovery. Stop all operations using this home
before manually removing an old `.codex-security-scan.lock` directory.

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
Expand Down
100 changes: 100 additions & 0 deletions sdk/typescript/scripts/fixtures/credential-lock.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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 owner and any JavaScript heartbeat. Bound the wait so the holder
// can exit if its parent dies.
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20_000);
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?.();
}
}
12 changes: 11 additions & 1 deletion sdk/typescript/scripts/smoke-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
11 changes: 2 additions & 9 deletions sdk/typescript/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1578,15 +1578,8 @@ export class CodexSecurity {
} catch (error) {
warnCleanupFailed(options, error);
} finally {
// The startup lock is normally released before workbench registration and Codex
// execution. This fallback covers failures during runtime preparation or
// authentication. The release only marks itself done once the lock directory is
// gone, so a failure leaves an owner.json naming this still-running process;
// recoverStaleCredentialHomeLock then refuses to reclaim it because that pid is
// alive, and later scans in this process wait on a lock nothing frees. Reporting
// success while leaving the client in that state is worse than failing, so the
// failure is only downgraded to a warning when the scan already failed and that
// error is the one worth keeping.
// Release any remaining startup lock, but preserve the scan's error if both
// the scan and lock cleanup fail.
try {
await releaseCredentialHome?.();
} catch (error) {
Expand Down
Loading
Loading