From df6e93f72de2ef78c6a8a49fa6814e5c07156a21 Mon Sep 17 00:00:00 2001 From: johannestreutlein Date: Wed, 28 Oct 2026 21:21:26 +0000 Subject: [PATCH 1/8] Let the caller choose Codex's sandbox (CODEX_COMPANION_THREAD_CONFIG) With CODEX_COMPANION_THREAD_CONFIG set, thread/start and thread/resume send the caller's Codex config for the run's mode instead of sandbox, approvalPolicy and cwd; each run starts its own app-server (no shared broker); and a run whose active permission profile is not the requested one stops before its first turn. plugin.json has no version, so each fork commit installs as an update. FORK.md says what differs from upstream. Co-Authored-By: Claude Opus 5.5 --- FORK.md | 26 ++++ plugins/codex/.claude-plugin/plugin.json | 1 - plugins/codex/scripts/lib/app-server.mjs | 5 +- plugins/codex/scripts/lib/codex.mjs | 15 +- .../codex/scripts/lib/fork-thread-config.mjs | 58 ++++++++ tests/fork-thread-config.test.mjs | 131 ++++++++++++++++++ 6 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 FORK.md create mode 100644 plugins/codex/scripts/lib/fork-thread-config.mjs create mode 100644 tests/fork-thread-config.test.mjs diff --git a/FORK.md b/FORK.md new file mode 100644 index 000000000..f75db2511 --- /dev/null +++ b/FORK.md @@ -0,0 +1,26 @@ +# 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; 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. + + Without the variable, the plugin behaves as upstream. The code is `plugins/codex/scripts/lib/fork-thread-config.mjs`, called from two places in `codex.mjs` and one in `app-server.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 upstream's `main` into this `main`, but only commits it first saw at least 7 days earlier, as a defense against supply-chain attacks: every machine that installs this plugin updates itself from `main`. 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/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..45b19bd89 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")); @@ -348,7 +350,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..463054a6b 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 } 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) { 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..0c1290eca --- /dev/null +++ b/plugins/codex/scripts/lib/fork-thread-config.mjs @@ -0,0 +1,58 @@ +// 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. + +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.` + ); + } +} diff --git a/tests/fork-thread-config.test.mjs b/tests/fork-thread-config.test.mjs new file mode 100644 index 000000000..1e8c9256c --- /dev/null +++ b/tests/fork-thread-config.test.mjs @@ -0,0 +1,131 @@ +// 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 { + THREAD_CONFIG_ENV, + applyThreadConfig, + checkPermissionProfile, + 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"/); +}); From b7e10ff1e28194dc6c8689e038cac287d374bb4a Mon Sep 17 00:00:00 2001 From: johannestreutlein Date: Wed, 28 Oct 2026 21:48:50 +0000 Subject: [PATCH 2/8] cancel: a run without a broker interrupts its own turn when signaled Without a broker, `cancel` sent its turn interrupt to a fresh app-server that does not hold the run's thread, and its kill did nothing for a run that leads no process group (ESRCH), so runs went on. Now a run with CODEX_COMPANION_THREAD_CONFIG set interrupts its own turn and closes its app-server on SIGTERM/SIGINT, and the kill helper also signals the process itself when its process group is not found. Co-Authored-By: Claude Opus 5.5 --- FORK.md | 4 +- plugins/codex/scripts/lib/codex.mjs | 8 ++- .../codex/scripts/lib/fork-thread-config.mjs | 37 +++++++++++- plugins/codex/scripts/lib/process.mjs | 4 +- tests/fork-thread-config.test.mjs | 56 +++++++++++++++++++ 5 files changed, 103 insertions(+), 6 deletions(-) diff --git a/FORK.md b/FORK.md index f75db2511..0ed180231 100644 --- a/FORK.md +++ b/FORK.md @@ -17,7 +17,9 @@ This repository is a fork of [Crazytieguy/codex-plugin-cc](https://github.com/Cr - 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. - Without the variable, the plugin behaves as upstream. The code is `plugins/codex/scripts/lib/fork-thread-config.mjs`, called from two places in `codex.mjs` and one in `app-server.mjs`; the tests are `tests/fork-thread-config.test.mjs`. + - `cancel` signals the run's process (a shared broker would otherwise take the interrupt), and the run then interrupts its own turn, so Codex stops the turn's commands, 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. + + 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 three places in `codex.mjs` and one in `app-server.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.) diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index 463054a6b..b6481771d 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -42,7 +42,7 @@ 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 } from "./fork-thread-config.mjs"; +import { applyThreadConfig, checkPermissionProfile, interruptTurnOnSignal } from "./fork-thread-config.mjs"; const SERVICE_NAME = "claude_code_codex_plugin"; const TASK_THREAD_PREFIX = "Codex Companion Task"; @@ -1074,6 +1074,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, @@ -1085,8 +1087,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 index 0c1290eca..c4b10bba8 100644 --- a/plugins/codex/scripts/lib/fork-thread-config.mjs +++ b/plugins/codex/scripts/lib/fork-thread-config.mjs @@ -7,7 +7,9 @@ // 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. +// 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, so that Codex stops the turn's commands, before it exits. export const THREAD_CONFIG_ENV = "CODEX_COMPANION_THREAD_CONFIG"; @@ -56,3 +58,36 @@ export function checkPermissionProfile(response, options = {}, env = process.env ); } } + +// Installs, for a run with the variable set, a SIGTERM/SIGINT handler that interrupts this thread's turn and +// closes the app-server before the process exits. Pass `onResponse` to captureTurn; call `remove` afterwards. +export function interruptTurnOnSignal(client, threadId, env = process.env, proc = process) { + 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); + const interrupt = turnId ? client.request("turn/interrupt", { threadId, turnId }) : Promise.resolve(); + interrupt + .catch(() => {}) + .then(() => client.close()) + .catch(() => {}) + .finally(() => { + clearTimeout(timer); + exit(); + }); + }; + proc.once("SIGTERM", handler); + proc.once("SIGINT", handler); + return { + onResponse: (response) => { + turnId = response?.turn?.id ?? null; + }, + 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..54784aa93 100644 --- a/plugins/codex/scripts/lib/process.mjs +++ b/plugins/codex/scripts/lib/process.mjs @@ -123,7 +123,9 @@ 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), so signal the process itself then too (see FORK.md). + { try { killImpl(pid, "SIGTERM"); return { attempted: true, delivered: true, method: "process" }; diff --git a/tests/fork-thread-config.test.mjs b/tests/fork-thread-config.test.mjs index 1e8c9256c..3bbfa98cc 100644 --- a/tests/fork-thread-config.test.mjs +++ b/tests/fork-thread-config.test.mjs @@ -8,10 +8,12 @@ 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, + interruptTurnOnSignal, threadConfigFor } from "../plugins/codex/scripts/lib/fork-thread-config.mjs"; @@ -129,3 +131,57 @@ test("a run whose active profile differs is stopped", () => { 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 outcome = terminateProcessTree(4321, { platform: "linux", killImpl }); + assert.deepEqual(calls, [[-4321, "SIGTERM"], [4321, "SIGTERM"]]); + assert.equal(outcome.delivered, true); + assert.equal(outcome.method, "process"); +}); + +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; + } + }; + const handlers = new Map(); + 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), + exit: (code) => { + exitCode = code; + exited(); + } + }; + const env = { [THREAD_CONFIG_ENV]: CONFIG }; + const interrupt = interruptTurnOnSignal(client, "thread-1", env, proc); + interrupt.onResponse({ turn: { id: "turn-7" } }); + handlers.get("SIGTERM")("SIGTERM"); + await done; + assert.deepEqual(requests, [["turn/interrupt", { threadId: "thread-1", turnId: "turn-7" }]]); + assert.equal(closed, true); + assert.equal(exitCode, 143); +}); + +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(); +}); From 61543cbd8fa07619bd8c49517236b73a3cd75e09 Mon Sep 17 00:00:00 2001 From: johannestreutlein Date: Wed, 28 Oct 2026 21:54:15 +0000 Subject: [PATCH 3/8] FORK.md: describe the sync job's package-style rule Co-Authored-By: Claude Opus 5.5 --- FORK.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FORK.md b/FORK.md index 0ed180231..475f50520 100644 --- a/FORK.md +++ b/FORK.md @@ -23,6 +23,6 @@ This repository is a fork of [Crazytieguy/codex-plugin-cc](https://github.com/Cr 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 upstream's `main` into this `main`, but only commits it first saw at least 7 days earlier, as a defense against supply-chain attacks: every machine that installs this plugin updates itself from `main`. See `.github/upstream-sync/README.md`. +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. Upstream changes to `.github/` or `.gitattributes` are never merged automatically. See `.github/upstream-sync/README.md`. Changed files carry a comment saying so, as the Apache License 2.0 (section 4(b)) asks. From bf254e23e4b258da81855e8c2c68f1dfc94ba3e3 Mon Sep 17 00:00:00 2001 From: johannestreutlein Date: Wed, 28 Oct 2026 21:55:37 +0000 Subject: [PATCH 4/8] cancel: the plain review interrupts its own turn too (review thread for detached delivery) Co-Authored-By: Claude Opus 5.5 --- FORK.md | 2 +- plugins/codex/scripts/lib/codex.mjs | 5 ++++- plugins/codex/scripts/lib/fork-thread-config.mjs | 4 +++- tests/fork-thread-config.test.mjs | 14 ++++++++++++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/FORK.md b/FORK.md index 475f50520..b71120993 100644 --- a/FORK.md +++ b/FORK.md @@ -17,7 +17,7 @@ This repository is a fork of [Crazytieguy/codex-plugin-cc](https://github.com/Cr - 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` signals the run's process (a shared broker would otherwise take the interrupt), and the run then interrupts its own turn, so Codex stops the turn's commands, 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. + - `cancel` (for tasks and the plugin's plain `review`) signals the run's process (a shared broker would otherwise take the interrupt), and the run then interrupts its own turn, so Codex stops the turn's commands, 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. 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 three places in `codex.mjs` and one in `app-server.mjs`; the tests are `tests/fork-thread-config.test.mjs`. diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index b6481771d..dc1404cd3 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -1000,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, @@ -1018,9 +1020,10 @@ export async function runAppServerReview(cwd, options = {}) { state.threadId = response.reviewThreadId; } } + signalInterrupt.onResponse?.(response, state); } } - ); + ).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 index c4b10bba8..a43f3387d 100644 --- a/plugins/codex/scripts/lib/fork-thread-config.mjs +++ b/plugins/codex/scripts/lib/fork-thread-config.mjs @@ -82,8 +82,10 @@ export function interruptTurnOnSignal(client, threadId, env = process.env, proc proc.once("SIGTERM", handler); proc.once("SIGINT", handler); return { - onResponse: (response) => { + // 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); diff --git a/tests/fork-thread-config.test.mjs b/tests/fork-thread-config.test.mjs index 3bbfa98cc..0daedfc4e 100644 --- a/tests/fork-thread-config.test.mjs +++ b/tests/fork-thread-config.test.mjs @@ -185,3 +185,17 @@ test("without the variable no signal handler is installed", () => { 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), exit: () => exited() }; + const interrupt = interruptTurnOnSignal(client, "source-thread", { [THREAD_CONFIG_ENV]: CONFIG }, proc); + 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" }]]); +}); From b3e62a4a297adde39aa9801d4a8aeef6a03841c0 Mon Sep 17 00:00:00 2001 From: johannestreutlein Date: Wed, 28 Oct 2026 22:05:42 +0000 Subject: [PATCH 5/8] FORK.md: the sync job's force-push check and hand merges Co-Authored-By: Claude Opus 5.5 --- FORK.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FORK.md b/FORK.md index b71120993..2bc19e464 100644 --- a/FORK.md +++ b/FORK.md @@ -23,6 +23,6 @@ This repository is a fork of [Crazytieguy/codex-plugin-cc](https://github.com/Cr 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. Upstream changes to `.github/` or `.gitattributes` are never merged automatically. See `.github/upstream-sync/README.md`. +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. From 648bd6c88d70bdceca785aa81a91fbce5c53dbb3 Mon Sep 17 00:00:00 2001 From: johannestreutlein Date: Wed, 28 Oct 2026 22:08:54 +0000 Subject: [PATCH 6/8] cancel: kill the processes below the run's app-server; signal only the plugin's own pids Codex's turn interrupt leaves commands running in the background, and on Linux its sandbox keeps them alive when the app-server dies, so the run now lists the processes below its app-server before it interrupts the turn and kills them afterwards. The kill helper's fallback for a pid that leads no process group now checks that the pid still runs one of this plugin's scripts, since a stale pid may belong to another program. Co-Authored-By: Claude Opus 5.5 --- FORK.md | 4 +- .../codex/scripts/lib/fork-thread-config.mjs | 50 +++++++++++++++++-- plugins/codex/scripts/lib/process.mjs | 15 +++++- tests/fork-thread-config.test.mjs | 33 ++++++++++-- 4 files changed, 90 insertions(+), 12 deletions(-) diff --git a/FORK.md b/FORK.md index 2bc19e464..fc45c755b 100644 --- a/FORK.md +++ b/FORK.md @@ -17,9 +17,9 @@ This repository is a fork of [Crazytieguy/codex-plugin-cc](https://github.com/Cr - 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), and the run then interrupts its own turn, so Codex stops the turn's commands, 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. + - `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 three places in `codex.mjs` and one in `app-server.mjs`; the tests are `tests/fork-thread-config.test.mjs`. + 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.) diff --git a/plugins/codex/scripts/lib/fork-thread-config.mjs b/plugins/codex/scripts/lib/fork-thread-config.mjs index a43f3387d..356ef25e6 100644 --- a/plugins/codex/scripts/lib/fork-thread-config.mjs +++ b/plugins/codex/scripts/lib/fork-thread-config.mjs @@ -9,7 +9,10 @@ // 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, so that Codex stops the turn's commands, before it exits. +// 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"; @@ -59,9 +62,37 @@ export function checkPermissionProfile(response, options = {}, env = process.env } } -// Installs, for a run with the variable set, a SIGTERM/SIGINT handler that interrupts this thread's turn and -// closes the app-server before the process exits. Pass `onResponse` to captureTurn; call `remove` afterwards. -export function interruptTurnOnSignal(client, threadId, env = process.env, proc = process) { +// 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() {} }; } @@ -69,9 +100,20 @@ export function interruptTurnOnSignal(client, threadId, env = process.env, proc 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(() => { diff --git a/plugins/codex/scripts/lib/process.mjs b/plugins/codex/scripts/lib/process.mjs index 54784aa93..cfdde674e 100644 --- a/plugins/codex/scripts/lib/process.mjs +++ b/plugins/codex/scripts/lib/process.mjs @@ -124,8 +124,9 @@ export function terminateProcessTree(pid, options = {}) { return { attempted: true, delivered: true, method: "process-group" }; } catch (error) { // TruthfulAI-research fork: ESRCH also means pid leads no process group (a run started with nohup from a - // shell), so signal the process itself then too (see FORK.md). - { + // 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" }; @@ -157,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 index 0daedfc4e..377266e94 100644 --- a/tests/fork-thread-config.test.mjs +++ b/tests/fork-thread-config.test.mjs @@ -13,6 +13,7 @@ import { THREAD_CONFIG_ENV, applyThreadConfig, checkPermissionProfile, + descendantPids, interruptTurnOnSignal, threadConfigFor } from "../plugins/codex/scripts/lib/fork-thread-config.mjs"; @@ -140,12 +141,25 @@ test("cancel reaches a run that leads no process group: the process itself is si throw Object.assign(new Error("no such process group"), { code: "ESRCH" }); } }; - const outcome = terminateProcessTree(4321, { platform: "linux", killImpl }); + 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; @@ -155,28 +169,39 @@ test("on SIGTERM a run interrupts its own turn, closes the app-server and exits" 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 interrupt = interruptTurnOnSignal(client, "thread-1", env, proc); + 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: () => {} }; @@ -192,8 +217,8 @@ test("a review delivered to its own thread interrupts the turn on that thread", 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), exit: () => exited() }; - const interrupt = interruptTurnOnSignal(client, "source-thread", { [THREAD_CONFIG_ENV]: CONFIG }, proc); + 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; From fc47c7fe77b968d2b3aa2c131a6417bd2b4bb05d Mon Sep 17 00:00:00 2001 From: johannestreutlein Date: Wed, 28 Oct 2026 22:14:31 +0000 Subject: [PATCH 7/8] cancel: start the run's app-server in its own process group When the run leads its process group (started with setsid), cancel's group signal reached the app-server at the same moment, and Codex tore down its outer sandbox processes before the run could list the command below them, which then survived. With the app-server in its own group, the signal reaches only the run. Co-Authored-By: Claude Opus 5.5 --- FORK.md | 2 +- plugins/codex/scripts/lib/app-server.mjs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/FORK.md b/FORK.md index fc45c755b..f807319dc 100644 --- a/FORK.md +++ b/FORK.md @@ -12,7 +12,7 @@ This repository is a fork of [Crazytieguy/codex-plugin-cc](https://github.com/Cr ``` 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; no broker is shared; + - 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. diff --git a/plugins/codex/scripts/lib/app-server.mjs b/plugins/codex/scripts/lib/app-server.mjs index 45b19bd89..9e511cd09 100644 --- a/plugins/codex/scripts/lib/app-server.mjs +++ b/plugins/codex/scripts/lib/app-server.mjs @@ -205,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 From b248bb1a1b4ee3fa832650c5c44a597697128f22 Mon Sep 17 00:00:00 2001 From: johannestreutlein Date: Wed, 28 Oct 2026 22:17:38 +0000 Subject: [PATCH 8/8] README: point to this fork's install commands and FORK.md Co-Authored-By: Claude Opus 5.5 --- README.md | 2 ++ 1 file changed, 2 insertions(+) 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.