diff --git a/FORK.md b/FORK.md new file mode 100644 index 000000000..f807319dc --- /dev/null +++ b/FORK.md @@ -0,0 +1,28 @@ +# TruthfulAI-research's fork of codex-plugin-cc + +This repository is a fork of [Crazytieguy/codex-plugin-cc](https://github.com/Crazytieguy/codex-plugin-cc), which is itself a fork of [openai/codex-plugin-cc](https://github.com/openai/codex-plugin-cc). It is installed as `codex@codex-plugin-cc` (the marketplace keeps upstream's name), and the TruthfulAI-research research plugin (`research-codex`) uses it for GPT reviews and tasks. Everything else is upstream's; see README.md. + +## What differs from upstream + +1. **The caller can choose the sandbox.** Upstream starts every Codex run read-only (`task --write`: workspace-write) with no network and no approvals, which overrides Codex's own config. When `CODEX_COMPANION_THREAD_CONFIG` is set, the plugin sends a Codex config object instead of `sandbox`, `approvalPolicy` and `cwd`. The variable holds a JSON object keyed by the mode the command would otherwise use: + + ```json + {"read-only": {"default_permissions": "research-read", "permissions.research-read": {"filesystem": {}, "network": {}}}, + "workspace-write": {"default_permissions": "research-write", "permissions.research-write": {}}} + ``` + + Each entry is sent as thread/start's (and thread/resume's) `config`: keys are split on dots, so absolute paths go inside values. With the variable set: + - every run starts its own `codex app-server` in the run's folder, with the caller's environment and in its own process group; no broker is shared; + - `cwd` is not sent, because Codex marks a cwd that the sandbox can write as a trusted project in its config.toml (the app-server's own folder is used instead); + - a run stops before its first turn when the thread's active permission profile is not the entry's `default_permissions`; + - invalid JSON or a missing entry stops the run; it never falls back to the old settings. + + - `cancel` (for tasks and the plugin's plain `review`) signals the run's process (a shared broker would otherwise take the interrupt). The run then interrupts its own turn and kills the processes below its app-server, which Codex's interrupt leaves running, before it exits. For a run that leads no process group (started with `nohup` from a shell), the kill helper in `lib/process.mjs` now signals the process itself, where upstream gave up, after checking that the process is still the plugin's. + + Without the variable, the plugin behaves as upstream, apart from that kill-helper fix. The code is `plugins/codex/scripts/lib/fork-thread-config.mjs`, called from `codex.mjs`, `app-server.mjs` and `process.mjs`; the tests are `tests/fork-thread-config.test.mjs`. + +2. **No `version` in `plugins/codex/.claude-plugin/plugin.json`.** Claude Code installs a plugin update only when the version string changes, and uses the marketplace's git commit when there is none. Without the field, each commit of this fork installs as its own version, so switching from upstream and taking upstream's merges both reach installed copies. (`npm run check-version` reports the missing field; upstream's CI does not run it.) + +3. **Upstream's changes arrive through a daily job, after 7 days.** `.github/workflows/upstream-sync.yml` merges into this `main` the newest upstream commit that has been in upstream's `main` for at least 7 days, as npm's `min-release-age` does for packages: a defense against supply-chain attacks, since every machine that installs this plugin updates itself from `main`. A commit upstream removes is never merged; one it only reverts is merged once 7 days old. A commit that was force-pushed away and back counts from its return; the job checks GitHub's activity log of upstream's `main` for that. Upstream changes to `.github/` or `.gitattributes` are never merged automatically, so upstream cannot change the job itself; those merges are done by hand. See `.github/upstream-sync/README.md`. + +Changed files carry a comment saying so, as the Apache License 2.0 (section 4(b)) asks. diff --git a/README.md b/README.md index 3fb4ca1c4..6584621aa 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Codex plugin for Claude Code +> **TruthfulAI-research's fork** (see [FORK.md](FORK.md)): install it with `claude plugin marketplace add TruthfulAI-research/codex-plugin-cc` and `claude plugin install codex@codex-plugin-cc`. The rest of this README is upstream's. + AI models from different providers tend to have uncorrelated failure modes, making cross-model review effective at catching issues that self-review misses. Many users have found Codex particularly effective at reviewing Claude's work. This plugin connects them: Claude can delegate tasks to Codex, run adversarial reviews on code and plans, and iterate on the feedback autonomously. This is a fork of [openai/codex-plugin-cc](https://github.com/openai/codex-plugin-cc) with a focus on giving Claude and the user more control over when and how Codex is invoked, through composable hooks and a simpler CLI interface. diff --git a/plugins/codex/.claude-plugin/plugin.json b/plugins/codex/.claude-plugin/plugin.json index 362174915..4d5c311ab 100644 --- a/plugins/codex/.claude-plugin/plugin.json +++ b/plugins/codex/.claude-plugin/plugin.json @@ -1,5 +1,4 @@ { "name": "codex", - "version": "1.0.25", "description": "Run Codex reviews, plan reviews, and tasks from Claude Code." } diff --git a/plugins/codex/scripts/lib/app-server.mjs b/plugins/codex/scripts/lib/app-server.mjs index c62e39874..9e511cd09 100644 --- a/plugins/codex/scripts/lib/app-server.mjs +++ b/plugins/codex/scripts/lib/app-server.mjs @@ -15,6 +15,8 @@ import readline from "node:readline"; import { parseBrokerEndpoint } from "./broker-endpoint.mjs"; import { ensureBrokerSession, loadBrokerSession } from "./broker-lifecycle.mjs"; import { terminateProcessTree } from "./process.mjs"; +// TruthfulAI-research fork: see FORK.md. +import { threadConfigEnabled } from "./fork-thread-config.mjs"; const PLUGIN_MANIFEST_URL = new URL("../../.claude-plugin/plugin.json", import.meta.url); const PLUGIN_MANIFEST = JSON.parse(fs.readFileSync(PLUGIN_MANIFEST_URL, "utf8")); @@ -203,6 +205,9 @@ class SpawnedCodexAppServerClient extends AppServerClientBase { this.proc = spawn("codex", ["app-server"], { cwd: this.cwd, env: this.options.env ?? process.env, + // TruthfulAI-research fork: its own process group, so that `cancel` signaling the run's group reaches only + // the run, which then stops its turn and the processes below the app-server (see FORK.md). + detached: threadConfigEnabled(), stdio: ["pipe", "pipe", "pipe"], shell: process.platform === "win32" ? (process.env.SHELL || true) : false, windowsHide: true @@ -348,7 +353,8 @@ class BrokerCodexAppServerClient extends AppServerClientBase { export class CodexAppServerClient { static async connect(cwd, options = {}) { let brokerEndpoint = null; - if (!options.disableBroker) { + // TruthfulAI-research fork: a run with a caller-chosen sandbox never uses a shared broker. + if (!options.disableBroker && !threadConfigEnabled()) { brokerEndpoint = options.brokerEndpoint ?? options.env?.[BROKER_ENDPOINT_ENV] ?? process.env[BROKER_ENDPOINT_ENV] ?? null; if (!brokerEndpoint && options.reuseExistingBroker) { brokerEndpoint = loadBrokerSession(cwd)?.endpoint ?? null; diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index 7328e5231..dc1404cd3 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -41,6 +41,8 @@ import { BROKER_BUSY_RPC_CODE, BROKER_ENDPOINT_ENV, CodexAppServerClient } from import { parseBrokerEndpoint } from "./broker-endpoint.mjs"; import { loadBrokerSession } from "./broker-lifecycle.mjs"; import { binaryAvailable, isPidAlive } from "./process.mjs"; +// TruthfulAI-research fork: see FORK.md. +import { applyThreadConfig, checkPermissionProfile, interruptTurnOnSignal } from "./fork-thread-config.mjs"; const SERVICE_NAME = "claude_code_codex_plugin"; const TASK_THREAD_PREFIX = "Codex Companion Task"; @@ -57,25 +59,25 @@ function cleanCodexStderr(stderr) { /** @returns {ThreadStartParams} */ function buildThreadParams(cwd, options = {}) { - return { + return applyThreadConfig({ cwd, model: options.model ?? null, approvalPolicy: options.approvalPolicy ?? "never", sandbox: options.sandbox ?? "read-only", serviceName: SERVICE_NAME, ephemeral: options.ephemeral ?? true - }; + }, options); } /** @returns {ThreadResumeParams} */ function buildResumeParams(threadId, cwd, options = {}) { - return { + return applyThreadConfig({ threadId, cwd, model: options.model ?? null, approvalPolicy: options.approvalPolicy ?? "never", sandbox: options.sandbox ?? "read-only" - }; + }, options); } /** @returns {UserInput[]} */ @@ -646,6 +648,7 @@ async function withAppServer(cwd, fn, connectOptions = {}) { async function startThread(client, cwd, options = {}) { const response = await client.request("thread/start", buildThreadParams(cwd, options)); + checkPermissionProfile(response, options); const threadId = response.thread.id; if (options.threadName) { try { @@ -663,7 +666,9 @@ async function startThread(client, cwd, options = {}) { } async function resumeThread(client, threadId, cwd, options = {}) { - return client.request("thread/resume", buildResumeParams(threadId, cwd, options)); + const response = await client.request("thread/resume", buildResumeParams(threadId, cwd, options)); + checkPermissionProfile(response, options); + return response; } function buildResultStatus(turnState) { @@ -995,6 +1000,8 @@ export async function runAppServerReview(cwd, options = {}) { }); const delivery = options.delivery ?? "inline"; + // TruthfulAI-research fork: see FORK.md. + const signalInterrupt = interruptTurnOnSignal(client, sourceThreadId); const turnState = await captureTurn( client, sourceThreadId, @@ -1013,9 +1020,10 @@ export async function runAppServerReview(cwd, options = {}) { state.threadId = response.reviewThreadId; } } + signalInterrupt.onResponse?.(response, state); } } - ); + ).finally(() => signalInterrupt.remove()); return { status: buildResultStatus(turnState), @@ -1069,6 +1077,8 @@ export async function runAppServerTurn(cwd, options = {}) { throw new Error("A prompt is required for this Codex run."); } + // TruthfulAI-research fork: see FORK.md. + const signalInterrupt = interruptTurnOnSignal(client, threadId); const turnState = await captureTurn( client, threadId, @@ -1080,8 +1090,8 @@ export async function runAppServerTurn(cwd, options = {}) { effort: options.effort ?? null, outputSchema: options.outputSchema ?? null }), - { onProgress: options.onProgress } - ); + { onProgress: options.onProgress, onResponse: signalInterrupt.onResponse } + ).finally(() => signalInterrupt.remove()); return { status: buildResultStatus(turnState), diff --git a/plugins/codex/scripts/lib/fork-thread-config.mjs b/plugins/codex/scripts/lib/fork-thread-config.mjs new file mode 100644 index 000000000..356ef25e6 --- /dev/null +++ b/plugins/codex/scripts/lib/fork-thread-config.mjs @@ -0,0 +1,137 @@ +// TruthfulAI-research fork of codex-plugin-cc (see FORK.md); not in upstream. +// +// With CODEX_COMPANION_THREAD_CONFIG set, the caller, not this plugin, decides how Codex sandboxes a run. +// The variable holds a JSON object keyed by the sandbox mode a command would otherwise send ("read-only", +// or "workspace-write" for `task --write`); each entry is a Codex config object (thread/start's `config`). +// Then thread/start and thread/resume send that entry instead of `sandbox`, `approvalPolicy` and `cwd`, +// every run starts its own `codex app-server` (no shared broker, so it runs with the caller's environment), +// and a run whose active permission profile is not the entry's `default_permissions` is stopped before +// its first turn. `cwd` is left out because Codex marks a cwd that the sandbox can write as a trusted +// project in its config.toml; the app-server runs in cwd, which Codex then uses. Without a broker, `cancel` +// cannot reach a run's app-server, so it signals the run's process (lib/process.mjs), which then interrupts its +// own turn and kills the processes below its app-server before it exits: Codex's interrupt leaves commands +// running in the background, and on Linux its sandbox keeps them alive when the app-server dies. + +import { spawnSync } from "node:child_process"; + +export const THREAD_CONFIG_ENV = "CODEX_COMPANION_THREAD_CONFIG"; + +export function threadConfigEnabled(env = process.env) { + return Boolean(env[THREAD_CONFIG_ENV]); +} + +export function threadConfigFor(sandbox, env = process.env) { + const raw = env[THREAD_CONFIG_ENV]; + if (!raw) { + return null; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error(`${THREAD_CONFIG_ENV} is not valid JSON: ${error.message}`); + } + const config = parsed?.[sandbox]; + if (!config || typeof config !== "object" || Array.isArray(config)) { + throw new Error(`${THREAD_CONFIG_ENV} has no config object for "${sandbox}" runs.`); + } + return config; +} + +// thread/start or thread/resume params as upstream builds them, changed when the variable is set. +export function applyThreadConfig(params, options = {}, env = process.env) { + const config = threadConfigFor(options.sandbox ?? "read-only", env); + if (!config) { + return params; + } + const { cwd: _cwd, approvalPolicy: _approvalPolicy, sandbox: _sandbox, ...rest } = params; + return { ...rest, config }; +} + +// Throws unless the thread/start or thread/resume response names the requested permission profile. +export function checkPermissionProfile(response, options = {}, env = process.env) { + const wanted = threadConfigFor(options.sandbox ?? "read-only", env)?.default_permissions; + if (!wanted) { + return; + } + const active = response?.activePermissionProfile?.id; + if (active !== wanted) { + throw new Error( + `Codex did not apply the permission profile "${wanted}" (active: ${active ?? "none"}), so the run was stopped before it started.` + ); + } +} + +// The pids of all processes below pid, deepest first, from `ps` (Linux and macOS). +export function descendantPids(pid, run = spawnSync) { + if (!Number.isFinite(pid)) { + return []; + } + const result = run("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8" }); + if (result.status !== 0) { + return []; + } + const children = new Map(); + for (const line of String(result.stdout ?? "").split("\n")) { + const [child, parent] = line.trim().split(/\s+/).map(Number); + if (Number.isFinite(child) && Number.isFinite(parent)) { + children.set(parent, [...(children.get(parent) ?? []), child]); + } + } + const found = []; + const walk = (parent) => { + for (const child of children.get(parent) ?? []) { + walk(child); + found.push(child); + } + }; + walk(pid); + return found; +} + +// Installs, for a run with the variable set, a SIGTERM/SIGINT handler that interrupts this thread's turn, kills +// the processes below the app-server and closes it before the process exits. Pass `onResponse` to captureTurn; +// call `remove` afterwards. +export function interruptTurnOnSignal(client, threadId, env = process.env, proc = process, run = spawnSync) { + if (!threadConfigEnabled(env)) { + return { onResponse: undefined, remove() {} }; + } + let turnId = null; + const handler = (signal) => { + const exit = () => proc.exit(signal === "SIGINT" ? 130 : 143); + const timer = setTimeout(exit, 5000); + // Listed before the interrupt: once Codex ends the outer processes, the commands are no longer below it. + const below = descendantPids(client.proc?.pid, run); + const interrupt = turnId ? client.request("turn/interrupt", { threadId, turnId }) : Promise.resolve(); + interrupt + .catch(() => {}) + .then(() => { + for (const pid of below) { + try { + proc.kill(pid, "SIGKILL"); + } catch { + // already gone + } + } + }) + .then(() => client.close()) + .catch(() => {}) + .finally(() => { + clearTimeout(timer); + exit(); + }); + }; + proc.once("SIGTERM", handler); + proc.once("SIGINT", handler); + return { + // captureTurn passes its state; a review delivered to a new thread runs its turn there. + onResponse: (response, state) => { + turnId = response?.turn?.id ?? null; + threadId = state?.threadId ?? threadId; + }, + remove() { + proc.off("SIGTERM", handler); + proc.off("SIGINT", handler); + } + }; +} diff --git a/plugins/codex/scripts/lib/process.mjs b/plugins/codex/scripts/lib/process.mjs index 3917e8ed9..cfdde674e 100644 --- a/plugins/codex/scripts/lib/process.mjs +++ b/plugins/codex/scripts/lib/process.mjs @@ -123,7 +123,10 @@ export function terminateProcessTree(pid, options = {}) { killImpl(-pid, "SIGTERM"); return { attempted: true, delivered: true, method: "process-group" }; } catch (error) { - if (error?.code !== "ESRCH") { + // TruthfulAI-research fork: ESRCH also means pid leads no process group (a run started with nohup from a + // shell); then signal the process itself, but only while it is still this plugin's, since a stale pid may + // belong to another program by now (see FORK.md). + if (error?.code !== "ESRCH" || isPluginProcess(pid, runCommandImpl)) { try { killImpl(pid, "SIGTERM"); return { attempted: true, delivered: true, method: "process" }; @@ -155,3 +158,13 @@ export function formatCommandFailure(result) { } return parts.join(": "); } + +// TruthfulAI-research fork (see FORK.md): whether pid runs one of this plugin's scripts. +function isPluginProcess(pid, runCommandImpl) { + try { + const result = runCommandImpl("ps", ["-p", String(pid), "-o", "command="], {}); + return result.status === 0 && /codex-companion|app-server-broker/.test(result.stdout ?? ""); + } catch { + return false; + } +} diff --git a/tests/fork-thread-config.test.mjs b/tests/fork-thread-config.test.mjs new file mode 100644 index 000000000..377266e94 --- /dev/null +++ b/tests/fork-thread-config.test.mjs @@ -0,0 +1,226 @@ +// TruthfulAI-research fork (see FORK.md): tests for CODEX_COMPANION_THREAD_CONFIG. +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; + +import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs"; +import { initGitRepo, makeTempDir, run } from "./helpers.mjs"; +import { loadBrokerSession } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs"; +import { terminateProcessTree } from "../plugins/codex/scripts/lib/process.mjs"; +import { + THREAD_CONFIG_ENV, + applyThreadConfig, + checkPermissionProfile, + descendantPids, + interruptTurnOnSignal, + threadConfigFor +} from "../plugins/codex/scripts/lib/fork-thread-config.mjs"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SCRIPT = path.join(ROOT, "plugins", "codex", "scripts", "codex-companion.mjs"); + +const READ = { default_permissions: "research-read", approval_policy: "never" }; +const WRITE = { default_permissions: "research-write" }; +const CONFIG = JSON.stringify({ "read-only": READ, "workspace-write": WRITE }); +const UPSTREAM = { cwd: "/repo", model: null, approvalPolicy: "never", sandbox: "read-only", ephemeral: true }; + +test("without the variable, params are upstream's", () => { + assert.deepEqual(applyThreadConfig({ ...UPSTREAM }, {}, {}), UPSTREAM); + assert.equal(threadConfigFor("read-only", {}), null); + checkPermissionProfile({}, {}, {}); +}); + +test("the variable replaces sandbox, approval policy and cwd with the entry for the mode", () => { + const env = { [THREAD_CONFIG_ENV]: CONFIG }; + assert.deepEqual(applyThreadConfig({ ...UPSTREAM }, {}, env), { model: null, ephemeral: true, config: READ }); + assert.deepEqual( + applyThreadConfig({ ...UPSTREAM, sandbox: "workspace-write" }, { sandbox: "workspace-write" }, env), + { model: null, ephemeral: true, config: WRITE } + ); +}); + +test("bad JSON or a missing entry fails instead of falling back", () => { + assert.throws(() => threadConfigFor("read-only", { [THREAD_CONFIG_ENV]: "{" }), /not valid JSON/); + assert.throws( + () => threadConfigFor("workspace-write", { [THREAD_CONFIG_ENV]: JSON.stringify({ "read-only": READ }) }), + /no config object for "workspace-write"/ + ); + assert.throws(() => threadConfigFor("read-only", { [THREAD_CONFIG_ENV]: JSON.stringify({ "read-only": [] }) }), /no config object/); +}); + +test("the active permission profile must be the requested one", () => { + const env = { [THREAD_CONFIG_ENV]: CONFIG }; + checkPermissionProfile({ activePermissionProfile: { id: "research-read" } }, {}, env); + checkPermissionProfile({ activePermissionProfile: { id: "research-write" } }, { sandbox: "workspace-write" }, env); + assert.throws(() => checkPermissionProfile({ activePermissionProfile: { id: ":workspace" } }, {}, env), /did not apply/); + assert.throws(() => checkPermissionProfile({}, {}, env), /active: none/); +}); + +// Upstream's fake Codex, changed in a copy at test time: it logs thread/start params and answers with the +// active permission profile (the requested one, or a wrong one). +function installProfileFakeCodex(binDir, profileMode) { + installFakeCodex(binDir); + const scriptPath = path.join(binDir, "codex"); + const logPath = path.join(binDir, "thread-start.jsonl"); + let source = fs.readFileSync(scriptPath, "utf8"); + const start = "const thread = nextThread(state, message.params.cwd, message.params.ephemeral);"; + const result = "result: { thread: buildThread(thread), model:"; + assert.ok(source.includes(start) && source.includes(result), "upstream's fake Codex changed; update this test"); + source = source.replace( + start, + `require("node:fs").appendFileSync(${JSON.stringify(logPath)}, JSON.stringify({ params: message.params, cwd: process.cwd() }) + "\\n");\n` + + start.replace("message.params.cwd", "(message.params.cwd ?? process.cwd())") + ); + source = source.replace( + result, + `result: { activePermissionProfile: ${JSON.stringify(profileMode)} === "wrong" ? { id: ":workspace" } : { id: message.params.config && message.params.config.default_permissions }, thread: buildThread(thread), model:` + ); + fs.writeFileSync(scriptPath, source); + return logPath; +} + +function setupRepo() { + const repo = makeTempDir(); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + return repo; +} + +test("a task with the variable sends the config, no sandbox or cwd, and starts no broker", () => { + const repo = setupRepo(); + const binDir = makeTempDir(); + const logPath = installProfileFakeCodex(binDir, "right"); + const result = run("node", [SCRIPT, "task", "say hi"], { + cwd: repo, + env: { ...buildEnv(binDir), [THREAD_CONFIG_ENV]: CONFIG } + }); + assert.equal(result.status, 0, result.stderr); + const [entry] = fs.readFileSync(logPath, "utf8").trim().split("\n").map((line) => JSON.parse(line)); + assert.deepEqual(entry.params.config, READ); + for (const key of ["sandbox", "approvalPolicy", "cwd"]) { + assert.equal(key in entry.params, false, `thread/start sent ${key}`); + } + assert.equal(fs.realpathSync(entry.cwd), fs.realpathSync(repo)); + assert.equal(loadBrokerSession(repo), null); +}); + +test("task --write sends the workspace-write entry", () => { + const repo = setupRepo(); + const binDir = makeTempDir(); + const logPath = installProfileFakeCodex(binDir, "right"); + const result = run("node", [SCRIPT, "task", "--write", "say hi"], { + cwd: repo, + env: { ...buildEnv(binDir), [THREAD_CONFIG_ENV]: CONFIG } + }); + assert.equal(result.status, 0, result.stderr); + const [entry] = fs.readFileSync(logPath, "utf8").trim().split("\n").map((line) => JSON.parse(line)); + assert.deepEqual(entry.params.config, WRITE); +}); + +test("a run whose active profile differs is stopped", () => { + const repo = setupRepo(); + const binDir = makeTempDir(); + installProfileFakeCodex(binDir, "wrong"); + const result = run("node", [SCRIPT, "task", "say hi"], { + cwd: repo, + env: { ...buildEnv(binDir), [THREAD_CONFIG_ENV]: CONFIG } + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr + result.stdout, /did not apply the permission profile "research-read"/); +}); + +test("cancel reaches a run that leads no process group: the process itself is signaled", () => { + const calls = []; + const killImpl = (pid, signal) => { + calls.push([pid, signal]); + if (pid < 0) { + throw Object.assign(new Error("no such process group"), { code: "ESRCH" }); + } + }; + const runCommandImpl = () => ({ status: 0, stdout: "node /x/scripts/codex-companion.mjs task go\n" }); + const outcome = terminateProcessTree(4321, { platform: "linux", killImpl, runCommandImpl }); + assert.deepEqual(calls, [[-4321, "SIGTERM"], [4321, "SIGTERM"]]); + assert.equal(outcome.delivered, true); + assert.equal(outcome.method, "process"); +}); + +test("a stale pid that now belongs to another program is not signaled", () => { + const calls = []; + const killImpl = (pid, signal) => { + calls.push([pid, signal]); + throw Object.assign(new Error("no such process group"), { code: "ESRCH" }); + }; + const runCommandImpl = () => ({ status: 0, stdout: "/usr/bin/vim notes.txt\n" }); + const outcome = terminateProcessTree(4321, { platform: "linux", killImpl, runCommandImpl }); + assert.deepEqual(calls, [[-4321, "SIGTERM"]]); + assert.equal(outcome.delivered, false); +}); + +test("on SIGTERM a run interrupts its own turn, closes the app-server and exits", async () => { + const requests = []; + let closed = false; + const client = { + request: async (method, params) => requests.push([method, params]), + close: async () => { + closed = true; + } + }; + client.proc = { pid: 100 }; + const handlers = new Map(); + const killed = []; + let exitCode = null; + let exited; + const done = new Promise((resolve) => (exited = resolve)); + const proc = { + once: (signal, fn) => handlers.set(signal, fn), + off: (signal) => handlers.delete(signal), + kill: (pid, signal) => killed.push([pid, signal]), + exit: (code) => { + exitCode = code; + exited(); + } + }; + const env = { [THREAD_CONFIG_ENV]: CONFIG }; + const ps = () => ({ status: 0, stdout: " 100 1\n 200 100\n 300 200\n 400 1\n" }); + const interrupt = interruptTurnOnSignal(client, "thread-1", env, proc, ps); + interrupt.onResponse({ turn: { id: "turn-7" } }); + handlers.get("SIGTERM")("SIGTERM"); + await done; + assert.deepEqual(requests, [["turn/interrupt", { threadId: "thread-1", turnId: "turn-7" }]]); + assert.deepEqual(killed, [[300, "SIGKILL"], [200, "SIGKILL"]]); + assert.equal(closed, true); + assert.equal(exitCode, 143); +}); + +test("descendantPids lists every process below a pid, deepest first", () => { + const ps = () => ({ status: 0, stdout: "1 0\n10 1\n11 10\n12 11\n13 10\n20 1\n" }); + assert.deepEqual(descendantPids(10, ps), [12, 11, 13]); + assert.deepEqual(descendantPids(Number.NaN, ps), []); +}); + +test("without the variable no signal handler is installed", () => { + const handlers = new Map(); + const proc = { once: (signal, fn) => handlers.set(signal, fn), off: () => {}, exit: () => {} }; + const interrupt = interruptTurnOnSignal({}, "thread-1", {}, proc); + assert.equal(handlers.size, 0); + assert.equal(interrupt.onResponse, undefined); + interrupt.remove(); +}); + +test("a review delivered to its own thread interrupts the turn on that thread", async () => { + const requests = []; + const handlers = new Map(); + let exited; + const done = new Promise((resolve) => (exited = resolve)); + const client = { request: async (method, params) => requests.push([method, params]), close: async () => {} }; + const proc = { once: (signal, fn) => handlers.set(signal, fn), off: (signal) => handlers.delete(signal), kill: () => {}, exit: () => exited() }; + const interrupt = interruptTurnOnSignal(client, "source-thread", { [THREAD_CONFIG_ENV]: CONFIG }, proc, () => ({ status: 1 })); + interrupt.onResponse({ turn: { id: "turn-9" } }, { threadId: "review-thread" }); + handlers.get("SIGTERM")("SIGTERM"); + await done; + assert.deepEqual(requests, [["turn/interrupt", { threadId: "review-thread", turnId: "turn-9" }]]); +});