Skip to content
Open
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
10 changes: 10 additions & 0 deletions src/hosts/codex/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,16 @@ async function buildCodexHookCommand(options: CodexHookCommandOptions = {}): Pro
if (!options.local) {
const installedBinaryPath = await resolveInstalledTokenjuicePath();
if (installedBinaryPath) {
try {
const resolvedBinaryPath = await realpath(installedBinaryPath);
if (resolvedBinaryPath.endsWith(".js")) {
// Codex may execute hooks from a non-login environment with a different PATH.
// Pin the interpreter, but retain the launcher so package-manager upgrades stay atomic.
return `${shellQuote(nodePath)} ${shellQuote(installedBinaryPath)} codex-post-tool-use`;
}
} catch {
// Preserve package-manager wrappers when their final target cannot be inspected.
}
return `${shellQuote(installedBinaryPath)} codex-post-tool-use`;
}
}
Expand Down
7 changes: 6 additions & 1 deletion src/hosts/shared/hook-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,12 @@ export function extractHookCommandPaths(command: string, platform = process.plat
}

const second = argv[1];
if (first && second && isNodeExecutablePath(first) && second.endsWith(".js")) {
if (
first
&& second
&& isNodeExecutablePath(first)
&& (second.endsWith(".js") || isTokenjuiceExecutablePath(second))
) {
paths.add(second);
}

Expand Down
91 changes: 91 additions & 0 deletions test/hosts/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,34 @@ describe("installCodexHook", () => {
expect(parsed.hooks.PostToolUse?.[0]?.hooks[0]?.command).toBe(`${launcherPath} codex-post-tool-use`);
});

it("pins the current Node runtime when the installed launcher resolves to JavaScript", async () => {
const home = await createTempDir();
const hooksPath = join(home, "hooks.json");
const binDir = join(home, "bin");
const launcherPath = join(binDir, "tokenjuice");
const wrongNodePath = join(binDir, "node");
const installedCliPath = join(home, "lib", "tokenjuice", "dist", "cli", "main.js");
const nodePath = join(home, "node");

process.env.PATH = binDir;
await mkdir(binDir, { recursive: true });
await mkdir(dirname(installedCliPath), { recursive: true });
await writeFile(wrongNodePath, "#!/usr/bin/env bash\nexit 99\n", { encoding: "utf8", mode: 0o755 });
await writeFile(installedCliPath, "#!/usr/bin/env node\n", { encoding: "utf8", mode: 0o755 });
await writeFile(nodePath, "#!/usr/bin/env bash\nexit 0\n", { encoding: "utf8", mode: 0o755 });
await symlink(installedCliPath, launcherPath);

const result = await installCodexHook(hooksPath, { nodePath });
const parsed = JSON.parse(await readFile(hooksPath, "utf8")) as {
hooks: Record<string, Array<{ hooks: Array<{ command: string }> }>>;
};

expect(result.command).toBe(`${nodePath} ${launcherPath} codex-post-tool-use`);
expect(parsed.hooks.PostToolUse?.[0]?.hooks[0]?.command).toBe(
`${nodePath} ${launcherPath} codex-post-tool-use`,
);
});

it("can install a local codex hook without preferring PATH", async () => {
const home = await createTempDir();
const hooksPath = join(home, "hooks.json");
Expand Down Expand Up @@ -261,6 +289,69 @@ describe("doctorCodexHook", () => {
expect(report.featureFlag.enabled).toBe(true);
});

it("warns for an npm launcher-only hook and accepts the pinned Node command", async () => {
const home = await createTempDir();
const hooksPath = join(home, "hooks.json");
const binDir = join(home, "bin");
const launcherPath = join(binDir, "tokenjuice");
const installedCliPath = join(home, "lib", "tokenjuice", "dist", "cli", "main.js");
const nodePath = join(home, "node");

process.env.PATH = binDir;
await mkdir(binDir, { recursive: true });
await mkdir(dirname(installedCliPath), { recursive: true });
await writeFile(installedCliPath, "#!/usr/bin/env node\n", { encoding: "utf8", mode: 0o755 });
await writeFile(nodePath, "#!/usr/bin/env bash\nexit 0\n", { encoding: "utf8", mode: 0o755 });
await symlink(installedCliPath, launcherPath);
await writeFile(
hooksPath,
`${JSON.stringify({
hooks: {
PostToolUse: [
{
matcher: "^Bash$",
hooks: [
{
type: "command",
command: `${launcherPath} codex-post-tool-use`,
statusMessage: "compacting bash output with tokenjuice",
timeout: 30,
},
],
},
],
},
}, null, 2)}\n`,
"utf8",
);

const staleReport = await doctorCodexHook(hooksPath, { nodePath });

expect(staleReport.status).toBe("warn");
expect(staleReport.detectedCommand).toBe(`${launcherPath} codex-post-tool-use`);
expect(staleReport.expectedCommand).toBe(`${nodePath} ${launcherPath} codex-post-tool-use`);
expect(staleReport.issues).toContain(
"configured Codex hook command does not match the current recommended command",
);

await installCodexHook(hooksPath, { nodePath });
const currentReport = await doctorCodexHook(hooksPath, { nodePath });

expect(currentReport.status).toBe("ok");
expect(currentReport.detectedCommand).toBe(`${nodePath} ${launcherPath} codex-post-tool-use`);
expect(currentReport.issues).toEqual([]);

await rm(launcherPath);
const missingLauncherReport = await doctorCodexHook(hooksPath, {
binaryPath: installedCliPath,
nodePath,
});

expect(missingLauncherReport.status).toBe("broken");
expect(missingLauncherReport.checkedPaths).toContain(launcherPath);
expect(missingLauncherReport.missingPaths).toContain(launcherPath);
});

it("warns when the stable launcher resolves to an older Homebrew tokenjuice version", async () => {
const home = await createTempDir();
const hooksPath = join(home, "hooks.json");
Expand Down
11 changes: 11 additions & 0 deletions test/hosts/shared/hook-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ describe("parseShellWords", () => {
});

describe("extractHookCommandPaths", () => {
it("extracts both Node and a stable tokenjuice launcher from pinned commands", () => {
expect(
extractHookCommandPaths(
"/opt/node/bin/node /usr/local/bin/tokenjuice codex-post-tool-use --no-omit",
),
).toEqual([
"/opt/node/bin/node",
"/usr/local/bin/tokenjuice",
]);
});

it("extracts Windows launcher and script paths from quoted node commands", () => {
const nodePath = String.raw`C:\Program Files\nodejs\node.exe`;
const scriptPath = String.raw`C:\Users\andre\OneDrive\Documents\Github\tokenjuice\dist\cli\main.js`;
Expand Down