Fix remote Node.js detection for nvm, mise, asdf, and volta (#6037)

* Fix remote Node.js detection for nvm, mise, asdf, and volta

Remote Node resolution failed when node was installed via a version
manager (nvm with custom NVM_DIR, mise, asdf, volta) or when the user's
login shell was zsh/fish rather than bash.

Root cause: the SSH exec transport runs every command under /bin/sh,
which never sources shell init files. The only init-aware path was a
hardcoded `bash -lc` fallback that missed zsh/fish users and was never
reached for the newer version managers. nvm was handled by guessing
~/.nvm (breaking custom NVM_DIR), and mise/asdf/volta had no probes at
all. There was also no version gate, so nvm's highest-version glob
could return Node 8/10/12 and crash the relay on launch.

Fix: resolve via the user's own $SHELL as a login shell first (the only
path that runs nvm.sh / mise activate / asdf.sh init hooks), then fall
back to direct path probes for all major managers (nvm respecting
$NVM_DIR, fnm, mise, asdf, volta, n) plus system locations. Every
candidate is version-checked against the relay's Node 18+ requirement
before being accepted.

* Address CodeRabbit review: probes-first, no || short-circuit

- Reorder to path-probes first (deterministic, doesn't depend on shell
  rc-file semantics where bash -lc skips .bashrc and zsh -lc skips
  .zshrc — exactly where nvm/mise/asdf hooks live).
- Join probes with newlines instead of || so an empty
  `ls | sort -V | tail -1` (exit 0) doesn't mask later probes.
- Deduplicate candidate paths before version-checking.
- Drop unreachable mock and fix misleading $SHELL-unset test name.
- Login shell is now a fallback for custom ~/.profile PATH setups.

* Fix remote Node path probing portability

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Kevin Chan 2026-06-23 07:46:47 +08:00 committed by GitHub
parent a0d9505ba5
commit 44935d4ccb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 445 additions and 39 deletions

View File

