Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions plugins/codex/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-lifecycle-hook.mjs\" SessionStart",
"command": "node",
"args": [
"-e",
"const cp=require('node:child_process'),fs=require('node:fs'),p=require('node:path');let r=process.env.CLAUDE_PLUGIN_ROOT;if(!r){console.error('Codex plugin hook skipped: CLAUDE_PLUGIN_ROOT is not set.');process.exit(0);}if(process.platform==='win32'&&/^\\/[A-Za-z]\\//.test(r))r=r[1]+':/'+r.slice(3);const script=p.join(r,'scripts',process.argv[1]);if(!fs.existsSync(script)){console.error(`Codex plugin hook skipped: ${script} was not found.`);process.exit(0);}const x=cp.spawnSync(process.execPath,[script,...process.argv.slice(2)],{stdio:'inherit',env:process.env,windowsHide:true});if(x.error)throw x.error;process.exit(x.status??1);",
"session-lifecycle-hook.mjs",
"SessionStart"
],
"timeout": 5
}
]
Expand All @@ -17,7 +23,13 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-lifecycle-hook.mjs\" SessionEnd",
"command": "node",
"args": [
"-e",
"const cp=require('node:child_process'),fs=require('node:fs'),p=require('node:path');let r=process.env.CLAUDE_PLUGIN_ROOT;if(!r){console.error('Codex plugin hook skipped: CLAUDE_PLUGIN_ROOT is not set.');process.exit(0);}if(process.platform==='win32'&&/^\\/[A-Za-z]\\//.test(r))r=r[1]+':/'+r.slice(3);const script=p.join(r,'scripts',process.argv[1]);if(!fs.existsSync(script)){console.error(`Codex plugin hook skipped: ${script} was not found.`);process.exit(0);}const x=cp.spawnSync(process.execPath,[script,...process.argv.slice(2)],{stdio:'inherit',env:process.env,windowsHide:true});if(x.error)throw x.error;process.exit(x.status??1);",
"session-lifecycle-hook.mjs",
"SessionEnd"
],
"timeout": 5
}
]
Expand All @@ -28,7 +40,12 @@
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/stop-review-gate-hook.mjs\"",
"command": "node",
"args": [
"-e",
"const cp=require('node:child_process'),fs=require('node:fs'),p=require('node:path');let r=process.env.CLAUDE_PLUGIN_ROOT;if(!r){console.error('Codex plugin hook skipped: CLAUDE_PLUGIN_ROOT is not set.');process.exit(0);}if(process.platform==='win32'&&/^\\/[A-Za-z]\\//.test(r))r=r[1]+':/'+r.slice(3);const script=p.join(r,'scripts',process.argv[1]);if(!fs.existsSync(script)){console.error(`Codex plugin hook skipped: ${script} was not found.`);process.exit(0);}const x=cp.spawnSync(process.execPath,[script,...process.argv.slice(2)],{stdio:'inherit',env:process.env,windowsHide:true});if(x.error)throw x.error;process.exit(x.status??1);",
"stop-review-gate-hook.mjs"
],
"timeout": 900
}
]
Expand Down
123 changes: 123 additions & 0 deletions tests/hooks-manifest.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const PLUGIN_ROOT = path.join(ROOT, "plugins", "codex");
const HOOKS_MANIFEST = path.join(PLUGIN_ROOT, "hooks", "hooks.json");

function readHooksManifest() {
return JSON.parse(fs.readFileSync(HOOKS_MANIFEST, "utf8"));
}

function singleCommandHook(manifest, eventName) {
const groups = manifest.hooks[eventName] ?? [];
const commandHooks = groups.flatMap((group) => group.hooks ?? []);
assert.equal(commandHooks.length, 1, `${eventName} should define exactly one command hook`);
assert.equal(commandHooks[0].type, "command");
return commandHooks[0];
}

function runHookCommand(commandHook, { env, input = "{}" } = {}) {
return spawnSync(commandHook.command, commandHook.args ?? [], {
cwd: ROOT,
env,
encoding: "utf8",
input,
windowsHide: true
});
}

const EXPECTED_HOOKS = [
{
eventName: "SessionStart",
scriptName: "session-lifecycle-hook.mjs",
scriptArgs: ["SessionStart"],
timeout: 5
},
{
eventName: "SessionEnd",
scriptName: "session-lifecycle-hook.mjs",
scriptArgs: ["SessionEnd"],
timeout: 5
},
{
eventName: "Stop",
scriptName: "stop-review-gate-hook.mjs",
scriptArgs: [],
timeout: 900
}
];

test("hook manifest uses exec form for every Codex hook", () => {
const manifest = readHooksManifest();

for (const expected of EXPECTED_HOOKS) {
const hook = singleCommandHook(manifest, expected.eventName);
assert.equal(hook.command, "node");
assert.equal(hook.timeout, expected.timeout);
assert.equal(hook.args[0], "-e");
assert.equal(hook.args[2], expected.scriptName);
assert.deepEqual(hook.args.slice(3), expected.scriptArgs);
assert.match(hook.args[1], /CLAUDE_PLUGIN_ROOT/);
assert.match(hook.args[1], /spawnSync/);
}
});

test("hook manifest skips cleanly when CLAUDE_PLUGIN_ROOT is missing", () => {
const manifest = readHooksManifest();
const env = { ...process.env };
delete env.CLAUDE_PLUGIN_ROOT;

for (const expected of EXPECTED_HOOKS) {
const hook = singleCommandHook(manifest, expected.eventName);
const result = runHookCommand(hook, { env });

assert.equal(result.status, 0, `${expected.eventName} should fail open`);
assert.match(result.stderr, /CLAUDE_PLUGIN_ROOT is not set/);
assert.doesNotMatch(result.stderr, /Cannot find module/);
}
});

test("hook manifest delegates to bundled scripts when CLAUDE_PLUGIN_ROOT is set", () => {
const manifest = readHooksManifest();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codex-plugin-hooks-"));
const envFile = path.join(tempDir, "claude-env.sh");
const pluginDataDir = path.join(tempDir, "plugin-data");
const transcriptPath = path.join(tempDir, "session.jsonl");
fs.writeFileSync(envFile, "", "utf8");
fs.mkdirSync(pluginDataDir, { recursive: true });

const env = {
...process.env,
CLAUDE_ENV_FILE: envFile,
CLAUDE_PLUGIN_DATA: pluginDataDir,
CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT
};
const input = JSON.stringify({
session_id: "sess-hook-manifest",
transcript_path: transcriptPath,
cwd: tempDir
});

try {
const sessionStart = runHookCommand(singleCommandHook(manifest, "SessionStart"), { env, input });
assert.equal(sessionStart.status, 0, sessionStart.stderr);
assert.equal(
fs.readFileSync(envFile, "utf8"),
`export CODEX_COMPANION_SESSION_ID='sess-hook-manifest'\nexport CODEX_COMPANION_TRANSCRIPT_PATH='${transcriptPath}'\nexport CLAUDE_PLUGIN_DATA='${pluginDataDir}'\n`
);

const sessionEnd = runHookCommand(singleCommandHook(manifest, "SessionEnd"), { env, input });
assert.equal(sessionEnd.status, 0, sessionEnd.stderr);

const stop = runHookCommand(singleCommandHook(manifest, "Stop"), { env, input });
assert.equal(stop.status, 0, stop.stderr);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});