[Bug]: SSH device prerequisite test accepts Node releases below the pinned tool minimum #35981
Send to my agentSummary#
The SSH device-host connection test accepts a Node release below the minimum required by the device tool it later installs. When npm enforces package engines, the test succeeds but device setup fails during installation. This was reproduced with isolated runtime fixtures and fake platform-tool probes; the next supported Node release passed both the prerequisite test and installation.
Before submitting#
- Related issues: open and closed issue/PR searches found no duplicate; related probe-diagnostic coverage is listed under Additional context.
- Removed credentials, tokens, private paths, and sensitive data.
Area#
apps/server
Observed behavior#
Running the generated SSH probe script on Node 22.11.0 with npm 10.9.0 exits 0 and reports the fake Android platform available. Installing the pinned agent-device@0.21.36 with npm_config_engine_strict=true under that same runtime exits 1 with EBADENGINE: the package requires Node >=22.12. Node 22.12.0 with the same npm version passes both checks. The probe scripts and package-install commands ran locally as substitutes for the remote subprocess boundary; no real SSH connection or device was exercised.
Reproduction#
- Use a checkout of T3 Code at the commit below, Node with built-in fetch, and tar on macOS or Linux (arm64/x64).
- Save the script below as
repro-node-floor.mjsand runnode repro-node-floor.mjs /path/to/t3code. - Observe the successful prerequisite probe followed by an engine rejection on Node 22.11.0, while the Node 22.12.0 control passes installation.
One complete run reproduced the failure. The script downloads official Node archives and verifies their hashes against the release checksums, extracts the actual generated probe body from the checkout, supplies fake adb/xcrun executables, and installs only the pinned package with lifecycle scripts disabled. All homes, caches, state and runtime fixtures live under one temporary directory removed on exit. It requires network access to nodejs.org and the npm registry. Exit 1 means the defect reproduced, exit 0 means the probe rejects the unsupported runtime, and exit 2 means setup or the supported control failed.
Reproduction script: repro-node-floor.mjs
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import vm from 'node:vm';
import crypto from 'node:crypto';
import { spawnSync } from 'node:child_process';
const checkout = path.resolve(process.argv[2] ?? '.');
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 't3-node-floor-'));
let result = 'inconclusive';
let status = 2;
try {
console.log(`Harness Node ${process.version}; ${os.platform()} ${os.arch()}`);
const tarVersion = spawnSync('tar', ['--version'], { encoding: 'utf8' });
console.log(tarVersion.stdout.split('\n')[0]);
if (tarVersion.status !== 0) throw new Error('tar unavailable');
if (!['darwin', 'linux'].includes(os.platform()) || !['arm64', 'x64'].includes(os.arch())) throw new Error('Use macOS/Linux arm64/x64');
const versions = JSON.parse(fs.readFileSync(path.join(checkout, 'apps/server/src/device/deviceToolVersions.json'), 'utf8'));
const maintenance = fs.readFileSync(path.join(checkout, 'apps/server/src/device/deviceToolMaintenance.ts'), 'utf8').match(/export const deviceToolMaintenanceScript = String.raw`([\s\S]*?)`;/)?.[1];
if (!maintenance) throw new Error('Maintenance script extraction failed');
const source = fs.readFileSync(path.join(checkout, 'apps/server/src/device/sshDeviceScript.ts'), 'utf8');
const prefix = `const owner = 'fixture'; const mode = 'probe'; const hubVersion = ${JSON.stringify(versions['expo-device-hub'])}; const agentVersion = ${JSON.stringify(versions['agent-device'])};\n`;
const body = source.slice(source.indexOf('String.raw`') + 'String.raw`'.length, source.lastIndexOf('`;'));
if (!body.startsWith('\nconst fs')) throw new Error('Remote script extraction failed');
const probeFile = path.join(scratch, 'probe.cjs');
fs.writeFileSync(probeFile, prefix + maintenance + body);
const fakeBin = path.join(scratch, 'fake-bin'); fs.mkdirSync(fakeBin);
for (const [name, code] of [['adb', 0], ['xcrun', 1]]) {
const file = path.join(fakeBin, name); fs.writeFileSync(file, `#!/bin/sh\nexit ${code}\n`); fs.chmodSync(file, 0o755);
}
const probes = [];
const installs = [];
for (const version of ['22.11.0', '22.12.0']) {
const archive = `node-v${version}-${os.platform()}-${os.arch()}.tar.gz`;
const base = `https://nodejs.org/dist/v${version}/`;
const [bytes, sums] = await Promise.all([fetch(base + archive).then(async r => { if (!r.ok) throw new Error('Node archive fetch'); return Buffer.from(await r.arrayBuffer()); }), fetch(base + 'SHASUMS256.txt').then(r => r.text())]);
const digest = crypto.createHash('sha256').update(bytes).digest('hex');
if (!sums.split('\n').some(line => line.trim() === `${digest} ${archive}`)) throw new Error('Node checksum mismatch');
const archivePath = path.join(scratch, archive); fs.writeFileSync(archivePath, bytes);
if (spawnSync('tar', ['-xzf', archivePath, '-C', scratch]).status !== 0) throw new Error('Node extraction failed');
const nodeDir = path.join(scratch, `node-v${version}-${os.platform()}-${os.arch()}`, 'bin');
const home = path.join(scratch, `home-${version}`); const tmp = path.join(scratch, `tmp-${version}`); fs.mkdirSync(home); fs.mkdirSync(tmp);
const env = { HOME: home, TMPDIR: tmp, T3CODE_HOME: path.join(home, 't3'), AGENT_DEVICE_STATE_DIR: path.join(home, 'agent'), PATH: `${fakeBin}:${nodeDir}:/usr/bin:/bin`, npm_config_cache: path.join(home, 'cache'), npm_config_userconfig: path.join(home, 'npmrc'), npm_config_engine_strict: 'true' };
const node = path.join(nodeDir, 'node');
console.log(spawnSync(node, ['--version'], { env, encoding: 'utf8' }).stdout.trim());
console.log('npm ' + spawnSync(path.join(nodeDir, 'npm'), ['--version'], { env, encoding: 'utf8' }).stdout.trim());
const probe = spawnSync(node, [probeFile], { env, cwd: home, encoding: 'utf8' });
probes.push(probe.status === 0 && JSON.parse(probe.stdout).platforms.some(p => p.platform === 'android' && p.available));
console.log(`Node ${version}: T3 SSH probe exit ${probe.status}; Android available ${probes.at(-1)}`);
const install = spawnSync(path.join(nodeDir, 'npm'), ['install', '--prefix', path.join(home, 'install'), '--no-fund', '--no-audit', '--ignore-scripts', `agent-device@${versions['agent-device']}`], { env, cwd: home, encoding: 'utf8', timeout: 120000 });
installs.push(install);
console.log(`Node ${version}: pinned agent-device install exit ${install.status}; EBADENGINE ${install.stderr.includes('EBADENGINE')}`);
console.log(install.stderr.split('\n').filter(line => /EBADENGINE|Required:|Actual:/.test(line)).join('\n'));
}
if (!probes[1] || installs[1].status !== 0) throw new Error('Supported Node control failed');
if (probes[0] && installs[0].status !== 0 && installs[0].stderr.includes('EBADENGINE')) { result = 'defect reproduced'; status = 1; }
else if (!probes[0]) { result = 'expected behavior'; status = 0; }
else throw new Error('Unsupported Node did not fail installation');
} catch (error) {
console.error(error.message);
} finally {
fs.rmSync(scratch, { recursive: true, force: true });
}
// Possible final lines: RESULT: defect reproduced; RESULT: expected behavior; RESULT: inconclusive
console.log(`RESULT: ${result}`);
process.exitCode = status;
Expected behavior#
The prerequisite check should reject Node releases below the pinned tool's supported floor and identify that prerequisite before reporting a successful connection test. The released website/docs/docs/installation.md requires Node 22.12 or newer, and the published package metadata declares engines.node as >=22.12. T3's docs/user/devices.md#L107-L119 describes Test connection as checking Node/npm/platform prerequisites but tells users Node 22 or newer is sufficient.
Impact and scope#
Impact: P2 - The connection test gives a false success for a bounded range of unsupported runtimes, and users enforcing npm engines cannot complete agent-device installation until they change the runtime.
Observed scope is Node 22.11.0, npm 10.9.0, and engine-strict enabled, with Node 22.12.0 as the passing control. The full UI and SSH transport were not run. Without engine-strict, npm may merely warn; actual device operations on those unsupported runtimes are NOT_ENOUGH_INFO because no device command was run. Using Node 22.12.0 is a demonstrated workaround at the probe/package-install boundary.
Version#
T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93; runtime tool pin agent-device@0.21.36.
Runtime and platform#
Observed on macOS arm64 using official Node 22.11.0 and 22.12.0 archives, both with npm 10.9.0. Harness Node v26.12.1; bsdtar 3.5.3. No actual Android SDK, emulator, simulator, or phone participated.
Interface#
Package or setup: Settings → Devices → Machine over SSH → Test connection, followed by pinned device-tool installation.
Earliest failure stage#
Setup or configuration
Relevant output or evidence#
Exact output from the isolated script:
Harness Node v26.12.1; darwin arm64
bsdtar 3.5.3 - libarchive 3.7.4 zlib/1.2.12 liblzma/5.4.3 bz2lib/1.0.8
v22.11.0
npm 10.9.0
Node 22.11.0: T3 SSH probe exit 0; Android available true
Node 22.11.0: pinned agent-device install exit 1; EBADENGINE true
npm error code EBADENGINE
npm error notsup Required: {"node":">=22.12"}
npm error notsup Actual: {"npm":"10.9.0","node":"v22.11.0"}
v22.12.0
npm 10.9.0
Node 22.12.0: T3 SSH probe exit 0; Android available true
Node 22.12.0: pinned agent-device install exit 0; EBADENGINE false
RESULT: defect reproduced
The apps/server/src/device/sshDeviceScript.ts#L134-L145 tests only the Node major version. The apps/server/src/device/sshDeviceScript.ts#L101-L126 invokes npm with the inherited environment, and apps/server/src/device/sshDeviceScript.ts#L228-L230 installs the pinned agent package. Evidence provenance: observed subprocess reproduction; platform availability is deliberately fake and not a claim about hardware.
Expected outcome or recovery#
Restoration check: the unsupported-runtime probe must fail with an actionable minimum-version explanation, while the Node 22.12.0 control continues to pass the probe and pinned-package installation.
References#
Searches covered open and closed issues/PRs for agent-device, device EBADENGINE, device 22.12, and authored SSH/Node/device issues. The original runtime pin was preserved.
- https://github.com/pingdotgg/t3code/issues/32908 — covers missing Node on the noninteractive PATH and loss of its diagnostic; this report instead has Node/npm present and a probe that succeeds on a version the pinned package rejects.
tanstack-source added labelsbug
juliusmarminge closed this
Sign in to join this conversation.