Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/desktop
Steps to reproduce
- In a checkout of the commit under Version or commit with its dependencies installed, have Node 26 (or another Node that can import
.tsfiles) on PATH, and setNODE18to the path of a real Node 18.20.8 executable (for example from the official Node 18.20.8 release) andNODE24to a real Node 24.22.1 executable. No system Node needs replacing. - Save the script below as
repro.shoutside the checkout and run, from the checkout root:env -u NODE_ENV sh /path/to/repro.sh "$PWD" "$NODE18" "$NODE24". An optional fourth argument chooses the parent directory for its temporary fixture (defaultTMPDIR). The script writes nothing into the checkout, removes its fixture on exit, and closes the local HTTP server it starts. - The script builds two isolated fake remote homes. In
old-path, real Node 18 is first on PATH and is also the Volta default shim, while real Node 24 sits in the supported nvm versions layout (.nvm/versions/node/v24.22.1/bin). Incompatible-path, the same Node 24 directory is first on PATH. For each, it runs the generatedbuildRemoteNodeEnvScriptdiscovery under dash, then calls the exported productionlaunchOrReuseRemoteServerwithnodeScriptPathand the server's realengines.noderange, which is the option shape the development desktop runner selects. Ansshshim on PATH replaces only the SSH transport and login-profile loading: it pipes the complete, unmodified generated launch script into dash with the fixture home. A local HTTP readiness fixture lets the control exercise successful reuse of an already-running server without starting a real T3 server. - Compare
old-path launchwithcompatible-path launch. The script exits 0 only when the contradictory diagnostic appears in the old-path case and the compatible control succeeds.
Reproduction script: repro.sh
#!/bin/sh
set -eu
# Usage: sh repro.sh CHECKOUT NODE18 NODE24 [FIXTURE_PARENT]
# No network, source changes, or live state. All children are recorded and reaped.
checkout=$(cd "$1" && pwd)
node18=$2
node24=$3
parent=${4:-${TMPDIR:-/tmp}}
tmp=$(mktemp -d "$parent/ssh-node-repro.XXXXXX")
trap 'rm -rf "$tmp"' EXIT HUP INT TERM
mkdir -p "$tmp/driver-profile" "$tmp/tmp" "$tmp/t3-home"
ln -s "$checkout/packages/ssh/node_modules" "$tmp/node_modules"
cat > "$tmp/driver.mjs" <<'JS'
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import http from 'node:http';
import {spawnSync} from 'node:child_process';
import {pathToFileURL} from 'node:url';
import * as Effect from 'effect/Effect';
import * as Layer from 'effect/Layer';
import * as Result from 'effect/Result';
import * as References from 'effect/References';
import * as NodeServices from '@effect/platform-node/NodeServices';
import * as NodeCrypto from '@effect/platform-node/NodeCrypto';
const [checkout,node18,node24,root] = process.argv.slice(2);
const {buildRemoteNodeEnvScript,launchOrReuseRemoteServer} = await import(pathToFileURL(path.join(checkout,'packages/ssh/src/tunnel.ts')));
const {engines} = JSON.parse(fs.readFileSync(path.join(checkout,'apps/server/package.json'),'utf8'));
const range = engines.node;
const shell = fs.existsSync('/bin/dash') ? '/bin/dash' : '/bin/sh';
const version = n => spawnSync(n,['--version'],{encoding:'utf8'}).stdout.trim();
console.log(`range=${range}; old=${version(node18)}; managed=${version(node24)}; shell=${shell}`);
assert.match(version(node18),/^v18\./);
assert.match(version(node24),/^v24\./);
const shimBin = path.join(root,'ssh-bin'); fs.mkdirSync(shimBin);
// The shim replaces only SSH transport and login-profile loading. Stdin is the real,
// unmodified production launch script; it executes under dash with fixture HOME.
fs.writeFileSync(path.join(shimBin,'ssh'),`#!/bin/sh
set -eu
for key do :; done
cat > "$REMOTE_HOME/launch.sh"
exec env -i HOME="$REMOTE_HOME" TMPDIR="$REMOTE_HOME/tmp" T3CODE_HOME="$REMOTE_HOME/t3-home" PATH="$REMOTE_INITIAL_PATH" VOLTA_HOME="$REMOTE_HOME/.volta" ${shell} -s -- "$key" < "$REMOTE_HOME/launch.sh"
`,{mode:0o755});
process.env.PATH = `${shimBin}:${process.env.PATH}`;
const layer = Layer.mergeAll(NodeServices.layer,NodeCrypto.layer);
const target = {alias:'fixture-host',hostname:'fixture-host.invalid',username:null,port:null};
const missing = 'Remote host is missing node on PATH.';
const external = http.createServer((_,res)=>res.end('ready'));
await new Promise(resolve=>external.listen(0,'127.0.0.1',resolve));
const port = external.address().port;
// This HTTP fixture is owned by this driver, not an unrelated live server.
fs.writeFileSync(path.join(root,'owned.pid'),String(process.pid));
try {
for (const mode of ['old-path','compatible-path']) {
const home=path.join(root,mode);
for(const sub of ['tmp','t3-home','system/bin','.volta/bin','.nvm/versions/node/v24.22.1/bin','.t3/userdata']) fs.mkdirSync(path.join(home,sub),{recursive:true});
fs.symlinkSync(node18,path.join(home,'system/bin/node'));
fs.symlinkSync(node18,path.join(home,'.volta/bin/node'));
const nvmBin=path.join(home,'.nvm/versions/node/v24.22.1/bin');
fs.symlinkSync(node24,path.join(nvmBin,'node'));
// The Volta default shim also points at Node 18, so the old-path case selects
// Node 18 even on a host whose Homebrew bin directory (also searched) has another Node.
const initialPath = mode==='old-path' ? `${home}/system/bin:/usr/bin:/bin` : `${nvmBin}:${home}/system/bin:/usr/bin:/bin`;
const env={HOME:home,TMPDIR:path.join(home,'tmp'),T3CODE_HOME:path.join(home,'t3-home'),PATH:initialPath,VOLTA_HOME:path.join(home,'.volta')};
const generated=buildRemoteNodeEnvScript({nodeEngineRange:range});
const check=spawnSync(shell,['-c',`${generated}\nensure_remote_node_path\nrc=$?\nprintf 'selected=%s\\n' "$(node --version)"\nexit "$rc"`],{env,encoding:'utf8'});
console.log(`${mode} discovery: exit=${check.status}; ${check.stdout.trim()}`);
if (check.stderr) console.log(check.stderr.trim());
assert.equal(check.status,mode==='old-path'?1:0);
fs.writeFileSync(path.join(home,'.t3/userdata/server-runtime.json'),JSON.stringify({pid:process.pid,port,origin:`http://127.0.0.1:${port}`}));
process.env.REMOTE_HOME=home;
process.env.REMOTE_INITIAL_PATH=initialPath;
const result=await Effect.runPromise(launchOrReuseRemoteServer(target,undefined,{nodeScriptPath:path.join(checkout,'apps/server/dist/bin.mjs'),nodeEngineRange:range}).pipe(Effect.result,Effect.provide(layer),Effect.scoped,Effect.provideService(References.MinimumLogLevel,'None')));
if(Result.isFailure(result)) {
console.log(`${mode} launch: ${result.failure._tag}; exit=${result.failure.exitCode}`);
console.log(result.failure.message);
assert.equal(mode,'old-path'); assert.equal(result.failure.exitCode,1);
assert.match(result.failure.message,/Remote node 18\.20\.8 does not satisfy required range/);
assert.ok(result.failure.message.includes(missing));
} else {
console.log(`${mode} launch: success; kind=${result.success.remoteServerKind}; port matches=${result.success.remotePort===port}`);
assert.notEqual(mode,'old-path'); assert.equal(result.success.remotePort,port);
}
}
console.log('REPRODUCED: real incompatible Node is reported as missing; compatible PATH control passes.');
} finally {
await new Promise((resolve,reject)=>external.close(e=>e?reject(e):resolve()));
console.log('CLEANUP: owned HTTP fixture closed; no persistent subprocess was started.');
}
JS
cd "$checkout/packages/ssh"
env -u NODE_ENV HOME="$tmp/driver-profile" TMPDIR="$tmp/tmp" T3CODE_HOME="$tmp/t3-home" node "$tmp/driver.mjs" "$checkout" "$node18" "$node24" "$tmp"Expected behavior
When the selected remote Node exists but fails the engine range, the launch error identifies the incompatible version and required range without also stating that Node is missing from PATH. The production engine check already reports this condition precisely ("Remote node 18.20.8 does not satisfy required range ..."), and the troubleshooting section of REMOTE.md added with that check (#2504) lists the version-mismatch message as its own failure case, separate from node: command not found. A missing executable and an incompatible version are distinct conditions with different fixes. Automatic selection of another installed Node is not part of this expectation.
Actual behavior
When the remote node that the SSH launch selects exists but does not satisfy the server's engines.node range, the launch fails with two contradictory lines: a correct one naming the installed version and the required range, followed by "Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells." The second line misdiagnoses an incompatible Node as an absent one and points the user toward installing Node or configuring a version manager. Rejecting the incompatible Node is intended; only the extra missing-Node diagnostic is reported here. This report does not claim that the launch should switch to another installed Node: the troubleshooting documentation added with the engine check describes version-manager discovery only when Node is missing, and directs users with an incompatible version to fix their non-interactive default. Scope: the desktop uses this Node-dependent route only in development builds with T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH set; release desktop builds launch a self-contained archive that skips Node discovery, and standalone web cannot start an SSH launch without the desktop bridge.
Observed in the reproduction through the exported production launch function: launchOrReuseRemoteServer fails with SshCommandError, exit 1, whose message contains both lines:
Remote node 18.20.8 does not satisfy required range ^22.16 || ^23.11 || >=24.10.
Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.
The discovery step alone exits 1 with only the first line, so the second line is added by the outer launch conditional, which prints the missing-Node message for every ensure_remote_node_path failure, including an engine rejection. The SSH command wrapper keeps both lines in the error message. With the same Node 24 placed first on PATH, discovery exits 0 and the launch succeeds and reuses the fixture server on the expected port.
Source only, not observed in a running app: the desktop IPC handler forwards this failure to the web client, which prefixes it with "Could not prepare the SSH environment:", and the Add Environment dialog's error formatting removes wrappers but keeps both Node lines. The exact rendered text was not captured.
Evidence
- Expected source:
packages/ssh/src/tunnel.tslines 207-217 at the baseline (linked above) report a version mismatch as its own condition, and theREMOTE.mdSSH launch troubleshooting section introduced in fix(ssh): fix reconnect issues and node binary path resolution #2504 treats "does not satisfy the required range" separately from a missingnode. - Failure source: the reproduction output under Logs or stack traces, produced through the exported
launchOrReuseRemoteServer;packages/ssh/src/tunnel.tslines 588-596 at the baseline map everyensure_remote_node_pathfailure to the missing-Node message. - Evidence provenance: observed
- Local verification: reproduced
- Reproduction completeness: complete
The development desktop runner selects the Node-script route only when isDevelopment is true and T3CODE_DEV_REMOTE_T3_SERVER_ENTRY_PATH is set (resolveDesktopSshCliRunner); otherwise it uses the archive runner, which bypasses Node discovery. The reproduction replaces SSH transport and login-profile loading with a local shell, so it makes no claim about SSH network or authentication behavior. Focused existing launch and pairing tests in packages/ssh/src/tunnel.test.ts passed at the baseline (2 selected tests); they do not cover this diagnostic.
Related
Hypothesis, not established: the outer launch conditional cannot tell an absent executable from an engine rejection because both make ensure_remote_node_path return nonzero. The inline review on #2504 already pointed out this wording (review comment); the PR merged without changing it. Related: #2641 (merged) changed fnm initialization, not this message. Closed issue #3709 and closed, unmerged PR #3710 concern selecting another installed Node that satisfies the range, a separate behavior this report does not request. Open issue #13047 concerns fnm output evaluated through POSIX sh before this check. Open issue #32908 and open PR #33198 concern the separate device-host Node probe. Open PR #23934 changes remote profile loading and keeps this POSIX bootstrap. No open PR in a fresh search changes the missing-Node message or the conditional that prints it.
Restoration check
With the old-path fixture (real Node 18.20.8 selected, real Node 24.22.1 present only under the nvm versions directory), the launch still fails with exit 1 and still names Remote node 18.20.8 and the required range, but the error no longer contains "Remote host is missing node on PATH". Controls that must keep passing: with Node 24.22.1 first on PATH, the launch succeeds; with no node reachable at all, the launch still reports the missing-Node message. The script asserts the current defect and exits nonzero once it is fixed; reverse its missing assertion to use it as a regression check.
Impact
Cosmetic issue
P3 (supported): The launch correctly rejects the incompatible Node and prints the accurate version-mismatch line; the defect is an added contradictory line in the error text, with no demonstrated behavior change, reachable only through the development desktop Node-script route.
Version or commit
d9805b2; the same reproduction and passing control also ran on upstream main at 1001bc7, and the two messages and the outer conditional are unchanged on main at 5cff012 (source read, not rerun there).
Environment
Darwin arm64; POSIX /bin/dash as the remote shell; Node 26.12.1 as the local driver (it imports the TypeScript sources directly); official Node 18.20.8 darwin-arm64 build as the incompatible runtime; Node 24.22.1 as the compatible runtime. Engine range from apps/server/package.json: ^22.16 || ^23.11 || >=24.10. Function-level reproduction through the exported SSH launcher with the development desktop Node-script options; no Electron app, GUI, or real SSH host was run.
Logs or stack traces
d9805b2ca667be7caec8c9035950564ab96704c3
range=^22.16 || ^23.11 || >=24.10; old=v18.20.8; managed=v24.22.1; shell=/bin/dash
old-path discovery: exit=1; selected=v18.20.8
Remote node 18.20.8 does not satisfy required range ^22.16 || ^23.11 || >=24.10.
old-path launch: SshCommandError; exit=1
Remote node 18.20.8 does not satisfy required range ^22.16 || ^23.11 || >=24.10.
Remote host is missing node on PATH. Install Node or configure a supported version manager for non-interactive shells.
compatible-path discovery: exit=0; selected=v24.22.1
compatible-path launch: success; kind=external; port matches=true
REPRODUCED: real incompatible Node is reported as missing; compatible PATH control passes.
CLEANUP: owned HTTP fixture closed; no persistent subprocess was started.
exit=0
Screenshots, recordings, or supporting files
No response
Workaround
Putting a compatible Node first on the remote's non-interactive PATH made the same production launch succeed in the reproduction, and the accurate version-mismatch line already tells the user what to fix.
Activity