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
10 changes: 10 additions & 0 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions sdk/typescript/scripts/fixtures/credential-lock.mjs
Original file line number Diff line number Diff line change
@@ -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?.();
}
}
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
Loading
Loading