Skip to content
Merged
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
1 change: 1 addition & 0 deletions scripts/test-layout/layout-codex-collision.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion tests/codex-integration/codex-cli-update-apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ async function approved(f: ReturnType<typeof fixture>, deps = depsFor(f)) {
}
function cli(f: ReturnType<typeof fixture>, 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);
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof fixture>, 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<string>((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);

});
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions tests/helpers/codex-cli-apply-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"),
Expand Down
40 changes: 40 additions & 0 deletions tests/helpers/codex-cli-process-scan-preload.ts
Original file line number Diff line number Diff line change
@@ -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<typeof scanOriginal>[0] = {}) =>
scanOriginal({ ...io, listSnapshots: snapshots }),
scanCodexSessionProcessesAsync: (io: Parameters<typeof scanAsyncOriginal>[0] = {}) =>
scanAsyncOriginal({ ...io, listSnapshotsAsync: async () => snapshots() }),
}));
}
Loading