fix(ssh): bypass npm init for content-hashed relay dirs and harden install probe

The versioned-install dirs land at `relay-${version}+${hash}/` (e.g.
`relay-0.1.0+07994a7870e1`). npm 11 / Node 26 reject the `+` in derived
package names and `npm init -y` exits 1 — silently, since both stderr
and the failure landed inside the `2>/dev/null && ...` chain. The catch
swallowed the throw, `.install-complete` was written anyway, and every
reconnect surfaced 'node-pty is not available' at first pty.spawn.

Sidestep `npm init` entirely: SFTP-write a hardcoded minimal
package.json (`name: orca-relay`, `type: commonjs`) and run
`npm install node-pty` directly. `type: commonjs` pins the module
system against future Node default flips or remote-side .npmrc overrides.

Also harden the install path against the same class of silent failure:
- npm install errors now propagate (no more `.install-complete` on hard
  fail; future reconnects retry instead of stranding the user)
- Replace the weak `test -d node-pty` post-install probe with
  `node -e 'require("node-pty")'` so built-but-unloadable installs
  (missing prebuild, wrong arch, broken native binding) surface clearly
- Add a session-level error handler on the SFTP write so a torn-down
  session rejects the promise instead of hanging until enclosing timeout

Separate fix: add `for-each-ref` to the relay's git subcommand allowlist.
Client code (`src/main/git/repo.ts` ref-search and worktree-listing)
calls `git for-each-ref` over SSH; the relay was rejecting it. The
`--shell`/`--python`/`--perl`/`--tcl` format flags only control output
quoting (no eval) and the relay invokes git via execFileAsync (no shell),
so the read-only allowlist treatment matches `rev-parse`, `log`, etc.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-11 03:18:22 -04:00
co-authored by Orca
parent 84e9be3ad1
commit 963f56d724
5 changed files with 383 additions and 16 deletions
@@ -56,6 +56,7 @@ function makeMockConnection(): SshConnection {
}),
sftp: vi.fn().mockResolvedValue({
mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)),
on: vi.fn(),
createWriteStream: vi.fn().mockReturnValue({
on: vi.fn((_event: string, cb: () => void) => {
if (_event === 'close') {
@@ -97,6 +98,7 @@ describe('cross-version isolation', () => {
'', // chmod +x node
'', // npm install
'', // chmod prebuilds
'OK', // node-pty probe (post-install verify)
'', // touch .install-complete (finalizeInstall)
'', // rm -rf .install-lock
'DEAD', // launch socket probe
+74 -15
View File
@@ -216,9 +216,9 @@ async function uploadRelay(
}
// Why: node-pty is a native addon that can't be bundled by esbuild. It must
// be compiled on the remote host against its Node.js version and OS. We run
// `npm init -y && npm install node-pty` in the relay directory so
// `require('node-pty')` resolves to the local node_modules.
// be compiled on the remote host against its Node.js version and OS. We
// write a minimal package.json + run `npm install node-pty` in the relay
// directory so `require('node-pty')` resolves to the local node_modules.
async function installNativeDeps(conn: SshConnection, remoteDir: string): Promise<void> {
const nodePath = await resolveRemoteNodePath(conn)
// Why: node's bin directory must be in PATH for npm's child processes.
@@ -228,24 +228,83 @@ async function installNativeDeps(conn: SshConnection, remoteDir: string): Promis
const nodeBinDir = nodePath.replace(/\/node$/, '')
const escapedDir = shellEscape(remoteDir)
const escapedBinDir = shellEscape(nodeBinDir)
const escapedNode = shellEscape(nodePath)
// Why: we previously ran `npm init -y` to bootstrap package.json, but npm's
// name validation rejects content-hashed dir names like
// `relay-0.1.0+07994a7870e1` (the `+` is invalid in an npm package name)
// and exits 1 — silently, since both `npm init`'s stderr and the failure
// landed inside the `2>/dev/null && ...` chain. Sidestep npm init entirely
// by writing a minimal hardcoded-name package.json over SFTP. `type:commonjs`
// pins the module system so a future Node default flip (or remote ~/.npmrc
// with type=module) cannot silently break `require('node-pty')`.
const pkgJson = `${JSON.stringify({
name: 'orca-relay',
version: '1.0.0',
private: true,
type: 'commonjs'
})}\n`
const sftpPkg = await conn.sftp()
try {
await new Promise<void>((resolve, reject) => {
const ws = sftpPkg.createWriteStream(`${remoteDir}/package.json`)
// Why: also wire the SFTP session's own error event — a session-level
// tear-down between createWriteStream and the stream's close/error
// would otherwise leave this promise hanging until enclosing timeouts.
sftpPkg.on('error', reject)
ws.on('close', resolve)
ws.on('error', reject)
ws.end(pkgJson)
})
} finally {
sftpPkg.end()
}
try {
await execCommand(
conn,
`export PATH=${escapedBinDir}:$PATH && cd ${escapedDir} && npm init -y --silent 2>/dev/null && npm install node-pty 2>&1`
)
// Why: SFTP uploads preserve file content but not Unix execute bits.
// node-pty ships a prebuilt `spawn-helper` binary that must be executable
// for posix_spawnp to fork the PTY process.
await execCommand(
conn,
`find ${shellEscape(`${remoteDir}/node_modules/node-pty/prebuilds`)} -name spawn-helper -exec chmod +x {} + 2>/dev/null; true`
`export PATH=${escapedBinDir}:$PATH && cd ${escapedDir} && npm install node-pty 2>&1`
)
} catch (err) {
// Why: node-pty install can fail if build tools (python, make, g++) are
// missing on the remote. Log the error but don't block relay startup —
// the relay will degrade gracefully (pty.spawn returns an error).
console.warn('[ssh-relay] Failed to install node-pty:', (err as Error).message)
// Why: a hard `npm install` failure (no compiler, no network, registry
// unreachable, disk full) means node-pty truly is not available on this
// host. Surface a loud, greppable warning AND propagate so the caller's
// catch leaves the dir without `.install-complete`. Future reconnects
// will detect the partial install and retry — the previous behavior
// wrote `.install-complete` anyway, stranding users on broken installs.
const msg = (err as Error).message
console.warn(
`[ssh-relay][NPTY-INSTALL-FAIL] npm install node-pty failed at ${remoteDir}: ${msg}`
)
throw err
}
// Why: SFTP uploads preserve file content but not Unix execute bits.
// node-pty ships a prebuilt `spawn-helper` binary that must be executable
// for posix_spawnp to fork the PTY process.
await execCommand(
conn,
`find ${shellEscape(`${remoteDir}/node_modules/node-pty/prebuilds`)} -name spawn-helper -exec chmod +x {} + 2>/dev/null; true`
)
// Why: defense-in-depth load-test so a silent install regression (npm exits
// 0 but node-pty is unloadable: missing prebuild, wrong arch, broken
// native binding) surfaces in deploy logs immediately instead of hiding
// behind a generic "node-pty not available" at first pty.spawn. We use
// `node -e require()` rather than `test -d` so a built-but-unloadable
// state is also caught. SSH-channel failures of the probe itself are
// intentionally NOT swallowed — they bubble to the caller's catch so we
// never confuse "probe could not run" with "node-pty is missing".
const probeOutput = (
await execCommand(
conn,
`cd ${escapedDir} && ${escapedNode} -e 'require("node-pty"); console.log("OK")' 2>&1 || echo MISSING`
)
).trim()
if (!probeOutput.endsWith('OK')) {
console.warn(
`[ssh-relay][NPTY-MISSING] node-pty installed but require() failed at ${remoteDir}: ${probeOutput.slice(-500)}`
)
}
}
@@ -0,0 +1,296 @@
// Why: regression coverage for the npm-init bypass + node-pty load-test
// probe in `installNativeDeps`. The original "node-pty is not available"
// bug shipped because every layer that could have caught it (chained
// shell, redirected stderr, swallowing catch, weak presence probe) was
// silent. These tests pin the contract that:
// 1. package.json is written via SFTP BEFORE `npm install` runs (order)
// 2. `npm install` failures propagate so `.install-complete` is NOT
// written by the deploy caller
// 3. the post-install probe uses `node -e require()` (load-test, not
// mere directory presence) and warns clearly on MISSING
// 4. SSH-channel failures of the probe itself are NOT swallowed
// (no `.catch(() => 'MISSING')` confusion)
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: { getAppPath: () => '/mock/app' }
}))
vi.mock('fs', () => ({
existsSync: vi.fn().mockReturnValue(true),
readFileSync: vi.fn().mockReturnValue('0.1.0+testhash')
}))
vi.mock('./relay-protocol', () => ({
RELAY_VERSION: '0.1.0',
RELAY_REMOTE_DIR: '.orca-remote',
parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'),
RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n',
RELAY_SENTINEL_TIMEOUT_MS: 10_000
}))
vi.mock('./ssh-relay-deploy-helpers', () => ({
uploadDirectory: vi.fn().mockResolvedValue(undefined),
waitForSentinel: vi.fn().mockResolvedValue({
write: vi.fn(),
onData: vi.fn(),
onClose: vi.fn()
}),
execCommand: vi.fn(),
resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node')
}))
vi.mock('./ssh-relay-versioned-install', () => ({
readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'),
computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`,
isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false),
acquireInstallLock: vi.fn().mockResolvedValue(undefined),
finalizeInstall: vi.fn().mockResolvedValue(undefined),
abandonInstall: vi.fn().mockResolvedValue(undefined),
gcOldRelayVersions: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('./ssh-connection-utils', () => ({
shellEscape: (s: string) => `'${s}'`
}))
import { deployAndLaunchRelay } from './ssh-relay-deploy'
import { execCommand } from './ssh-relay-deploy-helpers'
import { parseUnameToRelayPlatform } from './relay-protocol'
import { finalizeInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-install'
import type { SshConnection } from './ssh-connection'
type SftpWriteCapture = {
paths: string[]
contents: Record<string, string>
}
function makeMockConnection(capture: SftpWriteCapture): SshConnection {
const sftpCreate = (): unknown => ({
mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)),
on: vi.fn(),
createWriteStream: vi.fn().mockImplementation((path: string) => {
capture.paths.push(path)
let buf = ''
let closeCb: (() => void) | undefined
return {
on: vi.fn((event: string, cb: () => void) => {
if (event === 'close') {
closeCb = cb
}
}),
end: vi.fn((data?: string) => {
if (typeof data === 'string') {
buf += data
}
capture.contents[path] = buf
if (closeCb) {
setTimeout(closeCb, 0)
}
})
}
}),
end: vi.fn()
})
return {
exec: vi.fn().mockResolvedValue({
on: vi.fn(),
stderr: { on: vi.fn() },
stdin: {},
stdout: { on: vi.fn() },
close: vi.fn()
}),
sftp: vi.fn().mockImplementation(() => Promise.resolve(sftpCreate()))
} as unknown as SshConnection
}
type ExecResponse = string | { reject: string }
// Why: actual call order under our mocks is:
// 1: uname 2: $HOME 3: mkdir remoteDir (uploadRelay)
// 4: chmod +x node 5: npm install 6: chmod prebuilds
// 7: probe 8: socket DEAD 9: socket READY
function makeExecResponses(opts: {
npmInstall: 'ok' | { reject: string }
probe: 'ok' | 'missing' | { reject: string }
}): ExecResponse[] {
return [
'Linux x86_64',
'/home/u',
'', // mkdir remoteDir (uploadRelay)
'', // chmod +x node
opts.npmInstall === 'ok' ? '' : opts.npmInstall,
'', // chmod prebuilds
opts.probe === 'ok'
? 'OK'
: opts.probe === 'missing'
? 'require error: Cannot find module\nMISSING'
: opts.probe,
'DEAD',
'READY'
]
}
describe('installNativeDeps (via deployAndLaunchRelay)', () => {
let warnSpy: ReturnType<typeof vi.spyOn>
const sftpCapture: SftpWriteCapture = { paths: [], contents: {} }
beforeEach(() => {
vi.clearAllMocks()
// Why: tests that throw mid-deploy leave unconsumed `mockResolvedValueOnce`
// entries in the execCommand queue, which then leak into the next test
// and cause it to consume `Linux x86_64` from the wrong slot. Reset to
// wipe the queue between tests.
vi.mocked(execCommand).mockReset()
sftpCapture.paths.length = 0
for (const k of Object.keys(sftpCapture.contents)) {
delete sftpCapture.contents[k]
}
// Why: vi.clearAllMocks wipes the mockReturnValue set in the factory,
// so re-prime the mocks each test.
vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64')
vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(false)
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
function feed(execResponses: ExecResponse[]): void {
const mockExec = vi.mocked(execCommand)
for (const r of execResponses) {
if (typeof r === 'string') {
mockExec.mockResolvedValueOnce(r)
} else {
mockExec.mockRejectedValueOnce(new Error(r.reject))
}
}
}
it('writes a hardcoded package.json BEFORE running npm install', async () => {
const conn = makeMockConnection(sftpCapture)
feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' }))
await deployAndLaunchRelay(conn)
const pkgPath = sftpCapture.paths.find((p) => p.endsWith('/package.json'))
expect(pkgPath, 'package.json must be written via SFTP').toBeTruthy()
const written = sftpCapture.contents[pkgPath as string]
expect(written).toBeTruthy()
const parsed = JSON.parse(written) as Record<string, unknown>
expect(parsed.name).toBe('orca-relay')
expect(parsed.version).toBe('1.0.0')
expect(parsed.private).toBe(true)
// Why: pin commonjs so a future Node default flip doesn't silently
// break `require('node-pty')`.
expect(parsed.type).toBe('commonjs')
const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c)
const npmInstallIdx = execCalls.findIndex((c) => c.includes('npm install node-pty'))
expect(npmInstallIdx).toBeGreaterThanOrEqual(0)
// The SFTP write resolves before deploy continues into npm install,
// so by the time `npm install` was queued, package.json must already be
// present in our capture.
expect(sftpCapture.contents[pkgPath as string]).toBeTruthy()
})
it('propagates a hard `npm install` failure so the deploy aborts before finalizeInstall', async () => {
const conn = makeMockConnection(sftpCapture)
feed(
makeExecResponses({
npmInstall: { reject: 'npm ERR! E404 Not Found node-pty' },
probe: 'ok'
})
)
await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/npm ERR/)
// The crucial regression: `.install-complete` must NOT have been written.
// Previously the catch swallowed the throw and finalizeInstall ran anyway.
expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled()
const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? ''))
expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-INSTALL-FAIL]'))).toBe(true)
})
it('warns clearly when node-pty installs but require() fails (built-but-unloadable)', async () => {
const conn = makeMockConnection(sftpCapture)
feed(makeExecResponses({ npmInstall: 'ok', probe: 'missing' }))
await deployAndLaunchRelay(conn)
// Probe failure is non-fatal (graceful degradation), but it MUST log the
// greppable token so a user filing a bug pastes something that points
// at the real cause.
const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? ''))
expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true)
// finalizeInstall still runs — relay can serve fs/git/preflight.
expect(vi.mocked(finalizeInstall)).toHaveBeenCalled()
})
it('lets a probe SSH-channel failure bubble up rather than silently mapping to MISSING', async () => {
const conn = makeMockConnection(sftpCapture)
feed(
makeExecResponses({
npmInstall: 'ok',
probe: { reject: 'SSH channel closed unexpectedly' }
})
)
await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/SSH channel/)
// We must NOT have logged NPTY-MISSING — that would conflate "probe
// could not run" with "node-pty is missing", which is exactly the
// class of bug the original outage came from.
const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? ''))
expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false)
expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled()
})
it('uses `node -e require()` rather than `test -d` so unloadable installs are caught', async () => {
const conn = makeMockConnection(sftpCapture)
feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' }))
await deployAndLaunchRelay(conn)
const probeCmds = vi
.mocked(execCommand)
.mock.calls.map(([, c]) => c)
.filter((c) => c.includes(`require("node-pty")`))
// Why: the probe shape must invoke the deployed node binary against
// require('node-pty'). A weaker probe (test -d) could pass even when
// the native binding load is broken.
expect(probeCmds.length).toBeGreaterThan(0)
expect(probeCmds[0]).toMatch(/node['"]?\s+-e/)
})
it('writes an idempotent package.json (same bytes on every install)', async () => {
// First install run.
const conn1 = makeMockConnection(sftpCapture)
feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' }))
await deployAndLaunchRelay(conn1)
const firstPath = sftpCapture.paths.find((p) => p.endsWith('/package.json')) as string
const first = sftpCapture.contents[firstPath]
// Reset capture, run again as if it were a fresh install of the same dir.
sftpCapture.paths.length = 0
for (const k of Object.keys(sftpCapture.contents)) {
delete sftpCapture.contents[k]
}
vi.mocked(execCommand).mockReset()
const conn2 = makeMockConnection(sftpCapture)
feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' }))
await deployAndLaunchRelay(conn2)
const secondPath = sftpCapture.paths.find((p) => p.endsWith('/package.json')) as string
const second = sftpCapture.contents[secondPath]
expect(second).toBe(first)
})
})
+10 -1
View File
@@ -28,7 +28,16 @@ describe('validateGitExecArgs', () => {
[['config', '--get-all', 'remote.origin.url']],
[['config', '--list']],
[['config', '-l']],
[['config', '--get-regexp', 'user']]
[['config', '--get-regexp', 'user']],
[['for-each-ref', '--format=%(refname)', 'refs/remotes']],
[
[
'for-each-ref',
'--format=%(refname)%00%(refname:short)',
'--sort=-committerdate',
'refs/heads/*foo*'
]
]
])('allows %j', (args) => {
expectAllowed(args)
})
+1
View File
@@ -17,6 +17,7 @@ const ALLOWED_GIT_SUBCOMMANDS = new Set([
'symbolic-ref',
'merge-base',
'ls-files',
'for-each-ref',
'config'
])
const CONFIG_READ_ONLY_FLAGS = new Set(['--get', '--get-all', '--list', '--get-regexp', '-l'])