mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
* fix(ssh): compile node-pty from the host's own Node headers instead of nodejs.org
STA-6674: a Linux SSH host that cannot reach nodejs.org never came up. node-pty
ships no Linux prebuild, so npm hands it to node-gyp, and node-gyp's default is
to download node-v<ver>-headers.tar.gz before configuring. The host refused
that connection (ECONNREFUSED) and the relay deploy failed inside npm install,
which the UI showed only as "Disconnected".
Every official Node build and every version manager that unpacks one already
has those exact headers at <prefix>/include/node. Export node-gyp's nodedir to
that prefix, on every command that can compile node-pty (npm install, npm
rebuild, the cloexec patch's rebuild), when the shipped node_version.h matches
the running Node. Both npm_config_nodedir (node-gyp 10, Node 20) and
npm_package_config_node_gyp_nodedir (node-gyp >= 11.4) are set so every Node
the relay runs on reads it. A version mismatch leaves it unset, which is the
existing behaviour.
When a host is both header-less and offline, name that in the deploy error
instead of forty lines of gyp http output, with the two remedies.
Reproduced and verified with a Docker sshd whose nodejs.org resolves to
127.0.0.1, on node:24.12.0 (the user's version), node:20 and node:26:
ssh-relay-offline-node-headers.docker.test.ts.
* fix(ssh): fail loudly when node-gyp ignores the exported Node headers dir
The headers export relies on npm forwarding npm_config_nodedir /
npm_package_config_node_gyp_nodedir into lifecycle scripts. If a future npm
drops that, node-gyp would silently fall back to downloading, and an offline
host would fail with the same "install an official Node" diagnosis -- wrong,
since the host did ship headers.
The prefix now echoes ORCA-NODE-HEADERS:<dir|none> into the command's output
before the compile, and the download-failure diagnosis reads it back: an
exported dir plus a download attempt is reported as an Orca defect naming
the dir, not as a host problem. Nothing else changes when it works.
* fix(ssh): address review on the relay node-headers export
- Unset any inherited npm_config_nodedir / npm_package_config_node_gyp_nodedir
before the conditional export, so a stale header dir from the remote profile
cannot bypass the version check and build a wrong-ABI binding (CodeRabbit).
- Require `gyp ERR! configure error` and a real network errno in the
headers-download matcher; node-gyp's fetch client logs retried attempts it
recovers from, and a FetchError can be a non-2xx mirror answer (pullfrog).
- Say "no local headers matching its own version", since the probe also
rejects a version mismatch, not only absent headers (CodeRabbit).
- Log the same diagnosis from the non-fatal `npm rebuild` fallback (CodeRabbit).
- Docker test waits for the SSH banner on the mapped port before connecting
instead of trusting `docker run -d` (CodeRabbit).
* fix(ssh): read the node-headers marker from the host output, not the quoted command
execCommand rejects with `Command "<command>" failed (exit N): <output>`, and
<command> quotes the whole prefix, marker echo included. The first-match
parser hit that copy and returned `${ORCA_NODE_HEADERS_DIR:-none}"; ...` as a
"dir", so every real no-headers failure was misreported as an Orca defect
(measured by an independent Docker exercise of 609685e). Strip the exec-
failure head before scanning; keep first-match so gyp output cannot spoof it.
The unit fixture hid this by rejecting with `Command "npm install" failed`,
a string production never builds. It now rejects from the command the mock
actually received, and the Docker test gains a no-headers failure case on the
same offline fixture that asserts the host-remedy message.
Also unset NPM_CONFIG_NODEDIR (npm accepts either case), and narrow the claim:
a ~/.npmrc nodedir= is not overridable from the env (measured: empty env
override is ignored on npm 10 and 11), so it stays the operator's setting.
Copy the node binary via fs in the unit test so a failed copy fails the test.
* docs(ssh): state the header-mismatch refusal as a conservative default, not an observed crash
* docs(ssh): note why the exec-failure head regex may match lazily
98 lines
5.3 KiB
TypeScript
98 lines
5.3 KiB
TypeScript
/**
|
|
* Point node-gyp at the headers the host's Node install already ships, so compiling node-pty
|
|
* needs nothing from nodejs.org.
|
|
*
|
|
* Why: node-pty has no Linux prebuild, so every Linux relay compiles it, and node-gyp's default
|
|
* is to download `node-v<ver>-headers.tar.gz` before configuring. Every official Node build, and
|
|
* every version manager that unpacks one (nvm, fnm, volta, mise, n), already has those exact
|
|
* headers at `<prefix>/include/node`. The download was the only step that needed the internet,
|
|
* so a firewalled host failed with ECONNREFUSED on work that never had to happen (STA-6674).
|
|
*
|
|
* Why both variables: node-gyp >= 11.4 prefers `npm_package_config_node_gyp_<key>` and npm 11+
|
|
* warns that arbitrary `npm_config_<key>` is deprecated, but node-gyp 10 (bundled with Node 20)
|
|
* reads only `npm_config_<key>`. Both together cover every Node the relay runs on.
|
|
*
|
|
* Why the version check: node-gyp trusts `nodedir` blindly, so a distro `/usr/include/node` left
|
|
* by an older headers package would be compiled against as-is. Whether that binding then misbehaves
|
|
* is not established (one measured run loaded a node-20-header build under node 24); refusing is
|
|
* the conservative default. A mismatch leaves the variables unset, which is today's path.
|
|
*/
|
|
import { shellEscape } from './ssh-connection-utils'
|
|
|
|
/** Shell variable the probe answers into; namespaced so it cannot collide with npm's own. */
|
|
const NODEDIR_SHELL_VAR = 'ORCA_NODE_HEADERS_DIR'
|
|
|
|
/**
|
|
* Prints the running Node's install prefix when `<prefix>/include/node/node_version.h` matches
|
|
* `process.versions.node`, and nothing otherwise. `process.execPath` is symlink-resolved, so a
|
|
* `/usr/bin/node` -> `/opt/node/bin/node` shim still finds `/opt/node/include`.
|
|
*/
|
|
export const LOCAL_NODE_HEADERS_PROBE_JS = [
|
|
'const p=require("path"),f=require("fs");',
|
|
'const d=p.dirname(p.dirname(process.execPath));',
|
|
'try{',
|
|
'const h=f.readFileSync(p.join(d,"include","node","node_version.h"),"utf8");',
|
|
'const v=["MAJOR","MINOR","PATCH"].map(k=>(h.match(new RegExp("#define NODE_"+k+"_VERSION ([0-9]+)"))||[])[1]).join(".");',
|
|
'if(v===process.versions.node)process.stdout.write(d)',
|
|
'}catch{}'
|
|
].join('')
|
|
|
|
/**
|
|
* Stdout marker naming what the probe found, printed before the compile so the answer is in the
|
|
* captured output of any failure that follows. `none` means no matching local headers.
|
|
*/
|
|
export const LOCAL_NODE_HEADERS_MARKER_PREFIX = 'ORCA-NODE-HEADERS:'
|
|
|
|
/**
|
|
* POSIX-sh prefix (`...; `) that exports node-gyp's `nodedir` for the rest of the command line
|
|
* when the host's Node ships matching headers. Prepend to any command that may compile node-pty:
|
|
* `npm install`, `npm rebuild`, and the cloexec patch (its `npm rebuild` inherits the env).
|
|
*/
|
|
export function exportLocalNodeHeadersPrefix(nodePath: string): string {
|
|
const probe = `${shellEscape(nodePath)} -e ${shellEscape(LOCAL_NODE_HEADERS_PROBE_JS)} 2>/dev/null`
|
|
// Why the unset: a remote profile can already export a nodedir (a stale distro header dir), in
|
|
// either case npm accepts. Left alone it would bypass the version check above and compile
|
|
// against those headers. Deliberately env only: a `nodedir=` in ~/.npmrc is not reachable from here
|
|
// -- npm ignores an empty env override, and a CLI `--nodedir=` would also override the good
|
|
// export -- so an npmrc setting stays the operator's, as it was before this prefix existed.
|
|
return (
|
|
`${NODEDIR_SHELL_VAR}=$(${probe}); ` +
|
|
`unset npm_config_nodedir NPM_CONFIG_NODEDIR npm_package_config_node_gyp_nodedir; ` +
|
|
`if [ -n "$${NODEDIR_SHELL_VAR}" ]; then ` +
|
|
`export npm_config_nodedir="$${NODEDIR_SHELL_VAR}" npm_package_config_node_gyp_nodedir="$${NODEDIR_SHELL_VAR}"; ` +
|
|
`fi; ` +
|
|
`echo "${LOCAL_NODE_HEADERS_MARKER_PREFIX}\${${NODEDIR_SHELL_VAR}:-none}"; `
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The headers dir the prefix exported, `null` when it found none, or `undefined` when the
|
|
* marker is absent (output truncated, or the command never reached the prefix).
|
|
*/
|
|
export function localNodeHeadersFromOutput(output: string): string | null | undefined {
|
|
// Why the head is stripped first: a failed exec's message is `Command "<command>" failed
|
|
// (exit N): <output>`, and <command> quotes this prefix verbatim -- including the marker's
|
|
// `echo`. Scanning from the start would match that copy and return `${ORCA_NODE_HEADERS_DIR:-
|
|
// none}"...` as a "dir". Only what follows the head is the host's answer.
|
|
const head = output.match(EXEC_FAILURE_HEAD_RE)
|
|
const hostOutput = head ? output.slice(head[0].length) : output
|
|
// First match, not last: the host's own line comes first, and later lines are npm/gyp output
|
|
// that must not be able to spoof it.
|
|
for (const line of hostOutput.split(/\r?\n/)) {
|
|
const at = line.indexOf(LOCAL_NODE_HEADERS_MARKER_PREFIX)
|
|
if (at === -1) {
|
|
continue
|
|
}
|
|
const dir = line.slice(at + LOCAL_NODE_HEADERS_MARKER_PREFIX.length).trim()
|
|
return dir === 'none' || dir === '' ? null : dir
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* `Command "<anything, quotes included>" failed (exit N): ` -- see ssh-relay-exec-command.ts.
|
|
* Lazy `[\s\S]*?` is safe: it stops at the first `" failed (exit N): `, and no command this
|
|
* module builds contains that literal, so the match cannot end early inside the command.
|
|
*/
|
|
const EXEC_FAILURE_HEAD_RE = /^Command "[\s\S]*?" failed \(exit -?\d+\): /
|