Files
orca/src/main/ssh/ssh-remote-node-resolution.test.ts
T
Neil 57681ecd09 fix(remote): resolve the spawn cwd, the node manager dir, the vault host and the scrollback seed (#17952)
* fix(remote): resolve workspace cwd, mise Node, host scope, and TUI scrollback honestly

#15296 relay: a folder workspace id (`folder:<uuid>`) carries no path, so the
worktree-id split yielded nothing and $HOME silently won. Resolve the spawn cwd
through worktreeId -> ORCA_WORKSPACE_ROOT -> host default, and refuse an agent
spawn outright when a folder workspace names a root this host cannot resolve.

#11733 ssh: generalize the NVM dotfile scrape into `orca_dotfile_dirs` and drive
mise off `MISE_DATA_DIR` / `XDG_DATA_HOME` instead of a hardcoded
`$HOME/.local/share/mise`.

#13713 ai-vault: an unresolvable workspace host is `unverifiable`, not local.
Widen the default scope to every host rather than scanning the client's own
history and reporting "No agent sessions found".

#6106 terminal: hydration asked the renderer for `scrollback: 0` while an
alt-screen TUI was up, which drops the normal buffer's shell history rather than
the TUI bytes. Drop the flag; readers already split the two buffers apart.

* fix(remote): stop the relay answering host questions for a guest execution host

Three findings from review of the spawn-cwd resolver, all the same shape: a path
question answered against the wrong host, or with the wrong key.

- resolveRelaySpawnCwd refused an agent launch whenever a folder workspace named
  a root that did not stat on the relay. But relayHostDirectoryExists stats the
  relay's *own* filesystem, and the relay supports WSL shells, so a folder
  workspace on a Windows relay launching into WSL now threw where it previously
  spawned -- contradicting the function's own doc comment, which says an absent
  path for that exact host pair is a miss, not a refusal. Thread the shell's
  execution host in and demote the refusal to a miss when the spawn does not run
  on the relay's filesystem.

- requireRelaySpawnCwd's doc claims both call sites route through one resolver
  so the fence can never be keyed on a directory the spawn won't use, but the
  fence key was still computed with the non-stripping splitWorktreeId while the
  cwd used splitWorktreeIdForFilesystem. For a `::workspace:<uuid>` id those
  disagree by construction, in adjacent lines: the removal fence guarded a path
  no spawn ever enters. Same defect in shutdownForWorktreePath and the revive
  path; all three now use the filesystem split.

- The remote Node probe expanded `$HOME` and `~/` prefixes out of a dotfile
  assignment but not `$XDG_DATA_HOME`, so `MISE_DATA_DIR=$XDG_DATA_HOME/...`
  was used as a literal directory name. Add the case arm, defaulting to the
  POSIX `$HOME/.local/share` the seed value already uses -- sshd's exec channel
  usually has no XDG_DATA_HOME at all.
2026-09-02 21:33:41 -07:00

688 lines
27 KiB
TypeScript

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'
import { getRemoteHostPlatform } from './ssh-remote-platform'
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
function decodePowerShellCommand(command: string): string {
const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)
if (!match) {
throw new Error(`No encoded PowerShell command found in: ${command}`)
}
return Buffer.from(match[1]!, 'base64').toString('utf16le')
}
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('skips an incomplete system Node and selects a complete NVM toolchain', async () => {
execCommandMock
.mockResolvedValueOnce('/usr/bin/node\n/home/u/.nvm/versions/node/v22.22.0/bin/node\n')
.mockRejectedValueOnce(new Error('/usr/bin/npm: not found'))
.mockResolvedValueOnce('__ORCA_NODE_VERSION__\nv22.22.0\n__ORCA_NPM_VERSION__\n11.13.0\n')
await expect(resolveRemoteNodePath(conn)).resolves.toBe(
'/home/u/.nvm/versions/node/v22.22.0/bin/node'
)
expect(execCommandMock.mock.calls[1]![1]).toContain("PATH='/usr/bin':$PATH npm --version")
expect(execCommandMock.mock.calls[2]![1]).toContain(
"PATH='/home/u/.nvm/versions/node/v22.22.0/bin':$PATH npm --version"
)
})
it.runIf(process.platform !== 'win32')(
'accepts npm elsewhere on PATH without probing another Node candidate',
async () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'orca-split-node-npm-'))
try {
const nodePath = path.join(root, 'selected node', 'bin', 'node')
const npmBinDir = path.join(root, 'npm elsewhere', 'bin')
mkdirSync(path.dirname(nodePath), { recursive: true })
mkdirSync(npmBinDir, { recursive: true })
writeFileSync(nodePath, '#!/bin/sh\nprintf "v22.22.0\\n"\n')
writeFileSync(path.join(npmBinDir, 'npm'), '#!/bin/sh\nprintf "11.13.0\\n"\n')
chmodSync(nodePath, 0o755)
chmodSync(path.join(npmBinDir, 'npm'), 0o755)
execCommandMock
.mockResolvedValueOnce(`${nodePath}\n${path.join(root, 'fallback', 'bin', 'node')}\n`)
.mockImplementationOnce((_conn: SshConnection, command: string) =>
Promise.resolve(
execFileSync('/bin/sh', ['-c', command], {
encoding: 'utf8',
env: { HOME: root, PATH: npmBinDir }
})
)
)
await expect(resolveRemoteNodePath(conn)).resolves.toBe(nodePath)
// One inventory exec plus one candidate probe keeps SSH startup work bounded.
expect(execCommandMock).toHaveBeenCalledTimes(2)
} finally {
rmSync(root, { recursive: true, force: true })
}
}
)
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('orca_dotfile_dirs NVM_DIR')
expect(callScript).toContain('"$nvm_dir"/versions/node/*/bin/node')
})
it('respects a custom MISE_DATA_DIR instead of hardcoding $HOME/.local/share/mise', async () => {
execCommandMock
.mockResolvedValueOnce('/opt/mise-data/installs/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('mise_dirs=${MISE_DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}')
expect(callScript).toContain('orca_dotfile_dirs MISE_DATA_DIR')
expect(callScript).toContain('"$mise_dir"/installs/node/*/bin/node')
expect(callScript).toContain('"$mise_dir/shims/node"')
})
it('finds node under a MISE_DATA_DIR exported from a shell dotfile', async () => {
execCommandMock
.mockResolvedValueOnce('/home/u/.local/share/mise/shims/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-mise-probe-'))
try {
const shimPath = path.join(home, 'custom-mise/shims/node')
const installPath = path.join(home, 'custom-mise/installs/node/v20.11.0/bin/node')
for (const target of [shimPath, installPath]) {
mkdirSync(path.dirname(target), { recursive: true })
writeFileSync(target, '#!/bin/sh\nprintf "v20.11.0\\n"\n')
chmodSync(target, 0o755)
}
writeFileSync(path.join(home, '.zshrc'), 'export MISE_DATA_DIR=~/custom-mise\n')
const output = execFileSync('/bin/sh', ['-c', callScript], {
encoding: 'utf8',
env: { HOME: home, PATH: '/usr/bin:/bin' }
})
const lines = output.split('\n')
expect(lines).toContain(shimPath)
expect(lines).toContain(installPath)
} finally {
rmSync(home, { recursive: true, force: true })
}
})
it('finds node under a MISE_DATA_DIR present only in the probe environment', async () => {
execCommandMock
.mockResolvedValueOnce('/home/u/.local/share/mise/shims/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-mise-env-probe-'))
try {
const miseDataDir = path.join(home, 'env-mise')
const installPath = path.join(miseDataDir, 'installs/node/v20.11.0/bin/node')
mkdirSync(path.dirname(installPath), { recursive: true })
writeFileSync(installPath, '#!/bin/sh\nprintf "v20.11.0\\n"\n')
chmodSync(installPath, 0o755)
const output = execFileSync('/bin/sh', ['-c', callScript], {
encoding: 'utf8',
env: { HOME: home, MISE_DATA_DIR: miseDataDir, PATH: '/usr/bin:/bin' }
})
expect(output.split('\n')).toContain(installPath)
} finally {
rmSync(home, { recursive: true, force: true })
}
})
it('falls back to XDG_DATA_HOME for mise installs when MISE_DATA_DIR is unset', async () => {
execCommandMock
.mockResolvedValueOnce('/home/u/.local/share/mise/shims/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-mise-xdg-probe-'))
try {
const xdgDataHome = path.join(home, 'xdg')
const installPath = path.join(xdgDataHome, 'mise/installs/node/v20.11.0/bin/node')
mkdirSync(path.dirname(installPath), { recursive: true })
writeFileSync(installPath, '#!/bin/sh\nprintf "v20.11.0\\n"\n')
chmodSync(installPath, 0o755)
const output = execFileSync('/bin/sh', ['-c', callScript], {
encoding: 'utf8',
env: { HOME: home, XDG_DATA_HOME: xdgDataHome, PATH: '/usr/bin:/bin' }
})
expect(output.split('\n')).toContain(installPath)
} finally {
rmSync(home, { recursive: true, force: true })
}
})
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/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('probes fnm XDG data directory installs', async () => {
execCommandMock
.mockResolvedValueOnce(
'/home/u/.local/share/fnm/node-versions/v24.18.0/installation/bin/node\n'
)
.mockResolvedValueOnce('v24.18.0\n')
await expect(resolveRemoteNodePath(conn)).resolves.toBe(
'/home/u/.local/share/fnm/node-versions/v24.18.0/installation/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('expands an $XDG_DATA_HOME-relative MISE_DATA_DIR assignment from shell dotfiles', async () => {
execCommandMock
.mockResolvedValueOnce('/home/u/.local/share/mise/shims/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-xdg-probe-'))
try {
// A name the seeded `${XDG_DATA_HOME:-$HOME/.local/share}/mise` default cannot reach, so
// only the dotfile arm can find it.
const nodePath = path.join(home, 'xdg-data/custom-mise/installs/node/20.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 MISE_DATA_DIR=$XDG_DATA_HOME/custom-mise\n')
const output = execFileSync('/bin/sh', ['-c', callScript], {
encoding: 'utf8',
env: {
HOME: home,
PATH: '/usr/bin:/bin',
XDG_DATA_HOME: path.join(home, 'xdg-data')
}
})
expect(output.split('\n')).toContain(nodePath)
} finally {
rmSync(home, { recursive: true, force: true })
}
})
it('falls back to the POSIX default when an $XDG_DATA_HOME assignment has no env value', async () => {
execCommandMock
.mockResolvedValueOnce('/home/u/.local/share/mise/shims/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-xdg-default-probe-'))
try {
// sshd's exec channel runs without the profile, so XDG_DATA_HOME is often simply absent.
const nodePath = path.join(home, '.local/share/custom-mise/installs/node/20.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 MISE_DATA_DIR=$XDG_DATA_HOME/custom-mise\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
})
})
it('uses -c (not -lc) for a csh login shell, which rejects combined -lc', async () => {
// Why: csh/tcsh reject `-lc` and mis-handle `-l` here ("Bad : modifier",
// issue #8701), so the login-shell probe must drop to plain `-c`.
execCommandMock
.mockResolvedValueOnce('\n') // path probe: empty
.mockResolvedValueOnce('/bin/csh\n') // $SHELL
.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/csh' -c 'command -v node'`, {
wrapCommand: false,
timeoutMs: 8_000
})
})
it('uses -c (not -lc) for a tcsh login shell', async () => {
execCommandMock
.mockResolvedValueOnce('\n') // path probe: empty
.mockResolvedValueOnce('/usr/bin/tcsh\n') // $SHELL
.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,
`'/usr/bin/tcsh' -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
.mockResolvedValueOnce('apt-get\n') // package manager hint probe
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/sudo apt-get install -y nodejs npm/)
})
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
.mockRejectedValueOnce(new Error('package manager probe failed'))
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Debian\/Ubuntu:/)
})
it('keeps the default path-probe fallback for SSH session-limit-shaped errors', async () => {
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
reason: 4
})
execCommandMock
.mockRejectedValueOnce(sessionLimitError)
.mockResolvedValueOnce('/bin/bash')
.mockResolvedValueOnce('\n')
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/Node\.js not found/)
})
it('rethrows SSH session-limit errors from the path probe in strict mode', async () => {
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
reason: 4
})
execCommandMock.mockRejectedValueOnce(sessionLimitError)
await expect(
resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true })
).rejects.toBe(sessionLimitError)
})
it('rethrows SSH session-limit errors from candidate version checks in strict mode', async () => {
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
reason: 4
})
execCommandMock
.mockResolvedValueOnce('/usr/local/bin/node\n')
.mockRejectedValueOnce(sessionLimitError)
await expect(
resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true })
).rejects.toBe(sessionLimitError)
})
it('rethrows SSH session-limit errors from login-shell resolution in strict mode', async () => {
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
reason: 4
})
execCommandMock.mockResolvedValueOnce('\n').mockRejectedValueOnce(sessionLimitError)
await expect(
resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true })
).rejects.toBe(sessionLimitError)
})
it('rethrows SSH session-limit errors from package-manager hint probing in strict mode', async () => {
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
reason: 4
})
execCommandMock
.mockResolvedValueOnce('\n')
.mockResolvedValueOnce('/bin/bash')
.mockResolvedValueOnce('\n')
.mockRejectedValueOnce(sessionLimitError)
await expect(
resolveRemoteNodePath(conn, undefined, { rethrowSessionLimitErrors: true })
).rejects.toBe(sessionLimitError)
})
it('rethrows SSH session-limit errors from Windows node resolution in strict mode', async () => {
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
reason: 4
})
execCommandMock.mockRejectedValueOnce(sessionLimitError)
await expect(
resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), {
rethrowSessionLimitErrors: true
})
).rejects.toBe(sessionLimitError)
})
it('rethrows SSH session-limit errors from Windows version checks in strict mode', async () => {
const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), {
reason: 4
})
execCommandMock
.mockResolvedValueOnce('C:\\Program Files\\nodejs\\node.exe\n')
.mockRejectedValueOnce(sessionLimitError)
await expect(
resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'), {
rethrowSessionLimitErrors: true
})
).rejects.toBe(sessionLimitError)
})
it('surfaces an AbortError instead of not-found when the shared signal is aborted', async () => {
// Why: in concurrent bootstrap a sibling probe aborts this resolution via
// the shared signal. Each strategy catch swallows the injected AbortError,
// so without re-raising it here the resolver would launder cancellation
// into a fatal "Node.js not found" and defeat the sequential fallback.
const controller = new AbortController()
const abortError = Object.assign(new Error('SSH operation was cancelled'), {
name: 'AbortError'
})
execCommandMock.mockImplementation(() => {
controller.abort()
return Promise.reject(abortError)
})
await expect(
resolveRemoteNodePath(conn, undefined, { signal: controller.signal })
).rejects.toMatchObject({ name: 'AbortError' })
})
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
.mockResolvedValueOnce('dnf\n') // package manager hint probe
await expect(resolveRemoteNodePath(conn)).rejects.toThrow(/sudo dnf install -y nodejs npm/)
})
it('rejects a Windows node.exe below the minimum version', async () => {
execCommandMock
.mockResolvedValueOnce('C:\\Program Files\\nodejs\\node.exe\n') // Get-Command / common paths
.mockResolvedValueOnce('v16.20.2\n') // version check: too old
await expect(resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'))).rejects.toThrow(
/winget install OpenJS\.NodeJS\.LTS/
)
})
it('does not stop Windows node discovery after the first existing candidate', async () => {
execCommandMock
.mockResolvedValueOnce('C:\\old\\node.exe\nC:\\Program Files\\nodejs\\node.exe\n')
.mockResolvedValueOnce('v16.20.2\n')
.mockResolvedValueOnce('v20.11.0\n')
await expect(resolveRemoteNodePath(conn, getRemoteHostPlatform('win32-x64'))).resolves.toBe(
'C:/Program Files/nodejs/node.exe'
)
const discoveryScript = decodePowerShellCommand(execCommandMock.mock.calls[0]![1] as string)
expect(discoveryScript).not.toMatch(/Write-Output \$path\s+exit 0/)
})
})