From 8f09814f4bf4bcc0cced83988fd6836fd72767d1 Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Fri, 4 Sep 2026 22:25:47 -0300 Subject: [PATCH 01/13] fix: resolve Node outside minimal hook PATH --- plugins/codex/commands/cancel.md | 4 +-- plugins/codex/commands/result.md | 4 +-- plugins/codex/commands/status.md | 4 +-- plugins/codex/hooks/hooks.json | 6 ++-- plugins/codex/scripts/run-node.sh | 46 +++++++++++++++++++++++++++++++ tests/commands.test.mjs | 13 +++++++++ tests/node-launcher.test.mjs | 33 ++++++++++++++++++++++ 7 files changed, 101 insertions(+), 9 deletions(-) create mode 100644 plugins/codex/scripts/run-node.sh create mode 100644 tests/node-launcher.test.mjs diff --git a/plugins/codex/commands/cancel.md b/plugins/codex/commands/cancel.md index a1472b836..f596bfa2d 100644 --- a/plugins/codex/commands/cancel.md +++ b/plugins/codex/commands/cancel.md @@ -2,7 +2,7 @@ description: Cancel an active background Codex job in this repository argument-hint: '[job-id]' disable-model-invocation: true -allowed-tools: Bash(node:*) +allowed-tools: Bash(bash:*) --- -!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" cancel "$ARGUMENTS"` +!`bash "$(if command -v cygpath >/dev/null 2>&1; then cygpath -u "${CLAUDE_PLUGIN_ROOT}"; else printf '%s' "${CLAUDE_PLUGIN_ROOT}"; fi)/scripts/run-node.sh" "codex-companion.mjs" cancel "$ARGUMENTS"` diff --git a/plugins/codex/commands/result.md b/plugins/codex/commands/result.md index 3abc2d931..799923dfc 100644 --- a/plugins/codex/commands/result.md +++ b/plugins/codex/commands/result.md @@ -2,10 +2,10 @@ description: Show the stored final output for a finished Codex job in this repository argument-hint: '[job-id]' disable-model-invocation: true -allowed-tools: Bash(node:*) +allowed-tools: Bash(bash:*) --- -!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" result "$ARGUMENTS"` +!`bash "$(if command -v cygpath >/dev/null 2>&1; then cygpath -u "${CLAUDE_PLUGIN_ROOT}"; else printf '%s' "${CLAUDE_PLUGIN_ROOT}"; fi)/scripts/run-node.sh" "codex-companion.mjs" result "$ARGUMENTS"` Present the full command output to the user. Do not summarize or condense it. Preserve all details including: - Job ID and status diff --git a/plugins/codex/commands/status.md b/plugins/codex/commands/status.md index 8f70663d1..17536358a 100644 --- a/plugins/codex/commands/status.md +++ b/plugins/codex/commands/status.md @@ -2,10 +2,10 @@ description: Show active and recent Codex jobs for this repository, including review-gate status argument-hint: '[job-id] [--wait] [--timeout-ms ] [--all]' disable-model-invocation: true -allowed-tools: Bash(node:*) +allowed-tools: Bash(bash:*) --- -!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" status "$ARGUMENTS"` +!`bash "$(if command -v cygpath >/dev/null 2>&1; then cygpath -u "${CLAUDE_PLUGIN_ROOT}"; else printf '%s' "${CLAUDE_PLUGIN_ROOT}"; fi)/scripts/run-node.sh" "codex-companion.mjs" status "$ARGUMENTS"` If the user did not pass a job ID: - Render the command output as a single Markdown table for the current and past runs in this session. diff --git a/plugins/codex/hooks/hooks.json b/plugins/codex/hooks/hooks.json index 19e33b818..09db8a3ee 100644 --- a/plugins/codex/hooks/hooks.json +++ b/plugins/codex/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-lifecycle-hook.mjs\" SessionStart", + "command": "bash \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PLUGIN_ROOT}\"; else printf '%s' \"${CLAUDE_PLUGIN_ROOT}\"; fi)/scripts/run-node.sh\" \"session-lifecycle-hook.mjs\" SessionStart", "timeout": 5 } ] @@ -17,7 +17,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/session-lifecycle-hook.mjs\" SessionEnd", + "command": "bash \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PLUGIN_ROOT}\"; else printf '%s' \"${CLAUDE_PLUGIN_ROOT}\"; fi)/scripts/run-node.sh\" \"session-lifecycle-hook.mjs\" SessionEnd", "timeout": 5 } ] @@ -28,7 +28,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/stop-review-gate-hook.mjs\"", + "command": "bash \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PLUGIN_ROOT}\"; else printf '%s' \"${CLAUDE_PLUGIN_ROOT}\"; fi)/scripts/run-node.sh\" \"stop-review-gate-hook.mjs\"", "timeout": 900 } ] diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh new file mode 100644 index 000000000..5ebe9ab93 --- /dev/null +++ b/plugins/codex/scripts/run-node.sh @@ -0,0 +1,46 @@ +#!/bin/sh +set -u + +target=${1:-} +if [ -z "$target" ]; then + echo "Codex Companion Node launcher requires a script name." >&2 + exit 64 +fi +shift + +case "$0" in + */*) script_base=${0%/*} ;; + *) script_base=. ;; +esac +script_dir=$(CDPATH= cd -- "$script_base" && pwd) + +find_node() { + if [ -n "${CODEX_COMPANION_NODE:-}" ] && [ -x "$CODEX_COMPANION_NODE" ]; then + printf '%s\n' "$CODEX_COMPANION_NODE" + return 0 + fi + if command -v node >/dev/null 2>&1; then + command -v node + return 0 + fi + home=${HOME:-} + for candidate in \ + /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node \ + "$home/.volta/bin/node" \ + "$home"/.nvm/versions/node/*/bin/node \ + "$home"/.local/share/fnm/node-versions/*/installation/bin/node \ + "$home"/.asdf/installs/nodejs/*/bin/node \ + "$home"/.local/share/mise/installs/node/*/bin/node; do + [ -x "$candidate" ] || continue + printf '%s\n' "$candidate" + return 0 + done + return 1 +} + +node_bin=$(find_node) || { + echo "Codex Companion requires Node.js >=18.18. Add node to PATH or set CODEX_COMPANION_NODE to its executable path." >&2 + exit 127 +} + +exec "$node_bin" "$script_dir/$target" "$@" diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index c34b06059..7da82b686 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -202,6 +202,19 @@ test("internal docs use task terminology for rescue runs", () => { assert.match(promptRecipes, /## Narrow Fix/); }); +test("hooks and deterministic commands use the portable Node launcher", () => { + const hooks = read("hooks/hooks.json"); + for (const relative of ["commands/status.md", "commands/result.md", "commands/cancel.md"]) { + const source = read(relative); + assert.match(source, /scripts\/run-node\.sh/); + assert.match(source, /cygpath/); + assert.doesNotMatch(source, /!`node "/); + } + assert.match(hooks, /scripts\/run-node\.sh/); + assert.match(hooks, /cygpath/); + assert.doesNotMatch(hooks, /"command": "node /); +}); + test("hooks keep session-end cleanup and stop gating enabled", () => { const source = read("hooks/hooks.json"); assert.match(source, /SessionStart/); diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs new file mode 100644 index 000000000..c03526ded --- /dev/null +++ b/tests/node-launcher.test.mjs @@ -0,0 +1,33 @@ +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 LAUNCHER = path.join(ROOT, "plugins", "codex", "scripts", "run-node.sh"); +const BASH = process.env.SHELL || (process.platform === "win32" ? "C:\\Program Files\\Git\\bin\\bash.exe" : "/bin/bash"); + +test("portable launcher finds an nvm Node when PATH does not contain node", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-launcher-")); + const nodePath = path.join(home, ".nvm", "versions", "node", "v22.0.0", "bin", "node"); + fs.mkdirSync(path.dirname(nodePath), { recursive: true }); + fs.writeFileSync(nodePath, "#!/bin/sh\nprintf 'FAKE_NODE:%s\\n' \"$*\"\n", "utf8"); + fs.chmodSync(nodePath, 0o755); + + const emptyBin = path.join(home, "empty-bin"); + fs.mkdirSync(emptyBin); + + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /FAKE_NODE:.*[\\/]companion\.mjs status --json\n$/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); From 31b8b184dad8c9f5a4fdaca16e8827b9a1a0f66c Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Fri, 4 Sep 2026 22:36:59 -0300 Subject: [PATCH 02/13] fix: preserve compatible Node toolchain PATH --- plugins/codex/scripts/run-node.sh | 23 +++++++++++++--- tests/node-launcher.test.mjs | 45 +++++++++++++++++++++++-------- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh index 5ebe9ab93..3bf67e044 100644 --- a/plugins/codex/scripts/run-node.sh +++ b/plugins/codex/scripts/run-node.sh @@ -14,14 +14,21 @@ case "$0" in esac script_dir=$(CDPATH= cd -- "$script_base" && pwd) +is_supported_node() { + "$1" -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 18 || (major === 18 && minor >= 18) ? 0 : 1)' >/dev/null 2>&1 +} + find_node() { - if [ -n "${CODEX_COMPANION_NODE:-}" ] && [ -x "$CODEX_COMPANION_NODE" ]; then + if [ -n "${CODEX_COMPANION_NODE:-}" ] && [ -x "$CODEX_COMPANION_NODE" ] && is_supported_node "$CODEX_COMPANION_NODE"; then printf '%s\n' "$CODEX_COMPANION_NODE" return 0 fi if command -v node >/dev/null 2>&1; then - command -v node - return 0 + candidate=$(command -v node) + if is_supported_node "$candidate"; then + printf '%s\n' "$candidate" + return 0 + fi fi home=${HOME:-} for candidate in \ @@ -32,6 +39,7 @@ find_node() { "$home"/.asdf/installs/nodejs/*/bin/node \ "$home"/.local/share/mise/installs/node/*/bin/node; do [ -x "$candidate" ] || continue + is_supported_node "$candidate" || continue printf '%s\n' "$candidate" return 0 done @@ -39,8 +47,15 @@ find_node() { } node_bin=$(find_node) || { - echo "Codex Companion requires Node.js >=18.18. Add node to PATH or set CODEX_COMPANION_NODE to its executable path." >&2 + echo "Codex Companion requires Node.js >=18.18. Add a supported node to PATH or set CODEX_COMPANION_NODE to its executable path." >&2 exit 127 } +case "$node_bin" in + */*) node_dir=${node_bin%/*} ;; + *) node_dir=. ;; +esac +PATH="$node_dir${PATH:+:$PATH}" +export PATH + exec "$node_bin" "$script_dir/$target" "$@" diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index c03526ded..89c1c59c5 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -10,23 +10,46 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const LAUNCHER = path.join(ROOT, "plugins", "codex", "scripts", "run-node.sh"); const BASH = process.env.SHELL || (process.platform === "win32" ? "C:\\Program Files\\Git\\bin\\bash.exe" : "/bin/bash"); -test("portable launcher finds an nvm Node when PATH does not contain node", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-launcher-")); - const nodePath = path.join(home, ".nvm", "versions", "node", "v22.0.0", "bin", "node"); - fs.mkdirSync(path.dirname(nodePath), { recursive: true }); - fs.writeFileSync(nodePath, "#!/bin/sh\nprintf 'FAKE_NODE:%s\\n' \"$*\"\n", "utf8"); +function installFakeNode(home, version, supported) { + const binDir = path.join(home, ".nvm", "versions", "node", version, "bin"); + const nodePath = path.join(binDir, "node"); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(nodePath, `#!/bin/sh\nif [ "\${1:-}" = "-e" ]; then exit ${supported ? 0 : 1}; fi\nprintf 'FAKE_NODE_${version}:%s\\n' "$*"\nprintf 'CODEX:%s\\n' "$(command -v codex || true)"\n`, "utf8"); fs.chmodSync(nodePath, 0o755); + return binDir; +} +function runWithMinimalPath(home) { const emptyBin = path.join(home, "empty-bin"); - fs.mkdirSync(emptyBin); + fs.mkdirSync(emptyBin, { recursive: true }); + return spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + }); +} +test("portable launcher preserves the selected Node toolchain directory on PATH", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-path-")); + const binDir = installFakeNode(home, "v22.0.0", true); + const codexPath = path.join(binDir, "codex"); + fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(codexPath, 0o755); try { - const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { - encoding: "utf8", - env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } - }); + const result = runWithMinimalPath(home); assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /FAKE_NODE:.*[\\/]companion\.mjs status --json\n$/); + assert.match(result.stdout, /CODEX:.+[\\/]codex\n$/m); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("portable launcher skips unsupported Node versions", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-version-")); + installFakeNode(home, "v12.22.0", false); + installFakeNode(home, "v22.0.0", true); + try { const result = runWithMinimalPath(home); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /FAKE_NODE_v22\.0\.0:/); + assert.doesNotMatch(result.stdout, /FAKE_NODE_v12\.22\.0:/); } finally { fs.rmSync(home, { recursive: true, force: true }); } From a17c3dafe445bd55ef484ce60810a19c8402bfbc Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Fri, 4 Sep 2026 22:37:20 -0300 Subject: [PATCH 03/13] test: tidy Node launcher regressions --- tests/node-launcher.test.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index 89c1c59c5..a35456d53 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -23,7 +23,8 @@ function runWithMinimalPath(home) { const emptyBin = path.join(home, "empty-bin"); fs.mkdirSync(emptyBin, { recursive: true }); return spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { - encoding: "utf8", env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + encoding: "utf8", + env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } }); } @@ -46,7 +47,8 @@ test("portable launcher skips unsupported Node versions", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-version-")); installFakeNode(home, "v12.22.0", false); installFakeNode(home, "v22.0.0", true); - try { const result = runWithMinimalPath(home); + try { + const result = runWithMinimalPath(home); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /FAKE_NODE_v22\.0\.0:/); assert.doesNotMatch(result.stdout, /FAKE_NODE_v12\.22\.0:/); From cc0ffadc0e565950196a78422ea89438ae8881e8 Mon Sep 17 00:00:00 2001 From: Verso Labs Date: Fri, 4 Sep 2026 23:10:31 -0300 Subject: [PATCH 04/13] fix: preserve alternate Codex toolchain paths --- plugins/codex/scripts/run-node.sh | 17 +++++++++++++---- tests/node-launcher.test.mjs | 26 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh index 3bf67e044..ba57d98f2 100644 --- a/plugins/codex/scripts/run-node.sh +++ b/plugins/codex/scripts/run-node.sh @@ -51,11 +51,20 @@ node_bin=$(find_node) || { exit 127 } -case "$node_bin" in - */*) node_dir=${node_bin%/*} ;; - *) node_dir=. ;; -esac +add_supported_node_dir_to_path() { + candidate=$1 + [ -x "$candidate" ] || return 0 + is_supported_node "$candidate" || return 0 + case "$candidate" in */*) candidate_dir=${candidate%/*} ;; *) candidate_dir=. ;; esac + case ":$PATH:" in *":$candidate_dir:"*) ;; *) PATH="$PATH:$candidate_dir" ;; esac +} + +case "$node_bin" in */*) node_dir=${node_bin%/*} ;; *) node_dir=. ;; esac PATH="$node_dir${PATH:+:$PATH}" +home=${HOME:-} +for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$home"/.nvm/versions/node/*/bin/node "$home"/.local/share/fnm/node-versions/*/installation/bin/node "$home"/.asdf/installs/nodejs/*/bin/node "$home"/.local/share/mise/installs/node/*/bin/node; do + add_supported_node_dir_to_path "$candidate" +done export PATH exec "$node_bin" "$script_dir/$target" "$@" diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index a35456d53..d44ec746b 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -56,3 +56,29 @@ test("portable launcher skips unsupported Node versions", () => { fs.rmSync(home, { recursive: true, force: true }); } }); + +test("portable launcher keeps searching compatible toolchains for codex", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-mixed-path-")); + const systemBin = path.join(home, "system-bin"); + fs.mkdirSync(systemBin, { recursive: true }); + const systemNode = path.join(systemBin, "node"); + fs.writeFileSync(systemNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf \'SYSTEM_NODE:%s\\n\' "$*"\nprintf \'CODEX:%s\\n\' "$(command -v codex || true)"\n', "utf8"); + fs.chmodSync(systemNode, 0o755); + + const managedBin = installFakeNode(home, "v22.0.0", true); + const codexPath = path.join(managedBin, "codex"); + fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(codexPath, 0o755); + + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /SYSTEM_NODE:/); + assert.match(result.stdout, /CODEX:.+[\\/]codex\n$/m); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); From ef58bf15973134f3da7066bef9f84a037c3361ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Federico=20=7C=20RUMBO=20IA?= <293577326+fscfede-beep@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:59:39 -0300 Subject: [PATCH 05/13] fix: discover Windows Node installs in launcher --- plugins/codex/scripts/run-node.sh | 81 ++++++++++++++++++++++++++++++- tests/node-launcher.test.mjs | 31 +++++++++++- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh index ba57d98f2..28b50e934 100644 --- a/plugins/codex/scripts/run-node.sh +++ b/plugins/codex/scripts/run-node.sh @@ -18,6 +18,58 @@ is_supported_node() { "$1" -e 'const [major, minor] = process.versions.node.split(".").map(Number); process.exit(major > 18 || (major === 18 && minor >= 18) ? 0 : 1)' >/dev/null 2>&1 } +windows_path_to_posix() { + value=$1 + [ -n "$value" ] || return 1 + if command -v cygpath >/dev/null 2>&1; then + cygpath -u "$value" + else + printf '%s\n' "$value" + fi +} + +find_windows_node() { + root= + if [ -n "${NVM_SYMLINK:-}" ]; then + root=$(windows_path_to_posix "$NVM_SYMLINK") || root= + for candidate in "$root/node.exe" "$root/node"; do + [ -x "$candidate" ] || continue + is_supported_node "$candidate" || continue + printf '%s\n' "$candidate" + return 0 + done + fi + if [ -n "${VOLTA_HOME:-}" ]; then + root=$(windows_path_to_posix "$VOLTA_HOME") || root= + for candidate in "$root/bin/node.exe" "$root/bin/node"; do + [ -x "$candidate" ] || continue + is_supported_node "$candidate" || continue + printf '%s\n' "$candidate" + return 0 + done + fi + if [ -n "${LOCALAPPDATA:-}" ]; then + root=$(windows_path_to_posix "$LOCALAPPDATA") || root= + for candidate in "$root/Volta/bin/node.exe" "$root/Volta/bin/node"; do + [ -x "$candidate" ] || continue + is_supported_node "$candidate" || continue + printf '%s\n' "$candidate" + return 0 + done + fi + program_files=${PROGRAMFILES:-${PROGRAMW6432:-${ProgramFiles:-}}} + if [ -n "$program_files" ]; then + root=$(windows_path_to_posix "$program_files") || root= + for candidate in "$root/nodejs/node.exe" "$root/nodejs/node"; do + [ -x "$candidate" ] || continue + is_supported_node "$candidate" || continue + printf '%s\n' "$candidate" + return 0 + done + fi + return 1 +} + find_node() { if [ -n "${CODEX_COMPANION_NODE:-}" ] && [ -x "$CODEX_COMPANION_NODE" ] && is_supported_node "$CODEX_COMPANION_NODE"; then printf '%s\n' "$CODEX_COMPANION_NODE" @@ -43,6 +95,7 @@ find_node() { printf '%s\n' "$candidate" return 0 done + find_windows_node && return 0 return 1 } @@ -56,7 +109,32 @@ add_supported_node_dir_to_path() { [ -x "$candidate" ] || return 0 is_supported_node "$candidate" || return 0 case "$candidate" in */*) candidate_dir=${candidate%/*} ;; *) candidate_dir=. ;; esac - case ":$PATH:" in *":$candidate_dir:"*) ;; *) PATH="$PATH:$candidate_dir" ;; esac + case ":${PATH:-}:" in *":$candidate_dir:"*) ;; *) PATH="${PATH:-}${PATH:+:}$candidate_dir" ;; esac +} + +add_windows_node_dirs_to_path() { + root= + if [ -n "${NVM_SYMLINK:-}" ]; then + root=$(windows_path_to_posix "$NVM_SYMLINK") || root= + [ -n "$root" ] && add_supported_node_dir_to_path "$root/node.exe" + [ -n "$root" ] && add_supported_node_dir_to_path "$root/node" + fi + if [ -n "${VOLTA_HOME:-}" ]; then + root=$(windows_path_to_posix "$VOLTA_HOME") || root= + [ -n "$root" ] && add_supported_node_dir_to_path "$root/bin/node.exe" + [ -n "$root" ] && add_supported_node_dir_to_path "$root/bin/node" + fi + if [ -n "${LOCALAPPDATA:-}" ]; then + root=$(windows_path_to_posix "$LOCALAPPDATA") || root= + [ -n "$root" ] && add_supported_node_dir_to_path "$root/Volta/bin/node.exe" + [ -n "$root" ] && add_supported_node_dir_to_path "$root/Volta/bin/node" + fi + program_files=${PROGRAMFILES:-${PROGRAMW6432:-${ProgramFiles:-}}} + if [ -n "$program_files" ]; then + root=$(windows_path_to_posix "$program_files") || root= + [ -n "$root" ] && add_supported_node_dir_to_path "$root/nodejs/node.exe" + [ -n "$root" ] && add_supported_node_dir_to_path "$root/nodejs/node" + fi } case "$node_bin" in */*) node_dir=${node_bin%/*} ;; *) node_dir=. ;; esac @@ -65,6 +143,7 @@ home=${HOME:-} for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$home"/.nvm/versions/node/*/bin/node "$home"/.local/share/fnm/node-versions/*/installation/bin/node "$home"/.asdf/installs/nodejs/*/bin/node "$home"/.local/share/mise/installs/node/*/bin/node; do add_supported_node_dir_to_path "$candidate" done +add_windows_node_dirs_to_path export PATH exec "$node_bin" "$script_dir/$target" "$@" diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index d44ec746b..b5cd0935e 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -1,4 +1,4 @@ -import fs from "node:fs"; +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -82,3 +82,32 @@ test("portable launcher keeps searching compatible toolchains for codex", () => fs.rmSync(home, { recursive: true, force: true }); } }); + +// Regression for review: minimal Git Bash PATH must still discover a normal Windows Node install. +test("portable launcher discovers Windows Node install roots under Git Bash", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-windows-")); + const emptyBin = path.join(home, "empty-bin"); + const programFiles = path.join(home, "Program Files"); + const nodeDir = path.join(programFiles, "nodejs"); + fs.mkdirSync(emptyBin, { recursive: true }); + fs.mkdirSync(nodeDir, { recursive: true }); + const cygpath = path.join(emptyBin, "cygpath"); + fs.writeFileSync(cygpath, '#!/bin/sh\nif [ "${1:-}" = "-u" ]; then shift; fi\nprintf "%s\\n" "$1"\n', "utf8"); + fs.chmodSync(cygpath, 0o755); + const nodePath = path.join(nodeDir, "node.exe"); + fs.copyFileSync(process.execPath, nodePath); + const probeName = `windows-node-probe-${process.pid}.mjs`; + const probePath = path.join(path.dirname(LAUNCHER), probeName); + fs.writeFileSync(probePath, 'console.log("WINDOWS_NODE")\n', "utf8"); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), probeName], { + encoding: "utf8", + env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), ProgramFiles: programFiles.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "", NVM_SYMLINK: "", VOLTA_HOME: "", LOCALAPPDATA: "" } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /WINDOWS_NODE/); + } finally { + fs.rmSync(probePath, { force: true }); + fs.rmSync(home, { recursive: true, force: true }); + } +}); From 1338edef04413adccf86f89eb884a4177ca08d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Federico=20=7C=20RUMBO=20IA?= <293577326+fscfede-beep@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:23:37 -0300 Subject: [PATCH 06/13] fix: complete portable launcher coverage --- plugins/codex/commands/transfer.md | 4 +- plugins/codex/scripts/run-node.sh | 18 +++++++++ tests/commands.test.mjs | 2 +- tests/node-launcher.test.mjs | 63 ++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/plugins/codex/commands/transfer.md b/plugins/codex/commands/transfer.md index 42170e51d..027f9b78c 100644 --- a/plugins/codex/commands/transfer.md +++ b/plugins/codex/commands/transfer.md @@ -2,9 +2,9 @@ description: Transfer the current Claude Code session into a resumable Codex thread argument-hint: "[--source ]" disable-model-invocation: true -allowed-tools: Bash(node:*) +allowed-tools: Bash(bash:*) --- -!`node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" transfer "$ARGUMENTS"` +!`bash "$(if command -v cygpath >/dev/null 2>&1; then cygpath -u "${CLAUDE_PLUGIN_ROOT}"; else printf '%s' "${CLAUDE_PLUGIN_ROOT}"; fi)/scripts/run-node.sh" "codex-companion.mjs" transfer "$ARGUMENTS"` Present the command output to the user exactly as returned. Preserve the Codex session ID and the `codex resume ` command. diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh index 28b50e934..05c7ebabc 100644 --- a/plugins/codex/scripts/run-node.sh +++ b/plugins/codex/scripts/run-node.sh @@ -112,6 +112,23 @@ add_supported_node_dir_to_path() { case ":${PATH:-}:" in *":$candidate_dir:"*) ;; *) PATH="${PATH:-}${PATH:+:}$candidate_dir" ;; esac } +add_dir_to_path() { + candidate_dir=$1 + [ -d "$candidate_dir" ] || return 0 + case ":${PATH:-}:" in *":$candidate_dir:"*) ;; *) PATH="${PATH:-}${PATH:+:}$candidate_dir" ;; esac +} + +add_npm_prefix_dirs_to_path() { + prefix=${NPM_CONFIG_PREFIX:-} + if [ -z "$prefix" ] && command -v npm >/dev/null 2>&1; then + prefix=$(npm prefix -g 2>/dev/null) || prefix= + fi + [ -n "$prefix" ] || return 0 + prefix=$(windows_path_to_posix "$prefix") || return 0 + add_dir_to_path "$prefix/bin" + add_dir_to_path "$prefix" +} + add_windows_node_dirs_to_path() { root= if [ -n "${NVM_SYMLINK:-}" ]; then @@ -144,6 +161,7 @@ for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node add_supported_node_dir_to_path "$candidate" done add_windows_node_dirs_to_path +add_npm_prefix_dirs_to_path export PATH exec "$node_bin" "$script_dir/$target" "$@" diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 7da82b686..04bc4c76d 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -204,7 +204,7 @@ test("internal docs use task terminology for rescue runs", () => { test("hooks and deterministic commands use the portable Node launcher", () => { const hooks = read("hooks/hooks.json"); - for (const relative of ["commands/status.md", "commands/result.md", "commands/cancel.md"]) { + for (const relative of ["commands/status.md", "commands/result.md", "commands/cancel.md", "commands/transfer.md"]) { const source = read(relative); assert.match(source, /scripts\/run-node\.sh/); assert.match(source, /cygpath/); diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index b5cd0935e..48e699e65 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -111,3 +111,66 @@ test("portable launcher discovers Windows Node install roots under Git Bash", () fs.rmSync(home, { recursive: true, force: true }); } }); + + +test("portable launcher restores a configured npm global prefix for codex", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-npm-prefix-")); + const nodeBin = installFakeNode(home, "v22.0.0", true); + const prefix = path.join(home, ".npm-global"); + const prefixBin = path.join(prefix, "bin"); + fs.mkdirSync(prefixBin, { recursive: true }); + const codexPath = path.join(prefixBin, "codex"); + fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(codexPath, 0o755); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { + ...process.env, + HOME: home.replaceAll("\\", "/"), + PATH: nodeBin.replaceAll("\\", "/"), + NPM_CONFIG_PREFIX: prefix.replaceAll("\\", "/"), + CODEX_COMPANION_NODE: "" + } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /CODEX:.+[\\/]\.npm-global[\\/]bin[\\/]codex\n$/m); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + + +test("portable launcher restores npm prefix reported by npm config", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-npm-config-")); + const nodeBin = installFakeNode(home, "v22.0.0", true); + const prefix = path.join(home, ".npm-configured"); + const prefixBin = path.join(prefix, "bin"); + fs.mkdirSync(prefixBin, { recursive: true }); + const codexPath = path.join(prefixBin, "codex"); + fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(codexPath, 0o755); + const npmPath = path.join(nodeBin, "npm"); + fs.writeFileSync( + npmPath, + `#!/bin/sh\nif [ "\${1:-}" = "prefix" ] && [ "\${2:-}" = "-g" ]; then printf '%s\\n' "${prefix.replaceAll("\\", "/")}"; exit 0; fi\nexit 1\n`, + "utf8" + ); + fs.chmodSync(npmPath, 0o755); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { + ...process.env, + HOME: home.replaceAll("\\", "/"), + PATH: nodeBin.replaceAll("\\", "/"), + NPM_CONFIG_PREFIX: "", + CODEX_COMPANION_NODE: "" + } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /CODEX:.+[\\/]\.npm-configured[\\/]bin[\\/]codex\n$/m); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); From a6855f39924831a7119f70aba400e43712bff898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Federico=20=7C=20RUMBO=20IA?= <293577326+fscfede-beep@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:32:56 -0300 Subject: [PATCH 07/13] fix: honor custom NVM_DIR in launcher --- plugins/codex/scripts/run-node.sh | 28 ++++++++++++++--- tests/node-launcher.test.mjs | 52 +++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh index 05c7ebabc..1a76dd072 100644 --- a/plugins/codex/scripts/run-node.sh +++ b/plugins/codex/scripts/run-node.sh @@ -23,9 +23,27 @@ windows_path_to_posix() { [ -n "$value" ] || return 1 if command -v cygpath >/dev/null 2>&1; then cygpath -u "$value" - else - printf '%s\n' "$value" + return fi + + normalized= + remaining=$value + while [ -n "$remaining" ]; do + char=${remaining%"${remaining#?}"} + remaining=${remaining#?} + case "$char" in + \\) normalized="${normalized}/" ;; + *) normalized="${normalized}${char}" ;; + esac + done + case "$normalized" in + [A-Za-z]:/*) + drive=${normalized%%:*} + rest=${normalized#?:} + printf '/%s%s\n' "$drive" "$rest" + ;; + *) printf '%s\n' "$normalized" ;; + esac } find_windows_node() { @@ -83,10 +101,11 @@ find_node() { fi fi home=${HOME:-} + nvm_dir=$(windows_path_to_posix "${NVM_DIR:-$home/.nvm}") || nvm_dir= for candidate in \ /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node \ "$home/.volta/bin/node" \ - "$home"/.nvm/versions/node/*/bin/node \ + "$nvm_dir"/versions/node/*/bin/node \ "$home"/.local/share/fnm/node-versions/*/installation/bin/node \ "$home"/.asdf/installs/nodejs/*/bin/node \ "$home"/.local/share/mise/installs/node/*/bin/node; do @@ -157,7 +176,8 @@ add_windows_node_dirs_to_path() { case "$node_bin" in */*) node_dir=${node_bin%/*} ;; *) node_dir=. ;; esac PATH="$node_dir${PATH:+:$PATH}" home=${HOME:-} -for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$home"/.nvm/versions/node/*/bin/node "$home"/.local/share/fnm/node-versions/*/installation/bin/node "$home"/.asdf/installs/nodejs/*/bin/node "$home"/.local/share/mise/installs/node/*/bin/node; do +nvm_dir=$(windows_path_to_posix "${NVM_DIR:-$home/.nvm}") || nvm_dir= +for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$nvm_dir"/versions/node/*/bin/node "$home"/.local/share/fnm/node-versions/*/installation/bin/node "$home"/.asdf/installs/nodejs/*/bin/node "$home"/.local/share/mise/installs/node/*/bin/node; do add_supported_node_dir_to_path "$candidate" done add_windows_node_dirs_to_path diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index 48e699e65..4e4b500c8 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -174,3 +174,55 @@ test("portable launcher restores npm prefix reported by npm config", () => { fs.rmSync(home, { recursive: true, force: true }); } }); + + +test("portable launcher discovers Node from a custom NVM_DIR", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-custom-nvm-")); + const emptyBin = path.join(home, "empty-bin"); + const nvmDir = path.join(home, "custom-nvm"); + const binDir = path.join(nvmDir, "versions", "node", "v22.0.0", "bin"); + fs.mkdirSync(emptyBin, { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + const nodePath = path.join(binDir, "node"); + fs.writeFileSync(nodePath, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "CUSTOM_NVM_NODE:%s\\n" "$*"\n', "utf8"); + fs.chmodSync(nodePath, 0o755); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), NVM_DIR: nvmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "", NVM_SYMLINK: "", VOLTA_HOME: "", LOCALAPPDATA: "", ProgramFiles: "", PROGRAMFILES: "", PROGRAMW6432: "" } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /CUSTOM_NVM_NODE:/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("portable launcher enriches PATH from a custom NVM_DIR", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-custom-nvm-path-")); + const systemBin = path.join(home, "system-bin"); + const nvmDir = path.join(home, "custom-nvm"); + const managedBin = path.join(nvmDir, "versions", "node", "v22.0.0", "bin"); + fs.mkdirSync(systemBin, { recursive: true }); + fs.mkdirSync(managedBin, { recursive: true }); + const systemNode = path.join(systemBin, "node"); + fs.writeFileSync(systemNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "SYSTEM_NODE:%s\\n" "$*"\nprintf "CODEX:%s\\n" "$(command -v codex || true)"\n', "utf8"); + fs.chmodSync(systemNode, 0o755); + const managedNode = path.join(managedBin, "node"); + fs.writeFileSync(managedNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nexit 0\n', "utf8"); + fs.chmodSync(managedNode, 0o755); + const codexPath = path.join(managedBin, "codex"); + fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(codexPath, 0o755); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), NVM_DIR: nvmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /SYSTEM_NODE:/); + assert.match(result.stdout, /CODEX:.+[\\/]custom-nvm[\\/]versions[\\/]node[\\/]v22\.0\.0[\\/]bin[\\/]codex\n$/m); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); From 64dcef92723eea9287f4757a98470dbb3b51b1cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Federico=20=7C=20RUMBO=20IA?= <293577326+fscfede-beep@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:57:05 -0300 Subject: [PATCH 08/13] fix: align launcher node with codex toolchain --- plugins/codex/scripts/run-node.sh | 56 ++++++++++++++++++++++++------- tests/node-launcher.test.mjs | 27 +++++++++++++-- 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh index 1a76dd072..7394b50d2 100644 --- a/plugins/codex/scripts/run-node.sh +++ b/plugins/codex/scripts/run-node.sh @@ -88,18 +88,14 @@ find_windows_node() { return 1 } -find_node() { - if [ -n "${CODEX_COMPANION_NODE:-}" ] && [ -x "$CODEX_COMPANION_NODE" ] && is_supported_node "$CODEX_COMPANION_NODE"; then - printf '%s\n' "$CODEX_COMPANION_NODE" - return 0 - fi - if command -v node >/dev/null 2>&1; then - candidate=$(command -v node) - if is_supported_node "$candidate"; then - printf '%s\n' "$candidate" - return 0 - fi - fi +node_dir_has_codex() { + candidate=$1 + case "$candidate" in */*) candidate_dir=${candidate%/*} ;; *) candidate_dir=. ;; esac + [ -x "$candidate_dir/codex" ] || [ -f "$candidate_dir/codex.cmd" ] || [ -f "$candidate_dir/codex.exe" ] +} + +find_managed_node() { + require_codex=$1 home=${HOME:-} nvm_dir=$(windows_path_to_posix "${NVM_DIR:-$home/.nvm}") || nvm_dir= for candidate in \ @@ -111,9 +107,45 @@ find_node() { "$home"/.local/share/mise/installs/node/*/bin/node; do [ -x "$candidate" ] || continue is_supported_node "$candidate" || continue + if [ "$require_codex" = "true" ]; then + node_dir_has_codex "$candidate" || continue + fi printf '%s\n' "$candidate" return 0 done + return 1 +} + +find_node() { + if [ -n "${CODEX_COMPANION_NODE:-}" ] && [ -x "$CODEX_COMPANION_NODE" ] && is_supported_node "$CODEX_COMPANION_NODE"; then + printf '%s\n' "$CODEX_COMPANION_NODE" + return 0 + fi + + path_node= + if command -v node >/dev/null 2>&1; then + candidate=$(command -v node) + if is_supported_node "$candidate"; then + path_node=$candidate + if command -v codex >/dev/null 2>&1; then + printf '%s\n' "$path_node" + return 0 + fi + fi + fi + + if candidate=$(find_managed_node true); then + printf '%s\n' "$candidate" + return 0 + fi + if [ -n "$path_node" ]; then + printf '%s\n' "$path_node" + return 0 + fi + if candidate=$(find_managed_node false); then + printf '%s\n' "$candidate" + return 0 + fi find_windows_node && return 0 return 1 } diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index 4e4b500c8..6219d8377 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -76,7 +76,8 @@ test("portable launcher keeps searching compatible toolchains for codex", () => env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } }); assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /SYSTEM_NODE:/); + assert.match(result.stdout, /FAKE_NODE_v22\.0\.0:/); + assert.doesNotMatch(result.stdout, /SYSTEM_NODE:/); assert.match(result.stdout, /CODEX:.+[\\/]codex\n$/m); } finally { fs.rmSync(home, { recursive: true, force: true }); @@ -209,7 +210,7 @@ test("portable launcher enriches PATH from a custom NVM_DIR", () => { fs.writeFileSync(systemNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "SYSTEM_NODE:%s\\n" "$*"\nprintf "CODEX:%s\\n" "$(command -v codex || true)"\n', "utf8"); fs.chmodSync(systemNode, 0o755); const managedNode = path.join(managedBin, "node"); - fs.writeFileSync(managedNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nexit 0\n', "utf8"); + fs.writeFileSync(managedNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "CUSTOM_NVM_MANAGED:%s\\n" "$*"\nprintf "CODEX:%s\\n" "$(command -v codex || true)"\n', "utf8"); fs.chmodSync(managedNode, 0o755); const codexPath = path.join(managedBin, "codex"); fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8"); @@ -220,9 +221,29 @@ test("portable launcher enriches PATH from a custom NVM_DIR", () => { env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), NVM_DIR: nvmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } }); assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /SYSTEM_NODE:/); + assert.match(result.stdout, /CUSTOM_NVM_MANAGED:/); + assert.doesNotMatch(result.stdout, /SYSTEM_NODE:/); assert.match(result.stdout, /CODEX:.+[\\/]custom-nvm[\\/]versions[\\/]node[\\/]v22\.0\.0[\\/]bin[\\/]codex\n$/m); } finally { fs.rmSync(home, { recursive: true, force: true }); } }); + + +test("portable launcher aligns Node with the supported toolchain that contains codex", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-multi-supported-")); + installFakeNode(home, "v20.19.0", true); + const newerBin = installFakeNode(home, "v24.2.0", true); + const codexPath = path.join(newerBin, "codex"); + fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(codexPath, 0o755); + try { + const result = runWithMinimalPath(home); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /FAKE_NODE_v24\.2\.0:/); + assert.doesNotMatch(result.stdout, /FAKE_NODE_v20\.19\.0:/); + assert.match(result.stdout, /CODEX:.+[\\/]v24\.2\.0[\\/]bin[\\/]codex\n$/m); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); From a420fc0dfbfa84626afd0f94c58759657090e55f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Federico=20=7C=20RUMBO=20IA?= <293577326+fscfede-beep@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:14:13 -0300 Subject: [PATCH 09/13] test: isolate portable launcher environment --- tests/node-launcher.test.mjs | 68 ++++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index 6219d8377..00b15c9b8 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -9,6 +9,21 @@ import { fileURLToPath } from "node:url"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const LAUNCHER = path.join(ROOT, "plugins", "codex", "scripts", "run-node.sh"); const BASH = process.env.SHELL || (process.platform === "win32" ? "C:\\Program Files\\Git\\bin\\bash.exe" : "/bin/bash"); +const VERSION_MANAGER_ENV_KEYS = new Set([ + "nvm_dir", + "nvm_symlink", + "volta_home", + "localappdata", + "programfiles", + "programw6432" +]); + +function cleanVersionManagerEnv() { + return Object.fromEntries( + Object.entries(process.env).filter(([key]) => !VERSION_MANAGER_ENV_KEYS.has(key.toLowerCase())) + ); +} + function installFakeNode(home, version, supported) { const binDir = path.join(home, ".nvm", "versions", "node", version, "bin"); @@ -24,7 +39,7 @@ function runWithMinimalPath(home) { fs.mkdirSync(emptyBin, { recursive: true }); return spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { encoding: "utf8", - env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } }); } @@ -73,7 +88,7 @@ test("portable launcher keeps searching compatible toolchains for codex", () => try { const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { encoding: "utf8", - env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } }); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /FAKE_NODE_v22\.0\.0:/); @@ -89,6 +104,9 @@ test("portable launcher discovers Windows Node install roots under Git Bash", () const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-windows-")); const emptyBin = path.join(home, "empty-bin"); const programFiles = path.join(home, "Program Files"); + const programFilesPosix = programFiles + .replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`) + .replaceAll("\\", "/"); const nodeDir = path.join(programFiles, "nodejs"); fs.mkdirSync(emptyBin, { recursive: true }); fs.mkdirSync(nodeDir, { recursive: true }); @@ -103,7 +121,7 @@ test("portable launcher discovers Windows Node install roots under Git Bash", () try { const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), probeName], { encoding: "utf8", - env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), ProgramFiles: programFiles.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "", NVM_SYMLINK: "", VOLTA_HOME: "", LOCALAPPDATA: "" } + env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), PROGRAMFILES: programFilesPosix, CODEX_COMPANION_NODE: "", NVM_SYMLINK: "", VOLTA_HOME: "", LOCALAPPDATA: "" } }); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /WINDOWS_NODE/); @@ -127,7 +145,7 @@ test("portable launcher restores a configured npm global prefix for codex", () = const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { encoding: "utf8", env: { - ...process.env, + ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: nodeBin.replaceAll("\\", "/"), NPM_CONFIG_PREFIX: prefix.replaceAll("\\", "/"), @@ -162,7 +180,7 @@ test("portable launcher restores npm prefix reported by npm config", () => { const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { encoding: "utf8", env: { - ...process.env, + ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: nodeBin.replaceAll("\\", "/"), NPM_CONFIG_PREFIX: "", @@ -190,7 +208,7 @@ test("portable launcher discovers Node from a custom NVM_DIR", () => { try { const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { encoding: "utf8", - env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), NVM_DIR: nvmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "", NVM_SYMLINK: "", VOLTA_HOME: "", LOCALAPPDATA: "", ProgramFiles: "", PROGRAMFILES: "", PROGRAMW6432: "" } + env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), NVM_DIR: nvmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "", NVM_SYMLINK: "", VOLTA_HOME: "", LOCALAPPDATA: "", ProgramFiles: "", PROGRAMFILES: "", PROGRAMW6432: "" } }); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /CUSTOM_NVM_NODE:/); @@ -218,7 +236,7 @@ test("portable launcher enriches PATH from a custom NVM_DIR", () => { try { const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { encoding: "utf8", - env: { ...process.env, HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), NVM_DIR: nvmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), NVM_DIR: nvmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } }); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /CUSTOM_NVM_MANAGED:/); @@ -230,6 +248,42 @@ test("portable launcher enriches PATH from a custom NVM_DIR", () => { }); +test("portable launcher accepts CODEX_COMPANION_NODE as a native Windows path", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-configured-windows-")); + const emptyBin = path.join(home, "empty-bin"); + const portableDir = path.join(home, "Portable Node"); + fs.mkdirSync(emptyBin, { recursive: true }); + fs.mkdirSync(portableDir, { recursive: true }); + const nodePath = path.join(portableDir, "node.exe"); + fs.copyFileSync(process.execPath, nodePath); + const probeName = `configured-node-probe-${process.pid}.mjs`; + const probePath = path.join(path.dirname(LAUNCHER), probeName); + fs.writeFileSync(probePath, 'console.log("CONFIGURED_WINDOWS_NODE")\n', "utf8"); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), probeName], { + encoding: "utf8", + env: { + ...cleanVersionManagerEnv(), + HOME: home.replaceAll("\\", "/"), + PATH: emptyBin.replaceAll("\\", "/"), + CODEX_COMPANION_NODE: nodePath, + NVM_DIR: "", + NVM_SYMLINK: "", + VOLTA_HOME: "", + LOCALAPPDATA: "", + ProgramFiles: "", + PROGRAMFILES: "", + PROGRAMW6432: "" + } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /CONFIGURED_WINDOWS_NODE/); + } finally { + fs.rmSync(probePath, { force: true }); + fs.rmSync(home, { recursive: true, force: true }); + } +}); + test("portable launcher aligns Node with the supported toolchain that contains codex", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-multi-supported-")); installFakeNode(home, "v20.19.0", true); From 0ef6b735bc6183873e0443e2e0e519b7742ca970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Federico=20=7C=20RUMBO=20IA?= <293577326+fscfede-beep@users.noreply.github.com> Date: Sat, 5 Sep 2026 16:42:37 -0300 Subject: [PATCH 10/13] fix: honor custom fnm directory --- plugins/codex/scripts/run-node.sh | 6 ++-- tests/node-launcher.test.mjs | 53 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh index 7394b50d2..e5260ea6d 100644 --- a/plugins/codex/scripts/run-node.sh +++ b/plugins/codex/scripts/run-node.sh @@ -98,11 +98,12 @@ find_managed_node() { require_codex=$1 home=${HOME:-} nvm_dir=$(windows_path_to_posix "${NVM_DIR:-$home/.nvm}") || nvm_dir= + fnm_dir=$(windows_path_to_posix "${FNM_DIR:-$home/.local/share/fnm}") || fnm_dir= for candidate in \ /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node \ "$home/.volta/bin/node" \ "$nvm_dir"/versions/node/*/bin/node \ - "$home"/.local/share/fnm/node-versions/*/installation/bin/node \ + "$fnm_dir"/node-versions/*/installation/bin/node \ "$home"/.asdf/installs/nodejs/*/bin/node \ "$home"/.local/share/mise/installs/node/*/bin/node; do [ -x "$candidate" ] || continue @@ -209,7 +210,8 @@ case "$node_bin" in */*) node_dir=${node_bin%/*} ;; *) node_dir=. ;; esac PATH="$node_dir${PATH:+:$PATH}" home=${HOME:-} nvm_dir=$(windows_path_to_posix "${NVM_DIR:-$home/.nvm}") || nvm_dir= -for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$nvm_dir"/versions/node/*/bin/node "$home"/.local/share/fnm/node-versions/*/installation/bin/node "$home"/.asdf/installs/nodejs/*/bin/node "$home"/.local/share/mise/installs/node/*/bin/node; do +fnm_dir=$(windows_path_to_posix "${FNM_DIR:-$home/.local/share/fnm}") || fnm_dir= +for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$nvm_dir"/versions/node/*/bin/node "$fnm_dir"/node-versions/*/installation/bin/node "$home"/.asdf/installs/nodejs/*/bin/node "$home"/.local/share/mise/installs/node/*/bin/node; do add_supported_node_dir_to_path "$candidate" done add_windows_node_dirs_to_path diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index 00b15c9b8..36ca7475f 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -12,6 +12,7 @@ const BASH = process.env.SHELL || (process.platform === "win32" ? "C:\\Program F const VERSION_MANAGER_ENV_KEYS = new Set([ "nvm_dir", "nvm_symlink", + "fnm_dir", "volta_home", "localappdata", "programfiles", @@ -248,6 +249,58 @@ test("portable launcher enriches PATH from a custom NVM_DIR", () => { }); +test("portable launcher discovers Node from a custom FNM_DIR", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-custom-fnm-")); + const emptyBin = path.join(home, "empty-bin"); + const fnmDir = path.join(home, "custom-fnm"); + const binDir = path.join(fnmDir, "node-versions", "v22.0.0", "installation", "bin"); + fs.mkdirSync(emptyBin, { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + const nodePath = path.join(binDir, "node"); + fs.writeFileSync(nodePath, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "CUSTOM_FNM_NODE:%s\\n" "$*"\n', "utf8"); + fs.chmodSync(nodePath, 0o755); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), FNM_DIR: fnmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /CUSTOM_FNM_NODE:/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("portable launcher aligns Node and codex from a custom FNM_DIR", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-custom-fnm-path-")); + const systemBin = path.join(home, "system-bin"); + const fnmDir = path.join(home, "custom-fnm"); + const managedBin = path.join(fnmDir, "node-versions", "v24.2.0", "installation", "bin"); + fs.mkdirSync(systemBin, { recursive: true }); + fs.mkdirSync(managedBin, { recursive: true }); + const systemNode = path.join(systemBin, "node"); + fs.writeFileSync(systemNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "SYSTEM_NODE:%s\\n" "$*"\n', "utf8"); + fs.chmodSync(systemNode, 0o755); + const managedNode = path.join(managedBin, "node"); + fs.writeFileSync(managedNode, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "CUSTOM_FNM_MANAGED:%s\\n" "$*"\nprintf "CODEX:%s\\n" "$(command -v codex || true)"\n', "utf8"); + fs.chmodSync(managedNode, 0o755); + const codexPath = path.join(managedBin, "codex"); + fs.writeFileSync(codexPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(codexPath, 0o755); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: systemBin.replaceAll("\\", "/"), FNM_DIR: fnmDir.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /CUSTOM_FNM_MANAGED:/); + assert.doesNotMatch(result.stdout, /SYSTEM_NODE:/); + assert.match(result.stdout, /CODEX:.+[\\/]custom-fnm[\\/]node-versions[\\/]v24\.2\.0[\\/]installation[\\/]bin[\\/]codex\n$/m); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + test("portable launcher accepts CODEX_COMPANION_NODE as a native Windows path", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-configured-windows-")); const emptyBin = path.join(home, "empty-bin"); From 2c6b2556242c4ea7afccf18b3597d4feb8140557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Federico=20=7C=20RUMBO=20IA?= <293577326+fscfede-beep@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:08:38 -0300 Subject: [PATCH 11/13] fix: honor custom asdf and mise roots --- plugins/codex/scripts/run-node.sh | 19 ++++++++----- tests/node-launcher.test.mjs | 44 +++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh index e5260ea6d..9eea0f6fb 100644 --- a/plugins/codex/scripts/run-node.sh +++ b/plugins/codex/scripts/run-node.sh @@ -99,13 +99,15 @@ find_managed_node() { home=${HOME:-} nvm_dir=$(windows_path_to_posix "${NVM_DIR:-$home/.nvm}") || nvm_dir= fnm_dir=$(windows_path_to_posix "${FNM_DIR:-$home/.local/share/fnm}") || fnm_dir= + asdf_dir=$(windows_path_to_posix "${ASDF_DATA_DIR:-$home/.asdf}") || asdf_dir= + mise_dir=$(windows_path_to_posix "${MISE_DATA_DIR:-$home/.local/share/mise}") || mise_dir= for candidate in \ /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node \ "$home/.volta/bin/node" \ "$nvm_dir"/versions/node/*/bin/node \ "$fnm_dir"/node-versions/*/installation/bin/node \ - "$home"/.asdf/installs/nodejs/*/bin/node \ - "$home"/.local/share/mise/installs/node/*/bin/node; do + "$asdf_dir"/installs/nodejs/*/bin/node \ + "$mise_dir"/installs/node/*/bin/node; do [ -x "$candidate" ] || continue is_supported_node "$candidate" || continue if [ "$require_codex" = "true" ]; then @@ -118,9 +120,12 @@ find_managed_node() { } find_node() { - if [ -n "${CODEX_COMPANION_NODE:-}" ] && [ -x "$CODEX_COMPANION_NODE" ] && is_supported_node "$CODEX_COMPANION_NODE"; then - printf '%s\n' "$CODEX_COMPANION_NODE" - return 0 + if [ -n "${CODEX_COMPANION_NODE:-}" ]; then + configured_node=$(windows_path_to_posix "$CODEX_COMPANION_NODE") || configured_node=$CODEX_COMPANION_NODE + if [ -x "$configured_node" ] && is_supported_node "$configured_node"; then + printf '%s\n' "$configured_node" + return 0 + fi fi path_node= @@ -211,7 +216,9 @@ PATH="$node_dir${PATH:+:$PATH}" home=${HOME:-} nvm_dir=$(windows_path_to_posix "${NVM_DIR:-$home/.nvm}") || nvm_dir= fnm_dir=$(windows_path_to_posix "${FNM_DIR:-$home/.local/share/fnm}") || fnm_dir= -for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$nvm_dir"/versions/node/*/bin/node "$fnm_dir"/node-versions/*/installation/bin/node "$home"/.asdf/installs/nodejs/*/bin/node "$home"/.local/share/mise/installs/node/*/bin/node; do +asdf_dir=$(windows_path_to_posix "${ASDF_DATA_DIR:-$home/.asdf}") || asdf_dir= +mise_dir=$(windows_path_to_posix "${MISE_DATA_DIR:-$home/.local/share/mise}") || mise_dir= +for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$nvm_dir"/versions/node/*/bin/node "$fnm_dir"/node-versions/*/installation/bin/node "$asdf_dir"/installs/nodejs/*/bin/node "$mise_dir"/installs/node/*/bin/node; do add_supported_node_dir_to_path "$candidate" done add_windows_node_dirs_to_path diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index 36ca7475f..798594072 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -1,4 +1,4 @@ -import fs from "node:fs"; +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; @@ -13,6 +13,8 @@ const VERSION_MANAGER_ENV_KEYS = new Set([ "nvm_dir", "nvm_symlink", "fnm_dir", + "asdf_data_dir", + "mise_data_dir", "volta_home", "localappdata", "programfiles", @@ -301,6 +303,43 @@ test("portable launcher aligns Node and codex from a custom FNM_DIR", () => { } }); + +test("portable launcher honors custom ASDF_DATA_DIR and MISE_DATA_DIR", () => { + const cases = [ + { envKey: "ASDF_DATA_DIR", label: "ASDF", relative: ["installs", "nodejs", "v22.0.0", "bin"] }, + { envKey: "MISE_DATA_DIR", label: "MISE", relative: ["installs", "node", "v22.0.0", "bin"] } + ]; + for (const { envKey, label, relative } of cases) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), `codex-node-custom-${label.toLowerCase()}-`)); + const emptyBin = path.join(home, "empty-bin"); + const dataDir = path.join(home, `custom-${label.toLowerCase()}`); + const binDir = path.join(dataDir, ...relative); + fs.mkdirSync(emptyBin, { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + const nodePath = path.join(binDir, "node"); + fs.writeFileSync(nodePath, `#!/bin/sh\nif [ "\${1:-}" = "-e" ]; then exit 0; fi\nprintf '${label}_NODE:%s\\n' "$*"\n`, "utf8"); + fs.chmodSync(nodePath, 0o755); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { + ...cleanVersionManagerEnv(), + HOME: home.replaceAll("\\\\", "/"), + PATH: emptyBin.replaceAll("\\\\", "/"), + ASDF_DATA_DIR: "", + MISE_DATA_DIR: "", + [envKey]: dataDir.replaceAll("\\\\", "/"), + CODEX_COMPANION_NODE: "" + } + }); + assert.equal(result.status, 0, `${label}: ${result.stderr}`); + assert.match(result.stdout, new RegExp(`${label}_NODE:`)); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } + } +}); + test("portable launcher accepts CODEX_COMPANION_NODE as a native Windows path", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-configured-windows-")); const emptyBin = path.join(home, "empty-bin"); @@ -311,7 +350,7 @@ test("portable launcher accepts CODEX_COMPANION_NODE as a native Windows path", fs.copyFileSync(process.execPath, nodePath); const probeName = `configured-node-probe-${process.pid}.mjs`; const probePath = path.join(path.dirname(LAUNCHER), probeName); - fs.writeFileSync(probePath, 'console.log("CONFIGURED_WINDOWS_NODE")\n', "utf8"); + fs.writeFileSync(probePath, 'console.log("CONFIGURED_WINDOWS_NODE"); console.log("PATH:" + (process.env.PATH ?? ""));\n', "utf8"); try { const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), probeName], { encoding: "utf8", @@ -331,6 +370,7 @@ test("portable launcher accepts CODEX_COMPANION_NODE as a native Windows path", }); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /CONFIGURED_WINDOWS_NODE/); + assert.match(result.stdout, /PATH:.*Portable Node/i); } finally { fs.rmSync(probePath, { force: true }); fs.rmSync(home, { recursive: true, force: true }); From 11007e405a86415407f208879324f5960868f2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Federico=20=7C=20RUMBO=20IA?= <293577326+fscfede-beep@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:09:36 -0300 Subject: [PATCH 12/13] test: preserve launcher test encoding --- tests/node-launcher.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index 798594072..bd5db201f 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -1,4 +1,4 @@ -import fs from "node:fs"; +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; From e116d57b1d5fa61a3bfa02ab6aa9595335b84416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Federico=20=7C=20RUMBO=20IA?= <293577326+fscfede-beep@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:38:11 -0300 Subject: [PATCH 13/13] fix: honor Linuxbrew prefix in launcher --- plugins/codex/scripts/run-node.sh | 10 ++++++++-- tests/node-launcher.test.mjs | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/plugins/codex/scripts/run-node.sh b/plugins/codex/scripts/run-node.sh index 9eea0f6fb..9b60d47c9 100644 --- a/plugins/codex/scripts/run-node.sh +++ b/plugins/codex/scripts/run-node.sh @@ -101,8 +101,11 @@ find_managed_node() { fnm_dir=$(windows_path_to_posix "${FNM_DIR:-$home/.local/share/fnm}") || fnm_dir= asdf_dir=$(windows_path_to_posix "${ASDF_DATA_DIR:-$home/.asdf}") || asdf_dir= mise_dir=$(windows_path_to_posix "${MISE_DATA_DIR:-$home/.local/share/mise}") || mise_dir= + homebrew_prefix=$(windows_path_to_posix "${HOMEBREW_PREFIX:-}") || homebrew_prefix= + homebrew_node= + [ -n "$homebrew_prefix" ] && homebrew_node="$homebrew_prefix/bin/node" for candidate in \ - /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node \ + "$homebrew_node" /home/linuxbrew/.linuxbrew/bin/node /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node \ "$home/.volta/bin/node" \ "$nvm_dir"/versions/node/*/bin/node \ "$fnm_dir"/node-versions/*/installation/bin/node \ @@ -218,7 +221,10 @@ nvm_dir=$(windows_path_to_posix "${NVM_DIR:-$home/.nvm}") || nvm_dir= fnm_dir=$(windows_path_to_posix "${FNM_DIR:-$home/.local/share/fnm}") || fnm_dir= asdf_dir=$(windows_path_to_posix "${ASDF_DATA_DIR:-$home/.asdf}") || asdf_dir= mise_dir=$(windows_path_to_posix "${MISE_DATA_DIR:-$home/.local/share/mise}") || mise_dir= -for candidate in /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$nvm_dir"/versions/node/*/bin/node "$fnm_dir"/node-versions/*/installation/bin/node "$asdf_dir"/installs/nodejs/*/bin/node "$mise_dir"/installs/node/*/bin/node; do +homebrew_prefix=$(windows_path_to_posix "${HOMEBREW_PREFIX:-}") || homebrew_prefix= +homebrew_node= +[ -n "$homebrew_prefix" ] && homebrew_node="$homebrew_prefix/bin/node" +for candidate in "$homebrew_node" /home/linuxbrew/.linuxbrew/bin/node /opt/homebrew/bin/node /usr/local/bin/node /opt/local/bin/node "$home/.volta/bin/node" "$nvm_dir"/versions/node/*/bin/node "$fnm_dir"/node-versions/*/installation/bin/node "$asdf_dir"/installs/nodejs/*/bin/node "$mise_dir"/installs/node/*/bin/node; do add_supported_node_dir_to_path "$candidate" done add_windows_node_dirs_to_path diff --git a/tests/node-launcher.test.mjs b/tests/node-launcher.test.mjs index bd5db201f..e51128241 100644 --- a/tests/node-launcher.test.mjs +++ b/tests/node-launcher.test.mjs @@ -15,6 +15,7 @@ const VERSION_MANAGER_ENV_KEYS = new Set([ "fnm_dir", "asdf_data_dir", "mise_data_dir", + "homebrew_prefix", "volta_home", "localappdata", "programfiles", @@ -340,6 +341,28 @@ test("portable launcher honors custom ASDF_DATA_DIR and MISE_DATA_DIR", () => { } }); +test("portable launcher honors HOMEBREW_PREFIX under a minimal PATH", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-homebrew-prefix-")); + const emptyBin = path.join(home, "empty-bin"); + const brewPrefix = path.join(home, "linuxbrew-prefix"); + const binDir = path.join(brewPrefix, "bin"); + fs.mkdirSync(emptyBin, { recursive: true }); + fs.mkdirSync(binDir, { recursive: true }); + const nodePath = path.join(binDir, "node"); + fs.writeFileSync(nodePath, '#!/bin/sh\nif [ "${1:-}" = "-e" ]; then exit 0; fi\nprintf "HOMEBREW_NODE:%s\\n" "$*"\n', "utf8"); + fs.chmodSync(nodePath, 0o755); + try { + const result = spawnSync(BASH, [LAUNCHER.replaceAll("\\", "/"), "companion.mjs", "status", "--json"], { + encoding: "utf8", + env: { ...cleanVersionManagerEnv(), HOME: home.replaceAll("\\", "/"), PATH: emptyBin.replaceAll("\\", "/"), HOMEBREW_PREFIX: brewPrefix.replaceAll("\\", "/"), CODEX_COMPANION_NODE: "" } + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /HOMEBREW_NODE:/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + test("portable launcher accepts CODEX_COMPANION_NODE as a native Windows path", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "codex-node-configured-windows-")); const emptyBin = path.join(home, "empty-bin");