diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index d9af6341408..8bb668619f0 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -31,6 +31,7 @@ "codex-launcher-env.test.ts": "codex-integration", "codex-cli-update-artifacts.test.ts": "codex-integration", "codex-cli-update-apply.test.ts": "codex-integration", + "codex-cli-update-process-fixture.test.ts": "codex-integration", "codex-cli-update-paths.test.ts": "codex-integration", "codex-cli-update-admission.test.ts": "codex-integration", "codex-cli-update-recovery.test.ts": "codex-integration", diff --git a/tests/codex-integration/codex-cli-update-apply.test.ts b/tests/codex-integration/codex-cli-update-apply.test.ts index dea2f9b1fb9..687a8a47f3e 100644 --- a/tests/codex-integration/codex-cli-update-apply.test.ts +++ b/tests/codex-integration/codex-cli-update-apply.test.ts @@ -43,7 +43,7 @@ async function approved(f: ReturnType, deps = depsFor(f)) { } function cli(f: ReturnType, args: string[]) { const result = spawnSync(Bun.which("node")!, [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", ...args, "--json"], { - cwd: repoRoot(), encoding: "utf8", env: f.env, timeout: 60_000, + cwd: f.root, encoding: "utf8", env: f.env, timeout: 60_000, }); expect(result.error).toBeUndefined(); expect(result.status).toBe(0); return JSON.parse(result.stdout); @@ -373,6 +373,14 @@ describe.skipIf(!posix)("Codex CLI private-prefix POSIX apply", () => { try { await waitForManagementServer(base, server.child, server.closed, server.output); session = await issueFixtureGuiSession(base); + for (const [scan, refusal] of [["active", "blocked_active_session"], ["unavailable", "blocked_process_state_unknown"]] as const) { + writeFileSync(join(f.root, "process-scan-state.json"), JSON.stringify({ scans: [scan] })); + const response = await fetch(`${base}/api/codex/cli-update/plan`, { headers: fixtureSessionHeaders(session) }); + expect(response.ok).toBe(true); + expect(await response.json()).toMatchObject({ applicable: false, refusal }); + expect(readFileSync(f.trace, "utf8")).toBe(""); + } + writeFileSync(join(f.root, "process-scan-state.json"), JSON.stringify({ scans: ["empty", "empty", "empty"] })); const planResponse = await fetch(`${base}/api/codex/cli-update/plan`, { headers: fixtureSessionHeaders(session) }); if (!planResponse.ok) throw new Error(`isolated production plan refused (${planResponse.status})`); const plan = await planResponse.json() as { schemaVersion?: number; applicable: boolean; refusal?: string | null; diff --git a/tests/codex-integration/codex-cli-update-process-fixture.test.ts b/tests/codex-integration/codex-cli-update-process-fixture.test.ts new file mode 100644 index 00000000000..1e43f48deb1 --- /dev/null +++ b/tests/codex-integration/codex-cli-update-process-fixture.test.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { spawn, spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { scanCodexSessionProcessesAsync } from "../../src/codex/app-server-processes"; +import { createCodexCliApplyFixture } from "../helpers/codex-cli-apply-fixture"; +import { createCodexCliTestRoot } from "../helpers/codex-cli-fixture-root"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) removeTreeWithRetry(root); }); +function fixture() { + const root = createCodexCliTestRoot("ocx-apply-private-"); roots.push(root); + return createCodexCliApplyFixture(root, process.platform, process.arch); +} +function cli(f: ReturnType, args: string[], exitCode = 0) { + const result = spawnSync(Bun.which("node")!, [repoPath("bin", "ocx.mjs"), "system", "codex-cli-update", ...args, "--json"], { + cwd: f.root, encoding: "utf8", env: f.env, timeout: 60_000, + }); + expect(result.error).toBeUndefined(); expect(result.status).toBe(exitCode); + return JSON.parse(result.stdout); +} + +describe.skipIf(process.platform === "win32")("Codex CLI private-prefix POSIX apply", () => { + test.each([ + ["active", JSON.stringify({ scans: ["active"] }), "blocked_active_session"], + ["unavailable", JSON.stringify({ scans: ["unavailable"] }), "blocked_process_state_unknown"], + ["malformed", "not-json", "blocked_process_state_unknown"], + ["exhausted", JSON.stringify({ scans: [] }), "blocked_process_state_unknown"], + ])("fixture scan %s keeps the real plan refusal", (_name, state, refusal) => { + const f = fixture(); + writeFileSync(join(f.root, "process-scan-state.json"), state); + const plan = cli(f, ["plan"]); + expect(plan).toMatchObject({ applicable: false, refusal }); + expect(readFileSync(f.trace, "utf8")).toBe(""); + }, 120_000); + + test.each([["active", "blocked_active_session"], ["unavailable", "blocked_process_state_unknown"]] as const)( + "fixture final scan %s refuses before installer admission", (last, refusal) => { + const f = fixture(); + const plan = cli(f, ["plan"]); + expect(plan.applicable).toBe(true); + writeFileSync(join(f.root, "process-scan-state.json"), JSON.stringify({ scans: ["empty", "empty", last] })); + const result = cli(f, ["apply", "--plan-id", plan.planId, "--yes"], 1); + expect(result).toMatchObject({ outcome: "refused", reason: refusal }); + const scans = readFileSync(join(f.root, "process-scan-trace.jsonl"), "utf8").trim().split("\n") + .map(line => JSON.parse(line) as { value: string; index: number }); + expect(scans).toEqual([{ value: "empty", index: 1 }, { value: "empty", index: 1 }, + { value: "empty", index: 2 }, { value: last, index: 3 }]); + expect(readFileSync(f.trace, "utf8")).toBe(""); + }, 120_000); + + test("published launcher remains isolated from a real owned Codex-shaped process", async () => { + const f = fixture(); + const node = Bun.which("node"); + if (!node) throw new Error("fixture node executable unavailable"); + const entry = join(f.root, "codex"); + writeFileSync(entry, 'process.stdout.write("READY\\n"); process.stdin.resume();\n'); + const child = spawn(node, [entry], { cwd: f.root, env: f.env, stdio: ["pipe", "pipe", "pipe"] }); + const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(resolve => { + child.once("close", (code, signal) => resolve({ code, signal })); + }); + try { + const ready = new Promise((resolve, reject) => { + let output = ""; + child.stdout!.on("data", chunk => { + output += String(chunk); + if (output.includes("\n")) resolve(output); + }); + child.once("error", reject); + closed.then(() => reject(new Error("owned fixture process exited before READY"))); + }); + expect(await ready).toBe("READY\n"); + const scan = await scanCodexSessionProcessesAsync(); + expect(scan.kind).toBe("observed"); + if (scan.kind !== "observed") throw new Error("native session scan unavailable"); + expect(scan.processes.some(process => process.pid === child.pid)).toBe(true); + const bunfig = join(f.root, "bunfig.toml"); + const fixtureConfig = readFileSync(bunfig, "utf8"); + writeFileSync(bunfig, ""); + expect(cli(f, ["plan"])).toMatchObject({ applicable: false, refusal: "blocked_active_session" }); + writeFileSync(bunfig, fixtureConfig); + const plan = cli(f, ["plan"]); + expect(plan.applicable).toBe(true); + const result = cli(f, ["apply", "--plan-id", plan.planId, "--yes"]); + expect(result).toMatchObject({ status: "applied", outcome: "applied", childState: "terminal" }); + expect(readFileSync(f.trace, "utf8").trim().split("\n")).toHaveLength(1); + } finally { + child.stdin?.end(); + const exit = await closed; + expect(exit).toEqual({ code: 0, signal: null }); + } + }, 120_000); + +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 71a17ebfca7..80eb6eab44f 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -83,6 +83,7 @@ "codex-cli-windows-child.test.ts": "codex-integration", "codex-cli-update-artifacts.test.ts": "codex-integration", "codex-cli-update-apply.test.ts": "codex-integration", + "codex-cli-update-process-fixture.test.ts": "codex-integration", "codex-cli-update-paths.test.ts": "codex-integration", "codex-cli-update-admission.test.ts": "codex-integration", "codex-cli-update-recovery.test.ts": "codex-integration", diff --git a/tests/helpers/codex-cli-apply-fixture.ts b/tests/helpers/codex-cli-apply-fixture.ts index 73371d5d17c..9d5406afe66 100644 --- a/tests/helpers/codex-cli-apply-fixture.ts +++ b/tests/helpers/codex-cli-apply-fixture.ts @@ -4,6 +4,7 @@ import { dirname, join } from "node:path"; import { createNpmConfigIsolation, isolatedNpmTarget } from "../../src/codex/cli-update-plan"; import { fixtureArtifact, fixtureArtifactSet } from "./codex-cli-apply-artifact"; import type { CodexCliApplyResult } from "../../src/codex/cli-update-apply"; +import { helperPath } from "./repo-root"; /** Admission waits must also observe an early refusal/rejection. Otherwise a * diagnostic failure before install/fetch entry becomes a misleading timeout. @@ -100,6 +101,9 @@ export function createCodexCliApplyFixture(root: string, platform: NodeJS.Platfo ])); if (process.platform !== "win32") { writeFixtureNpmWrapper(fakeBin, node, realNpm, metadata, packs, trace); + // A private cwd makes this preload local to the published-launcher fixtures. + writeFileSync(join(root, "bunfig.toml"), `preload = [${JSON.stringify(helperPath("codex-cli-process-scan-preload.ts"))}]\n`); + writeFileSync(join(root, "process-scan-state.json"), JSON.stringify({ scans: ["empty", "empty", "empty"] })); } const launcher = process.platform === "win32" ? join(prefix, "codex.cmd") : join(prefix, "bin", "codex"); const env = { ...process.env, HOME: home, TMPDIR: temporaryRoot, OPENCODEX_HOME: join(home, "ocx"), diff --git a/tests/helpers/codex-cli-process-scan-preload.ts b/tests/helpers/codex-cli-process-scan-preload.ts new file mode 100644 index 00000000000..d3bbdaf1fd6 --- /dev/null +++ b/tests/helpers/codex-cli-process-scan-preload.ts @@ -0,0 +1,40 @@ +import { mock } from "bun:test"; +import { appendFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Only fixture-owned Bun processes load this file through their private bunfig. +// Keep the published CLI and update worker real; replace only their process view. +if (Bun.isMainThread) { + const workerModule = await import("../../src/lib/worker-embed"); + const spawnOriginal = workerModule.spawnWorker; + mock.module(import.meta.resolve("../../src/lib/worker-embed"), () => ({ + ...workerModule, + spawnWorker: (url: string, key: string) => key === "cli-update-worker" + ? new Worker(url, { preload: [fileURLToPath(import.meta.url)] }) + : spawnOriginal(url, key), + })); +} else { + const processModule = await import("../../src/codex/app-server-processes"); + const scanOriginal = processModule.scanCodexSessionProcesses; + const scanAsyncOriginal = processModule.scanCodexSessionProcessesAsync; + let index = 0; + const snapshots = () => { + const state = JSON.parse(readFileSync(join(process.cwd(), "process-scan-state.json"), "utf8")) as { scans?: unknown }; + if (!Array.isArray(state.scans) || !state.scans.every(value => ["empty", "active", "unavailable"].includes(value))) { + throw new Error("invalid fixture process scan state"); + } + const value = state.scans[index++]; + if (value === undefined) throw new Error("fixture process scan sequence exhausted"); + appendFileSync(join(process.cwd(), "process-scan-trace.jsonl"), JSON.stringify({ value, index }) + "\n"); + if (value === "unavailable") throw new Error("fixture process scan unavailable"); + return value === "active" ? [{ pid: 918273, commandLine: "codex", executable: "codex" }] : []; + }; + mock.module(import.meta.resolve("../../src/codex/app-server-processes"), () => ({ + ...processModule, + scanCodexSessionProcesses: (io: Parameters[0] = {}) => + scanOriginal({ ...io, listSnapshots: snapshots }), + scanCodexSessionProcessesAsync: (io: Parameters[0] = {}) => + scanAsyncOriginal({ ...io, listSnapshotsAsync: async () => snapshots() }), + })); +}