From c8d814ce4388f54f1944bb2f71433da989a69a47 Mon Sep 17 00:00:00 2001 From: Mason Huang <8814856+hxy91819@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:03:14 +0800 Subject: [PATCH 1/2] fix(codex): pin Node runtime for npm launcher hooks --- src/hosts/codex/index.ts | 10 ++++ src/hosts/shared/hook-command.ts | 7 ++- test/hosts/codex.test.ts | 91 ++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/hosts/codex/index.ts b/src/hosts/codex/index.ts index 77c4abc6..e0a0d214 100644 --- a/src/hosts/codex/index.ts +++ b/src/hosts/codex/index.ts @@ -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`; } } diff --git a/src/hosts/shared/hook-command.ts b/src/hosts/shared/hook-command.ts index cec18c2c..5e733e60 100644 --- a/src/hosts/shared/hook-command.ts +++ b/src/hosts/shared/hook-command.ts @@ -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); } diff --git a/test/hosts/codex.test.ts b/test/hosts/codex.test.ts index d6a3643a..79b66a7e 100644 --- a/test/hosts/codex.test.ts +++ b/test/hosts/codex.test.ts @@ -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 }>>; + }; + + 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"); @@ -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"); From 4798b96366ee68049ecc752823fde90383352a53 Mon Sep 17 00:00:00 2001 From: masonxhuang Date: Fri, 31 Jul 2026 15:33:32 +0800 Subject: [PATCH 2/2] test(codex): cover pinned launcher path checks --- test/hosts/shared/hook-command.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/hosts/shared/hook-command.test.ts b/test/hosts/shared/hook-command.test.ts index c2391842..d769abc1 100644 --- a/test/hosts/shared/hook-command.test.ts +++ b/test/hosts/shared/hook-command.test.ts @@ -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`;