Skip to content
Merged
28 changes: 28 additions & 0 deletions FORK.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 0 additions & 1 deletion plugins/codex/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"name": "codex",
"version": "1.0.25",
"description": "Run Codex reviews, plan reviews, and tasks from Claude Code."
}
8 changes: 7 additions & 1 deletion plugins/codex/scripts/lib/app-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
26 changes: 18 additions & 8 deletions plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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[]} */
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down
137 changes: 137 additions & 0 deletions plugins/codex/scripts/lib/fork-thread-config.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
};
}
15 changes: 14 additions & 1 deletion plugins/codex/scripts/lib/process.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down Expand Up @@ -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;
}
}
Loading
Loading