@ -0,0 +1,283 @@
import { execFileSync } from 'node:child_process'
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SshConnection } from './ssh-connection'
const execCommandMock = vi.hoisted(() => vi.fn())
vi.mock('./ssh-relay-deploy-helpers', () => ({
execCommand: execCommandMock
}))
// Why: await import() is required so vi.mock() above registers before the
// module under test is evaluated. Static import would bypass the mock.
const { resolveRemoteNodePath } = await import('./ssh-remote-node-resolution')
const conn = {} as SshConnection
describe('resolveRemoteNodePath', () => {
beforeEach(() => {
execCommandMock.mockReset()
})
// ── Path-probe strategy (runs first) ───────────────────────────────────
it('resolves system node via the path probe', async () => {
execCommandMock
.mockResolvedValueOnce('/usr/local/bin/node\n') // path probe
.mockResolvedValueOnce('v20.0.0\n') // version check
await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node')
})
it('probes mise install directories', async () => {
execCommandMock
.mockResolvedValueOnce('/home/u/.local/share/mise/installs/node/20/bin/node\n')
.mockResolvedValueOnce('v20.11.0\n')
await resolveRemoteNodePath(conn)
const callScript = execCommandMock.mock.calls[0]![1] as string
expect(callScript).toContain('"$HOME/.local/share/mise/installs/node"/*/bin/node')
})
it('probes asdf install directories', async () => {
execCommandMock
.mockResolvedValueOnce('/home/u/.asdf/installs/nodejs/20.11.0/bin/node\n')
.mockResolvedValueOnce('v20.11.0\n')
await resolveRemoteNodePath(conn)
const callScript = execCommandMock.mock.calls[0]![1] as string
expect(callScript).toContain('"$HOME/.asdf/installs/nodejs"/*/bin/node')
})
it('probes volta bin directory', async () => {
execCommandMock
.mockResolvedValueOnce('/home/u/.volta/bin/node\n')
.mockResolvedValueOnce('v20.11.0\n')
await resolveRemoteNodePath(conn)
const callScript = execCommandMock.mock.calls[0]![1] as string
expect(callScript).toContain('$HOME/.volta/bin/node')
})
it('respects a custom NVM_DIR instead of hardcoding $HOME/.nvm', async () => {
execCommandMock
.mockResolvedValueOnce('/custom/nvm/versions/node/v20.11.0/bin/node\n')
.mockResolvedValueOnce('v20.11.0\n')
await resolveRemoteNodePath(conn)
const callScript = execCommandMock.mock.calls[0]![1] as string
expect(callScript).toContain('nvm_dirs=${NVM_DIR:-"$HOME/.nvm"}')
expect(callScript).toContain('NVM_DIR[[:space:]]*=')
expect(callScript).toContain('"$nvm_dir"/versions/node/*/bin/node')
})
it('quotes version-manager directory prefixes while leaving globs active', async () => {
execCommandMock
.mockResolvedValueOnce('/home/u/.fnm/node-versions/v20.11.0/installation/bin/node\n')
.mockResolvedValueOnce('v20.11.0\n')
await resolveRemoteNodePath(conn)
const callScript = execCommandMock.mock.calls[0]![1] as string
expect(callScript).toContain('"$HOME/.fnm/node-versions"/*/installation/bin/node')
expect(callScript).toContain('"$HOME/.local/share/mise/installs/node"/*/bin/node')
expect(callScript).toContain('"$HOME/.asdf/installs/nodejs"/*/bin/node')
})
it('does not depend on GNU sort when probing version-manager directories', async () => {
execCommandMock
.mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n')
.mockResolvedValueOnce('v20.11.0\n')
await resolveRemoteNodePath(conn)
const callScript = execCommandMock.mock.calls[0]![1] as string
expect(callScript).not.toContain('sort -V')
})
it('keeps the path-probe script successful when optional directories are missing', async () => {
execCommandMock
.mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n')
.mockResolvedValueOnce('v20.11.0\n')
await resolveRemoteNodePath(conn)
const callScript = execCommandMock.mock.calls[0]![1] as string
expect(callScript.trimEnd()).toMatch(/\ntrue$/)
})
it('expands tilde NVM_DIR assignments from shell dotfiles', async () => {
execCommandMock
.mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n')
.mockResolvedValueOnce('v20.11.0\n')
await resolveRemoteNodePath(conn)
const callScript = execCommandMock.mock.calls[0]![1] as string
const home = mkdtempSync(path.join(os.tmpdir(), 'orca-nvm-probe-'))
try {
const nodePath = path.join(home, 'tilde-nvm/versions/node/v20.11.0/bin/node')
mkdirSync(path.dirname(nodePath), { recursive: true })
writeFileSync(nodePath, '#!/bin/sh\nprintf "v20.11.0\\n"\n')
chmodSync(nodePath, 0o755)
writeFileSync(path.join(home, '.zshrc'), 'export NVM_DIR=~/tilde-nvm\n')
const output = execFileSync('/bin/sh', ['-c', callScript], {
encoding: 'utf8',
env: { HOME: home, PATH: '/usr/bin:/bin' }
})
expect(output.split('\n')).toContain(nodePath)
} finally {
rmSync(home, { recursive: true, force: true })
}
})
it('joins probes with newlines, not ||, so a missing dir does not mask later probes', async () => {
execCommandMock
.mockResolvedValueOnce('/usr/local/bin/node\n')
.mockResolvedValueOnce('v20.0.0\n')
await resolveRemoteNodePath(conn)
const callScript = execCommandMock.mock.calls[0]![1] as string
// Why: an `||` chain would stop after the first successful probe and hide
// later version managers that may hold the first usable Node.
expect(callScript).not.toMatch(/node\b.*\|\|/)
})
it('rejects a path-probe candidate whose version is below the minimum', async () => {
// Probe returns two candidates; the first (v10) is too old, the second
// (v20) must be selected instead.
execCommandMock
.mockResolvedValueOnce(
'/home/u/.nvm/versions/node/v10.24.1/bin/node\n/home/u/.nvm/versions/node/v20.11.0/bin/node\n'
)
.mockResolvedValueOnce('v10.24.1\n') // first candidate fails the gate
.mockResolvedValueOnce('v20.11.0\n') // second candidate passes
await expect(resolveRemoteNodePath(conn)).resolves.toBe(
'/home/u/.nvm/versions/node/v20.11.0/bin/node'
)
})
it('accepts Node 18 (the exact minimum) as valid', async () => {
execCommandMock
.mockResolvedValueOnce('/usr/local/bin/node\n')
.mockResolvedValueOnce('v18.0.0\n')
await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node')
})
it('deduplicates repeated candidate paths before version-checking', async () => {
// Why: some managers leave stale shims that resolve to the same binary;
// we should not version-check the same path twice.
execCommandMock
.mockResolvedValueOnce('/usr/local/bin/node\n/usr/local/bin/node\n')
.mockResolvedValueOnce('v20.0.0\n')
await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node')
expect(execCommandMock).toHaveBeenCalledTimes(2)
})
it('falls back to the login shell when path probes find nothing', async () => {
execCommandMock
.mockResolvedValueOnce('\n') // path probe: empty
.mockResolvedValueOnce('/bin/zsh') // $SHELL
.mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n') // command -v node
.mockResolvedValueOnce('v20.11.0\n')
await expect(resolveRemoteNodePath(conn)).resolves.toBe(
'/home/u/.nvm/versions/node/v20.11.0/bin/node'
)
expect(execCommandMock).toHaveBeenNthCalledWith(3, conn, `'/bin/zsh' -lc 'command -v node'`, {
wrapCommand: false,
timeoutMs: 8_000
})
})
it('falls back to the login shell when every path-probe candidate is too old', async () => {
execCommandMock
.mockResolvedValueOnce('/old/node\n') // path probe
.mockResolvedValueOnce('v10.24.1\n') // too old
.mockResolvedValueOnce('/bin/bash') // $SHELL
.mockResolvedValueOnce('/home/u/.nvm/versions/node/v20.11.0/bin/node\n')
.mockResolvedValueOnce('v20.11.0\n')
await expect(resolveRemoteNodePath(conn)).resolves.toBe(
'/home/u/.nvm/versions/node/v20.11.0/bin/node'
)
})
// ── Login-shell strategy (fallback) ───────────────────────────────────
it('respects a non-default $SHELL instead of hardcoding bash', async () => {
execCommandMock
.mockResolvedValueOnce('\n') // path probe: empty
.mockResolvedValueOnce('/usr/bin/fish') // $SHELL
.mockResolvedValueOnce('/opt/homebrew/bin/node\n')
.mockResolvedValueOnce('v22.0.0\n')
await resolveRemoteNodePath(conn)
expect(execCommandMock).toHaveBeenNthCalledWith(
3,
conn,
`'/usr/bin/fish' -lc 'command -v node'`,
{ wrapCommand: false, timeoutMs: 8_000 }
)
})
it('uses /bin/sh when the remote shell expansion falls back to it', async () => {
execCommandMock
.mockResolvedValueOnce('\n') // path probe: empty
.mockResolvedValueOnce('/bin/sh\n') // ${SHELL:-/bin/sh}
.mockResolvedValueOnce('/usr/local/bin/node\n')
.mockResolvedValueOnce('v20.0.0\n')
await expect(resolveRemoteNodePath(conn)).resolves.toBe('/usr/local/bin/node')
expect(execCommandMock).toHaveBeenNthCalledWith(3, conn, `'/bin/sh' -c 'command -v node'`, {
wrapCommand: false,
timeoutMs: 8_000
})
})
// ── Failure ───────────────────────────────────────────────────────────
it('throws when both strategies find no usable node', async () => {
execCommandMock
.mockResolvedValueOnce('\n') // path probe: empty
.mockResolvedValueOnce('/bin/bash') // $SHELL
.mockResolvedValueOnce('\n') // command -v node: empty
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/)
})
it('throws when the path-probe exec fails and the login shell finds nothing', async () => {
execCommandMock
.mockRejectedValueOnce(new Error('SSH exec channel failed')) // path probe errors
.mockResolvedValueOnce('/bin/zsh') // $SHELL
.mockResolvedValueOnce('\n') // command -v node: empty
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/)
})
it('throws when every candidate across both strategies is below the minimum', async () => {
execCommandMock
.mockResolvedValueOnce('/old/node\n') // path probe
.mockResolvedValueOnce('v8.17.0\n') // too old
.mockResolvedValueOnce('/bin/bash') // $SHELL
.mockResolvedValueOnce('/old/node2\n') // login shell
.mockResolvedValueOnce('v6.17.0\n') // too old
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/)
})
})

