Files
orca/src/main/ssh/ssh-remote-node-resolution.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

297 lines
10 KiB
TypeScript

import type { SshConnection } from './ssh-connection'
import { createSshOperationAbortError } 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 {
buildPosixNodeInstallGuidance,
type RemoteNodeResolutionOptions
} from './ssh-remote-node-install-guidance'
import { execCommand } from './ssh-relay-deploy-helpers'
import {
buildPosixNodeToolchainProbe,
buildWindowsNodeToolchainProbe,
nodeToolchainVersionsMeetRequirements
} from './ssh-remote-node-toolchain-probe'
import { isSshSessionLimitError } from './ssh-session-limit-error'
import { buildSshLoginShellCommand } from './ssh-login-shell-command'
import { REMOTE_NODE_PATH_PROBE_SCRIPT } from './ssh-remote-node-probe-script'
// 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
export async function resolveRemoteNodePath(
conn: SshConnection,
host?: RemoteHostPlatform,
options?: RemoteNodeResolutionOptions
): Promise<string> {
if (host && isWindowsRemoteHost(host)) {
return resolveRemoteWindowsNodePath(conn, options)
}
// 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, options)
if (probedPath) {
return probedPath
}
// 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, options)
if (loginShellPath) {
return loginShellPath
}
return throwNodeNotFound(conn, options)
}
// 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 with a complete Node/npm toolchain.
async function tryResolveViaKnownPaths(
conn: SshConnection,
options?: RemoteNodeResolutionOptions
): Promise<string | null> {
const script = REMOTE_NODE_PATH_PROBE_SCRIPT
try {
const result = await execCommandWithOptionalOptions(conn, script, signalOnlyOptions(options))
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 nodeToolchainMeetsRequirements(conn, candidate, options)) {
console.log(`[ssh-relay] Found node via path probe: ${candidate}`)
return candidate
}
}
} catch (err) {
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
throw err
}
throwIfAborted(options)
// 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,
options?: RemoteNodeResolutionOptions
): 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}"',
commandOptions({ timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS }, options)
)
const shell = shellResult.trim().split('\n')[0]
if (!shell) {
return null
}
const nodePath = await execCommand(
conn,
buildSshLoginShellCommand(shell, 'command -v node'),
commandOptions({ wrapCommand: false, timeoutMs: LOGIN_SHELL_PROBE_TIMEOUT_MS }, options)
)
const candidate = nodePath.trim().split('\n')[0]
if (!candidate) {
return null
}
if (await nodeToolchainMeetsRequirements(conn, candidate, options)) {
console.log(`[ssh-relay] Found node via login shell (${shell}): ${candidate}`)
return candidate
}
} catch (err) {
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
throw err
}
throwIfAborted(options)
// Fall through.
}
return null
}
// Validates the same PATH-prepend + bare npm contract used during deployment.
// This rejects missing npm (#8450) without requiring colocation (#9165).
// Caches nothing — this runs at most a few times per resolution (one per
// candidate), and the exec round-trip dominates.
async function nodeToolchainMeetsRequirements(
conn: SshConnection,
nodePath: string,
options?: RemoteNodeResolutionOptions
): Promise<boolean> {
try {
const versionOutput = await execCommand(
conn,
buildPosixNodeToolchainProbe(nodePath),
// Why: the paired probe uses POSIX PATH assignment syntax, which fish
// and csh cannot parse when sshd delegates directly to the login shell.
commandOptions({ wrapCommand: true }, options)
)
return nodeToolchainVersionsMeetRequirements(versionOutput)
} catch (err) {
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
throw err
}
throwIfAborted(options)
// Binary missing or fails to run — not usable.
return false
}
}
async function resolveRemoteWindowsNodePath(
conn: SshConnection,
options?: RemoteNodeResolutionOptions
): Promise<string> {
const script = [
'$paths = @()',
'$cmd = Get-Command node.exe -ErrorAction SilentlyContinue',
'if ($cmd -and $cmd.Source) { $paths += $cmd.Source }',
'if ($env:ProgramFiles) { $paths += (Join-Path $env:ProgramFiles "nodejs/node.exe") }',
'if (${env:ProgramFiles(x86)}) { $paths += (Join-Path ${env:ProgramFiles(x86)} "nodejs/node.exe") }',
'if ($env:LOCALAPPDATA) { $paths += (Join-Path $env:LOCALAPPDATA "Programs/nodejs/node.exe") }',
'$found = $false',
'foreach ($path in $paths) {',
' if ($path -and (Test-Path -LiteralPath $path -PathType Leaf)) {',
' Write-Output $path',
' $found = $true',
' }',
'}',
'if ($found) { exit 0 }',
"Write-Error 'Node.js not found'",
'exit 1'
].join('\n')
try {
const result = await execCommand(
conn,
powerShellCommand(script),
commandOptions({ wrapCommand: false }, options)
)
for (const line of result.split('\n')) {
const nodePath = line.trim()
if (!nodePath) {
continue
}
const normalized = normalizeWindowsRemotePath(nodePath)
if (await windowsNodeToolchainMeetsRequirements(conn, normalized, options)) {
console.log(`[ssh-relay] Found Windows node at: ${normalized}`)
return normalized
}
}
} catch (err) {
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
throw err
}
throwIfAborted(options)
// Fall through to the shared error below.
}
throwWindowsNodeNotFound(options)
}
async function windowsNodeToolchainMeetsRequirements(
conn: SshConnection,
nodePath: string,
options?: RemoteNodeResolutionOptions
): Promise<boolean> {
try {
const versionOutput = await execCommand(
conn,
powerShellCommand(buildWindowsNodeToolchainProbe(nodePath)),
commandOptions({ wrapCommand: false }, options)
)
return nodeToolchainVersionsMeetRequirements(versionOutput)
} catch (err) {
if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) {
throw err
}
throwIfAborted(options)
return false
}
}
async function throwNodeNotFound(
conn: SshConnection,
options?: RemoteNodeResolutionOptions
): Promise<never> {
throwIfAborted(options)
const guidance = await buildPosixNodeInstallGuidance(conn, options)
throwIfAborted(options)
throw new Error(guidance)
}
function throwWindowsNodeNotFound(options?: RemoteNodeResolutionOptions): never {
throwIfAborted(options)
throw new Error(
[
'Node.js not found on remote host. Orca relay requires Node.js 18+ and npm.',
'',
'Install Node.js 18+ on the remote host, then reconnect:',
' winget install OpenJS.NodeJS.LTS',
' choco install nodejs-lts',
'',
'Verify the remote runtime before reconnecting:',
' node --version # must be v18 or newer',
' npm --version',
'',
'If those package managers are unavailable, install an LTS release from https://nodejs.org/.'
].join('\n')
)
}
function throwIfAborted(options?: RemoteNodeResolutionOptions): void {
// Why: strategy fallbacks intentionally swallow probe failures, but a shared
// bootstrap abort must stay an AbortError so callers can continue fallback.
if (options?.signal?.aborted) {
throw createSshOperationAbortError()
}
}
function signalOnlyOptions(
options?: RemoteNodeResolutionOptions
): { signal: AbortSignal } | undefined {
return options?.signal ? { signal: options.signal } : undefined
}
type RemoteExecOptions = {
wrapCommand?: boolean
timeoutMs?: number
signal?: AbortSignal
}
function commandOptions(
base: RemoteExecOptions,
options?: RemoteNodeResolutionOptions
): RemoteExecOptions {
return options?.signal ? { ...base, signal: options.signal } : base
}
async function execCommandWithOptionalOptions(
conn: SshConnection,
command: string,
options?: { signal: AbortSignal }
): Promise<string> {
return options ? execCommand(conn, command, options) : execCommand(conn, command)
}