View File

@ -1,12 +1,20 @@
import type { SshConnection } from './ssh-connection'
import { execCommand } from './ssh-relay-deploy-helpers'
import { shellEscape } from './ssh-connection-utils'
import type { RemoteHostPlatform } from './ssh-remote-platform'
import { isWindowsRemoteHost, normalizeWindowsRemotePath } from './ssh-remote-platform'
import { powerShellCommand } from './ssh-remote-powershell'
import { execCommand } from './ssh-relay-deploy-helpers'
// Why: the relay requires Node.js 18+. Version managers like nvm keep every
// installed version on disk, so a naive "highest version" glob can hand back
// Node 8/10/12 and crash the relay on launch. Gate every candidate on this.
const MIN_NODE_MAJOR = 18
// Why: the login-shell fallback catches custom PATH setups in ~/.profile that
// the path probes don't cover. Interactive configs (conda prompts, etc.) can
// hang a login shell, so keep this short.
const LOGIN_SHELL_PROBE_TIMEOUT_MS = 8_000
// Why: non-login SSH shells (the default for `exec`) don't source
// .bashrc/.zshrc, so node installed via nvm/fnm/Homebrew isn't in PATH.
// We try common locations and fall back to a login-shell `which`.
export async function resolveRemoteNodePath(
conn: SshConnection,
host?: RemoteHostPlatform
@ -15,48 +23,163 @@ export async function resolveRemoteNodePath(
return resolveRemoteWindowsNodePath(conn)
}
const script = [
'command -v node 2>/dev/null',
'command -v /usr/local/bin/node 2>/dev/null',
'command -v /opt/homebrew/bin/node 2>/dev/null',
// Why: nvm installs into a versioned directory. `ls -1` sorts
// alphabetically, which misorders versions (e.g. v9 > v18). Pipe
// through `sort -V` (version sort) so we pick the highest version.
'ls -1 $HOME/.nvm/versions/node/*/bin/node 2>/dev/null | sort -V | tail -1',
'command -v $HOME/.local/bin/node 2>/dev/null',
'command -v $HOME/.fnm/aliases/default/bin/node 2>/dev/null'
].join(' || ')
try {
const result = await execCommand(conn, script)
const nodePath = result.trim().split('\n')[0]
if (nodePath) {
console.log(`[ssh-relay] Found node at: ${nodePath}`)
return nodePath
}
} catch {
// Fall through to login shell attempt
// Strategy 1: probe well-known install directories for every common Node
// version manager (nvm, fnm, mise, asdf, volta, n) plus system locations.
// This doesn't depend on shell startup-file semantics — bash -lc skips
// .bashrc and zsh -lc skips .zshrc, but those are exactly the files where
// nvm/mise/asdf hooks live. Probing directories directly is deterministic.
const probedPath = await tryResolveViaKnownPaths(conn)
if (probedPath) {
return probedPath
}
// Why: last resort — source the full login profile. This is separated into
// its own exec because `bash -lc` can hang on remotes with interactive
// shell configs (conda prompts, etc.). If this times out, the error message
// from execCommand will tell us it was the login shell attempt.
try {
console.log('[ssh-relay] Trying login shell to find node...')
const result = await execCommand(conn, "bash -lc 'command -v node' 2>/dev/null")
const nodePath = result.trim().split('\n')[0]
if (nodePath) {
console.log(`[ssh-relay] Found node via login shell: ${nodePath}`)
return nodePath
}
} catch {
// Fall through
// Strategy 2 (fallback): ask the user's login shell. Catches custom PATH
// setups in ~/.profile / ~/.bash_profile that the probes don't cover.
const loginShellPath = await tryResolveViaLoginShell(conn)
if (loginShellPath) {
return loginShellPath
}
throwNodeNotFound()
}
// Probe the on-disk install directories of every common Node version manager
// plus system package-manager locations. Every probe runs unconditionally so
// a missing directory prints nothing rather than short-circuiting later
// probes. Returns the first candidate that meets the minimum version.
async function tryResolveViaKnownPaths(conn: SshConnection): Promise<string | null> {
const script = `
command -v node 2>/dev/null
nvm_dirs=\${NVM_DIR:-"$HOME/.nvm"}
for nvm_file in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"
do
[ -r "$nvm_file" ] || continue
nvm_dir_from_file=$(sed -n 's/^[[:space:]]*export[[:space:]][[:space:]]*NVM_DIR[[:space:]]*=[[:space:]]*//p; s/^[[:space:]]*NVM_DIR[[:space:]]*=[[:space:]]*//p' "$nvm_file" | tail -n 1)
case "$nvm_dir_from_file" in
\\"*\\") nvm_dir_from_file=\${nvm_dir_from_file#\\"}; nvm_dir_from_file=\${nvm_dir_from_file%%\\"*} ;;
\\'*\\') nvm_dir_from_file=\${nvm_dir_from_file#\\'}; nvm_dir_from_file=\${nvm_dir_from_file%%\\'*} ;;
*) nvm_dir_from_file=\${nvm_dir_from_file%%[[:space:]]*} ;;
esac
case "$nvm_dir_from_file" in
'$HOME'*) nvm_dir_from_file="$HOME\${nvm_dir_from_file#'$HOME'}" ;;
"~/"*) nvm_dir_from_file="$HOME/\${nvm_dir_from_file#\\~/}" ;;
esac
[ -n "$nvm_dir_from_file" ] && nvm_dirs="$nvm_dirs
$nvm_dir_from_file"
done
printf '%s\\n' "$nvm_dirs" | while IFS= read -r nvm_dir
do
[ -n "$nvm_dir" ] || continue
for candidate in "$nvm_dir"/versions/node/*/bin/node
do
[ -x "$candidate" ] && printf '%s\\n' "$candidate"
done
done
for candidate in \\
/usr/local/bin/node \\
/opt/homebrew/bin/node \\
"$HOME/.local/bin/node" \\
"$HOME/.fnm/aliases/default/bin/node" \\
"$HOME/.fnm/node-versions"/*/installation/bin/node \\
"$HOME/.local/share/mise/shims/node" \\
"$HOME/.local/share/mise/installs/node"/*/bin/node \\
"$HOME/.asdf/shims/node" \\
"$HOME/.asdf/installs/nodejs"/*/bin/node \\
"$HOME/.volta/bin/node" \\
/usr/local/n/versions/node/*/bin/node
do
[ -x "$candidate" ] && printf '%s\\n' "$candidate"
done
true
`
try {
const result = await execCommand(conn, script)
const seen = new Set<string>()
for (const line of result.split('\n')) {
const candidate = line.trim()
if (!candidate || seen.has(candidate)) {
continue
}
seen.add(candidate)
if (await nodeMeetsVersionRequirement(conn, candidate)) {
console.log(`[ssh-relay] Found node via path probe: ${candidate}`)
return candidate
}
}
} catch {
// Fall through to login shell.
}
return null
}
// Run `command -v node` under the user's login shell, then verify the result
// meets the minimum version. Returns null on any failure (shell missing, no
// node found, version too old, timeout) so callers fall through to the error.
async function tryResolveViaLoginShell(conn: SshConnection): Promise<string | null> {
try {
// Why: $SHELL is the user's configured login shell (set by chsh / passwd).
// Using it — rather than hardcoding bash — means zsh/fish users whose
// custom PATH hooks live in profile files get coverage too. We fall back
// to sh if $SHELL is unset (rare, e.g. restricted accounts).
const shellResult = await execCommand(conn, 'echo "${SHELL:-/bin/sh}"', {
timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS
})
const shell = shellResult.trim().split('\n')[0]
if (!shell) {
return null
}
const nodePath = await execCommand(conn, buildCommandInShell(shell, 'command -v node'), {
wrapCommand: false,
timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS
})
const candidate = nodePath.trim().split('\n')[0]
if (!candidate) {
return null
}
if (await nodeMeetsVersionRequirement(conn, candidate)) {
console.log(`[ssh-relay] Found node via login shell (${shell}): ${candidate}`)
return candidate
}
} catch {
// Fall through.
}
return null
}
function buildCommandInShell(shell: string, command: string): string {
const shellName = shell.split('/').at(-1)
// Why: dash and POSIX sh do not require `-l`; when $SHELL falls back to
// /bin/sh, prefer a portable command over login-shell semantics.
const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc'
return `${shellEscape(shell)} ${mode} ${shellEscape(command)}`
}
// Returns true if `nodePath` runs and reports Node >= MIN_NODE_MAJOR.
// Caches nothing — this runs at most a few times per resolution (one per
// candidate), and the exec round-trip dominates.
async function nodeMeetsVersionRequirement(
conn: SshConnection,
nodePath: string
): Promise<boolean> {
try {
const versionOutput = await execCommand(conn, `${shellEscape(nodePath)} --version`, {
wrapCommand: false
})
const match = versionOutput.trim().match(/^v?(\d+)/)
if (!match) {
return false
}
const major = Number.parseInt(match[1]!, 10)
return major >= MIN_NODE_MAJOR
} catch {
// Binary missing or fails to run — not usable.
return false
}
}
async function resolveRemoteWindowsNodePath(conn: SshConnection): Promise<string> {
const script = [
'$paths = @()',