agentroam 0.2.0-preview.15 → 0.2.0-preview.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@ Zero-install mobile remote terminal and file console. **No desktop app, no VPN**
4
4
 
5
5
  ## Requirements
6
6
 
7
- - **Node.js 22** (required)
7
+ - **Node.js >=22.22.0** (no upper version bound)
8
8
  - macOS arm64 or Windows 10/11 x64
9
9
 
10
10
  The release is assembled on macOS arm64. Windows does not need Bun, Python,
@@ -20,20 +20,20 @@ npx agentroam@preview
20
20
  Or install from tarball:
21
21
 
22
22
  ```bash
23
- npm install ./agentroam-cloudflared-darwin-arm64-0.2.0-preview.15.tgz \
24
- ./agentroam-runtime-darwin-arm64-0.2.0-preview.15.tgz \
25
- ./agentroam-tui-darwin-arm64-0.2.0-preview.15.tgz \
26
- ./agentroam-0.2.0-preview.15.tgz
23
+ npm install ./agentroam-cloudflared-darwin-arm64-0.2.0-preview.17.tgz \
24
+ ./agentroam-runtime-darwin-arm64-0.2.0-preview.17.tgz \
25
+ ./agentroam-tui-darwin-arm64-0.2.0-preview.17.tgz \
26
+ ./agentroam-0.2.0-preview.17.tgz
27
27
  npx agentroam
28
28
  ```
29
29
 
30
30
  Windows PowerShell:
31
31
 
32
32
  ```powershell
33
- npm install ./agentroam-cloudflared-win32-x64-0.2.0-preview.15.tgz `
34
- ./agentroam-runtime-win32-x64-0.2.0-preview.15.tgz `
35
- ./caoqu-agentroam-tui-win32-x64-0.2.0-preview.15.tgz `
36
- ./agentroam-0.2.0-preview.15.tgz
33
+ npm install ./agentroam-cloudflared-win32-x64-0.2.0-preview.17.tgz `
34
+ ./agentroam-runtime-win32-x64-0.2.0-preview.17.tgz `
35
+ ./caoqu-agentroam-tui-win32-x64-0.2.0-preview.17.tgz `
36
+ ./agentroam-0.2.0-preview.17.tgz
37
37
  npx agentroam
38
38
  ```
39
39
 
@@ -57,16 +57,17 @@ agent-tui # start the bundled terminal Agent UI
57
57
 
58
58
  Run the versioned Shell installer on macOS or PowerShell installer on Windows
59
59
  from the project directory AgentRoam should expose. The installer verifies or
60
- installs Node 22 and the matching CLI, registers the current-user service, starts
60
+ installs a compatible Node and the matching CLI, registers the current-user service, starts
61
61
  it, checks readiness, and prints the access URL. Set `AGENTROAM_ROOT` to select a
62
62
  different root explicitly. An implicit root equal to the home directory is
63
63
  rejected.
64
64
 
65
- If the active Node is not 22, the installer checks nvm-sh on macOS and
66
- nvm-windows on Windows, validates every installed candidate, and uses the
67
- highest installed Node 22. It pins that executable for AgentRoam without
65
+ The installer reuses the active Node when it meets the minimum version. Otherwise
66
+ it checks nvm-sh on macOS and nvm-windows on Windows and uses the highest
67
+ compatible installed version. The service records that executable without
68
68
  changing the user's global or active NVM version. A private Node 22.22.0 is
69
- downloaded only when no installed Node 22 is available.
69
+ downloaded only when no compatible installed Node is available. Higher Node
70
+ versions are never rejected or downgraded solely because of their major version.
70
71
 
71
72
  The service keeps running after the terminal closes, restarts after failure, and
72
73
  starts again when the user logs in. macOS uses a user LaunchAgent; Windows uses a
@@ -117,7 +118,7 @@ The platform cloudflared binary is installed from npm after size and SHA-256 ver
117
118
  - Next.js **standalone** web gateway + WebSocket PTY server
118
119
  - `@agent/core` bundled runtime
119
120
  - Optional platform **cloudflared** package (verified size + SHA-256 before execution)
120
- - Optional platform **agent-tui** package (Node 22 bundle; Bun is not required)
121
+ - Optional platform **agent-tui** package (Node >=22.22.0 bundle; Bun is not required)
121
122
  - **Pairing token** + secure cookies behind HTTPS tunnel
122
123
 
123
124
  Not included: Electron desktop app, SDK, dev `.env`, session caches.
@@ -4,10 +4,10 @@ import { homedir } from "node:os";
4
4
  import { delimiter, dirname, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { promisify } from "node:util";
7
+ import { MINIMUM_NODE_VERSION, compareNodeVersions, isSupportedNodeVersion, parseNodeVersion } from "./runtime-policy.mjs";
7
8
 
8
9
  const execFileAsync = promisify(execFile);
9
- const MANAGED_NODE_VERSION = "22.22.0";
10
- const RELEASE_VERSION = "0.2.0-preview.15";
10
+ const RELEASE_VERSION = "0.2.0-preview.17";
11
11
  const RELEASE_BASE_URL = `https://gitee.com/caoqu/team-agent/releases/download/v${RELEASE_VERSION}`;
12
12
  const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
13
13
 
@@ -21,10 +21,10 @@ export async function runNodePreflight(options = {}) {
21
21
  const processHost = options.processHost ?? process;
22
22
  const major = Number(nodeVersion.split(".")[0]);
23
23
 
24
- if (major === 22) return { handled: false };
24
+ if (isSupportedNodeVersion(nodeVersion)) return { handled: false };
25
25
  if (environment.AGENTROAM_MANAGED_NODE) {
26
26
  throw cliError(
27
- `managed Node.js re-entry failed: expected 22, got ${nodeVersion} (${environment.AGENTROAM_MANAGED_NODE})`,
27
+ `managed Node.js re-entry failed: expected >=${MINIMUM_NODE_VERSION}, got ${nodeVersion} (${environment.AGENTROAM_MANAGED_NODE})`,
28
28
  );
29
29
  }
30
30
  if (!Number.isFinite(major) || major < 18) {
@@ -33,8 +33,8 @@ export async function runNodePreflight(options = {}) {
33
33
 
34
34
  const target = detectManagedTarget(platform, arch);
35
35
  const dataDir = parsePreflightDataDir(argv, environment, options.homeDir ?? homedir());
36
- const findSystemNode22 = options.findSystemNode22 ?? defaultFindSystemNode22;
37
- const systemNode = await findSystemNode22(environment, platform, options.currentExecutable ?? process.execPath);
36
+ const findSystemNode = options.findSystemNode ?? defaultFindSystemNode;
37
+ const systemNode = await findSystemNode(environment, platform, options.currentExecutable ?? process.execPath);
38
38
  let executable = systemNode;
39
39
 
40
40
  if (!executable) {
@@ -55,7 +55,7 @@ export async function runNodePreflight(options = {}) {
55
55
  const spawnChild = options.spawnChild ?? defaultSpawnChild;
56
56
  const child = spawnChild(executable, [launcherPath, ...argv], {
57
57
  stdio: "inherit",
58
- env: { ...environment, AGENTROAM_MANAGED_NODE: MANAGED_NODE_VERSION },
58
+ env: { ...environment, AGENTROAM_MANAGED_NODE: executable },
59
59
  });
60
60
  const result = await waitForChild(child, processHost);
61
61
  return { handled: true, ...result };
@@ -77,7 +77,7 @@ function detectManagedTarget(platform, arch) {
77
77
  throw cliError(`managed Node.js is unsupported on ${platform}-${arch}`);
78
78
  }
79
79
 
80
- async function defaultFindSystemNode22(environment, platform, currentExecutable) {
80
+ async function defaultFindSystemNode(environment, platform, currentExecutable) {
81
81
  const executableName = platform === "win32" ? "node.exe" : "node";
82
82
  const pathDelimiter = platform === "win32" ? ";" : delimiter;
83
83
  const currentDirectory = dirname(currentExecutable);
@@ -85,12 +85,12 @@ async function defaultFindSystemNode22(environment, platform, currentExecutable)
85
85
  if (resolve(directory) === resolve(currentDirectory)) continue;
86
86
  const candidate = resolve(directory, executableName);
87
87
  const version = await inspectNodeVersion(candidate);
88
- if (version?.[0] === 22) return candidate;
88
+ if (isSupportedNodeVersion(version)) return candidate;
89
89
  }
90
- return findNvmNode22(environment, platform);
90
+ return findNvmNode(environment, platform);
91
91
  }
92
92
 
93
- export async function findNvmNode22(environment = process.env, platform = process.platform) {
93
+ export async function findNvmNode(environment = process.env, platform = process.platform) {
94
94
  const windows = platform === "win32";
95
95
  const homeDir = environment.HOME || environment.USERPROFILE || homedir();
96
96
  const roots = windows
@@ -109,10 +109,10 @@ export async function findNvmNode22(environment = process.env, platform = proces
109
109
  if (!entry.isDirectory()) continue;
110
110
  const candidate = resolve(versionsRoot, entry.name, windows ? "node.exe" : "bin/node");
111
111
  const version = await inspectNodeVersion(candidate);
112
- if (version?.[0] === 22) candidates.push({ candidate, version });
112
+ if (isSupportedNodeVersion(version)) candidates.push({ candidate, version });
113
113
  }
114
114
  }
115
- candidates.sort((left, right) => compareVersions(right.version, left.version));
115
+ candidates.sort((left, right) => compareNodeVersions(right.version, left.version));
116
116
  return candidates[0]?.candidate ?? null;
117
117
  }
118
118
 
@@ -124,20 +124,13 @@ async function inspectNodeVersion(candidate) {
124
124
  timeout: 5_000,
125
125
  windowsHide: true,
126
126
  });
127
- const parts = stdout.trim().split(".").map((part) => Number(part));
128
- return parts.length >= 3 && parts.every(Number.isInteger) ? parts.slice(0, 3) : null;
127
+ const version = stdout.trim();
128
+ return parseNodeVersion(version) ? version : null;
129
129
  } catch {
130
130
  return null;
131
131
  }
132
132
  }
133
133
 
134
- function compareVersions(left, right) {
135
- for (let index = 0; index < 3; index++) {
136
- if (left[index] !== right[index]) return left[index] - right[index];
137
- }
138
- return 0;
139
- }
140
-
141
134
  async function defaultSpawnChild(executable, args, options) {
142
135
  const { spawn } = await import("node:child_process");
143
136
  return spawn(executable, args, options);
@@ -0,0 +1,4 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "minimumNodeVersion": "22.22.0"
4
+ }
@@ -0,0 +1,5 @@
1
+ export const MINIMUM_NODE_VERSION: string;
2
+ export function parseNodeVersion(version: unknown): number[] | null;
3
+ export function compareNodeVersions(left: string, right: string): number;
4
+ export function isSupportedNodeVersion(version: unknown, minimum?: string): boolean;
5
+ export function assertSupportedNodeVersion(version?: string): void;
@@ -0,0 +1,37 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ const policy = JSON.parse(readFileSync(new URL("./node-runtime-policy.json", import.meta.url), "utf8"));
4
+
5
+ export const MINIMUM_NODE_VERSION = policy.minimumNodeVersion;
6
+
7
+ export function parseNodeVersion(version) {
8
+ if (typeof version !== "string") return null;
9
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(version);
10
+ if (!match) return null;
11
+ const parts = match.slice(1, 4).map(Number);
12
+ return parts.every(Number.isSafeInteger) ? parts : null;
13
+ }
14
+
15
+ export function compareNodeVersions(left, right) {
16
+ const leftParts = parseNodeVersion(left);
17
+ const rightParts = parseNodeVersion(right);
18
+ if (!leftParts || !rightParts) throw new Error("invalid Node.js version");
19
+ for (let index = 0; index < 3; index++) {
20
+ if (leftParts[index] !== rightParts[index]) return leftParts[index] - rightParts[index];
21
+ }
22
+ return 0;
23
+ }
24
+
25
+ export function isSupportedNodeVersion(version, minimum = MINIMUM_NODE_VERSION) {
26
+ return parseNodeVersion(version) !== null && compareNodeVersions(version, minimum) >= 0;
27
+ }
28
+
29
+ export function assertSupportedNodeVersion(version = process.versions.node) {
30
+ if (!isSupportedNodeVersion(version)) {
31
+ throw Object.assign(new Error(`Node.js >=${MINIMUM_NODE_VERSION} required (current ${version})`), { exitCode: 2 });
32
+ }
33
+ }
34
+
35
+ if (policy.schemaVersion !== 1 || !parseNodeVersion(MINIMUM_NODE_VERSION)) {
36
+ throw new Error("invalid AgentRoam Node.js runtime policy");
37
+ }
package/dist/args.js CHANGED
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
3
3
  export function parseArgs(argv) {
4
4
  const values = [...argv];
5
5
  let command = "start";
6
- if (values[0] && ["start", "doctor", "version", "service"].includes(values[0])) {
6
+ if (values[0] && ["start", "doctor", "version", "service", "update", "update-worker"].includes(values[0])) {
7
7
  command = values.shift();
8
8
  }
9
9
  let serviceAction = null;
@@ -17,6 +17,16 @@ export function parseArgs(argv) {
17
17
  throw cliError(`service ${serviceAction} does not accept options`);
18
18
  }
19
19
  }
20
+ let updateVersion = null;
21
+ let updateStateFile = null;
22
+ if (command === "update" && values[0] && !values[0].startsWith("--"))
23
+ updateVersion = values.shift();
24
+ if (command === "update-worker") {
25
+ const stateFile = values.shift();
26
+ if (!stateFile || values.length > 0)
27
+ throw cliError("update-worker requires one state file");
28
+ updateStateFile = resolve(stateFile);
29
+ }
20
30
  const options = {
21
31
  command,
22
32
  serviceAction,
@@ -27,8 +37,10 @@ export function parseArgs(argv) {
27
37
  localOnly: false,
28
38
  qr: true,
29
39
  dataDir: resolve(homedir(), ".agentroam"),
40
+ updateVersion,
41
+ updateStateFile,
30
42
  };
31
- for (let index = 0; index < values.length; index++) {
43
+ for (let index = 0; index < values.length && command !== "update-worker"; index++) {
32
44
  const arg = values[index];
33
45
  const next = () => {
34
46
  const value = values[++index];
@@ -64,6 +76,9 @@ export function parseArgs(argv) {
64
76
  else
65
77
  throw cliError(`unknown argument: ${arg}`);
66
78
  }
79
+ if (command === "update" && updateVersion && !/^\d+\.\d+\.\d+$/.test(updateVersion)) {
80
+ throw cliError("update version must be an exact stable X.Y.Z version");
81
+ }
67
82
  if (!options.roots.length)
68
83
  options.roots = [process.cwd()];
69
84
  if (options.relay === "custom" && !options.tunnelCommand)
package/dist/args.test.js CHANGED
@@ -51,4 +51,9 @@ describe("parseArgs", () => {
51
51
  expect(() => parseArgs(["service", "start", "--port", "3000"])).toThrow("does not accept options");
52
52
  expect(() => parseArgs(["service", "stop", "--local-only"])).toThrow("does not accept options");
53
53
  });
54
+ it("parses update with an optional exact stable version", () => {
55
+ expect(parseArgs(["update"]).updateVersion).toBeNull();
56
+ expect(parseArgs(["update", "1.2.3", "--data-dir", "./data"])).toMatchObject({ command: "update", updateVersion: "1.2.3" });
57
+ expect(() => parseArgs(["update", "1.2.3-preview.1"])).toThrow("exact stable");
58
+ });
54
59
  });
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
3
3
  import { parseArgs } from "./args.js";
4
4
  import { ensureCloudflared } from "./cloudflared/installer.js";
5
5
  import { resolveCodexRuntime } from "./codex-runtime-manager.js";
6
- import { repairNativeRuntimePermissions } from "./native-runtime.js";
6
+ import { probeSQLiteRuntime, probeTerminalRuntime, repairNativeRuntimePermissions } from "./native-runtime.js";
7
7
  import { findLanUrl } from "./network.js";
8
8
  import { createPairingSecret } from "./pairing.js";
9
9
  import { detectPlatform } from "./platform.js";
@@ -15,9 +15,15 @@ import { ServiceRuntimeReporter } from "./service/runtime-state.js";
15
15
  import { runServiceCommand } from "./service/service-command.js";
16
16
  import { resolveServicePaths } from "./service/service-files.js";
17
17
  import { selectRelay } from "./tunnel/relay-orchestrator.js";
18
+ import { currentCliPath, startUpdate } from "./update/update-command.js";
19
+ import { runUpdateWorker } from "./update/update-worker.js";
18
20
  const VERSION = AGENTROAM_VERSION;
19
21
  export async function main(argv) {
20
22
  const options = parseArgs(argv);
23
+ if (options.command === "update-worker") {
24
+ await runUpdateWorker(options.updateStateFile);
25
+ return;
26
+ }
21
27
  if (options.command === "service") {
22
28
  await runServiceCommand(options, {
23
29
  nodePath: process.execPath,
@@ -27,6 +33,13 @@ export async function main(argv) {
27
33
  return;
28
34
  }
29
35
  const target = detectPlatform();
36
+ if (options.command === "update") {
37
+ if (target !== "darwin-arm64" && target !== "windows-amd64")
38
+ throw new Error(`updates are unavailable for ${target}`);
39
+ const state = await startUpdate({ currentVersion: VERSION, requestedVersion: options.updateVersion ?? null, dataDir: options.dataDir, target, cliPath: currentCliPath() });
40
+ console.log(`AgentRoam ${state.targetVersion} update started in the background.`);
41
+ return;
42
+ }
30
43
  if (options.command === "version") {
31
44
  console.log(`agentroam ${VERSION}`);
32
45
  return;
@@ -125,7 +138,7 @@ export async function main(argv) {
125
138
  }
126
139
  }
127
140
  async function doctor(target, dataDir) {
128
- console.log(`✓ Node ${process.versions.node}`);
141
+ console.log(`✓ Node ${process.versions.node} · Node-API ${process.versions.napi}`);
129
142
  console.log(`✓ Platform ${target}`);
130
143
  const { runtimeRoot } = resolvePlatformRuntime(target);
131
144
  const runtimeRequire = createRequire(`${runtimeRoot}/package.json`);
@@ -136,9 +149,12 @@ async function doctor(target, dataDir) {
136
149
  catch (error) {
137
150
  reportDoctorFailure(error);
138
151
  }
139
- for (const module of ["node-pty", "better-sqlite3"]) {
152
+ for (const [module, probe] of [
153
+ ["node-pty", probeTerminalRuntime],
154
+ ["better-sqlite3", probeSQLiteRuntime],
155
+ ]) {
140
156
  try {
141
- runtimeRequire(module);
157
+ await probe(runtimeRequire);
142
158
  console.log(`✓ ${module}`);
143
159
  }
144
160
  catch (error) {
@@ -4,12 +4,35 @@ import { execFileSync } from "node:child_process";
4
4
  import { describe, expect, it } from "vitest";
5
5
  import { MANAGED_NODE_VERSION, NODE_RUNTIME_ASSETS } from "./node-runtime-manager.js";
6
6
  import { AGENTROAM_VERSION } from "./platform-packages.js";
7
+ import { MINIMUM_NODE_VERSION, isSupportedNodeVersion } from "../bin/runtime-policy.mjs";
7
8
  const installRoot = resolve(import.meta.dirname, "../install");
8
9
  describe("standalone installer contracts", () => {
10
+ it.skipIf(process.platform !== "darwin" || process.arch !== "arm64")("keeps a compatible active Node instead of selecting a different NVM version", async () => {
11
+ const root = await mkdtemp(resolve(process.env.TMPDIR || "/tmp", "agentroam-active-node-"));
12
+ try {
13
+ const homeDir = resolve(root, "home");
14
+ const workspace = resolve(root, "workspace");
15
+ const activeBin = resolve(root, "active bin");
16
+ const nvmRoot = resolve(root, "nvm");
17
+ await Promise.all([mkdir(homeDir), mkdir(workspace), mkdir(activeBin), mkdir(nvmRoot)]);
18
+ const expected = await fakeNode(resolve(activeBin, "node"), "24.13.0");
19
+ await fakeNode(resolve(nvmRoot, "versions/node/v26.0.0/bin/node"), "26.0.0");
20
+ const output = execFileSync("/bin/sh", [resolve(installRoot, "install-agentroam.sh")], {
21
+ cwd: workspace, encoding: "utf8",
22
+ env: { ...process.env, HOME: homeDir, NVM_DIR: nvmRoot, PATH: `${activeBin}:/usr/bin:/bin`,
23
+ AGENTROAM_BOOTSTRAP_TEST: "1", AGENTROAM_BOOTSTRAP_NODE_DISCOVERY_ONLY: "1" },
24
+ });
25
+ expect(output.trim()).toBe(expected);
26
+ }
27
+ finally {
28
+ await rm(root, { recursive: true, force: true });
29
+ }
30
+ });
9
31
  it("keeps the macOS installer aligned with the runtime manifest", async () => {
10
32
  const scriptPath = resolve(installRoot, "install-agentroam.sh");
11
33
  const script = await readFile(scriptPath, "utf8");
12
34
  expect(script).toContain(`NODE_VERSION="${MANAGED_NODE_VERSION}"`);
35
+ expect(script).toContain(`MINIMUM_NODE_VERSION="${MINIMUM_NODE_VERSION}"`);
13
36
  expect(script).toContain(`AGENTROAM_VERSION="${AGENTROAM_VERSION}"`);
14
37
  expect(script).toContain(NODE_RUNTIME_ASSETS["darwin-arm64"].archive);
15
38
  expect(script).toContain(NODE_RUNTIME_ASSETS["darwin-arm64"].sha256);
@@ -20,7 +43,7 @@ describe("standalone installer contracts", () => {
20
43
  expect(script).toContain('AGENTROAM_INSTALL_SKIP_SERVICE');
21
44
  expect(script).toContain('service install --root "$SERVICE_ROOT" --data-dir "$DATA_DIR"');
22
45
  expect(script).toContain("refusing to expose the entire home directory implicitly");
23
- expect(script).toContain("find_nvm_node_22");
46
+ expect(script).toContain("find_nvm_node");
24
47
  expect(script).toContain('${NVM_DIR:-}');
25
48
  expect(script).not.toMatch(/\b(?:brew|sudo|fnm|volta)\b/);
26
49
  execFileSync("sh", ["-n", scriptPath]);
@@ -28,6 +51,7 @@ describe("standalone installer contracts", () => {
28
51
  it("keeps the Windows installer aligned with the runtime manifest", async () => {
29
52
  const script = await readFile(resolve(installRoot, "install-agentroam.ps1"), "utf8");
30
53
  expect(script).toContain(`$NodeVersion = "${MANAGED_NODE_VERSION}"`);
54
+ expect(script).toContain(`$MinimumNodeVersion = "${MINIMUM_NODE_VERSION}"`);
31
55
  expect(script).toContain(`$AgentRoamVersion = "${AGENTROAM_VERSION}"`);
32
56
  expect(script).toContain(NODE_RUNTIME_ASSETS["windows-amd64"].archive);
33
57
  expect(script).toContain(NODE_RUNTIME_ASSETS["windows-amd64"].sha256);
@@ -38,11 +62,11 @@ describe("standalone installer contracts", () => {
38
62
  expect(script).toContain("$env:AGENTROAM_INSTALL_SKIP_SERVICE");
39
63
  expect(script).toContain("service install --root $ServiceRoot --data-dir $DataDir");
40
64
  expect(script).toContain("Refusing to expose the entire home directory implicitly");
41
- expect(script).toContain("Find-NvmNode22");
65
+ expect(script).toContain("Find-NvmNode");
42
66
  expect(script).toContain("$env:NVM_HOME");
43
67
  expect(script).not.toMatch(/\b(?:winget|choco|scoop|Start-Process\s+.*RunAs)\b/i);
44
68
  });
45
- it.skipIf(process.platform !== "darwin" || process.arch !== "arm64")("selects the highest installed NVM Node 22 without changing the active version", async () => {
69
+ it.skipIf(process.platform !== "darwin" || process.arch !== "arm64")("selects the highest compatible NVM Node without changing the active version", async () => {
46
70
  const root = await mkdtemp(resolve(process.env.TMPDIR || "/tmp", "agentroam-nvm-discovery-"));
47
71
  try {
48
72
  const home = resolve(root, "home");
@@ -52,7 +76,8 @@ describe("standalone installer contracts", () => {
52
76
  await Promise.all([mkdir(home), mkdir(workspace), mkdir(activeBin), mkdir(nvmRoot)]);
53
77
  await fakeNode(resolve(activeBin, "node"), "20.19.0");
54
78
  await fakeNode(resolve(nvmRoot, "versions/node/v22.3.0/bin/node"), "22.3.0");
55
- const expected = await fakeNode(resolve(nvmRoot, "versions/node/v22.22.2/bin/node"), "22.22.2");
79
+ await fakeNode(resolve(nvmRoot, "versions/node/v22.22.2/bin/node"), "22.22.2");
80
+ const expected = await fakeNode(resolve(nvmRoot, "versions/node/v25.8.0/bin/node"), "25.8.0");
56
81
  await fakeNode(resolve(nvmRoot, "versions/node/v22.99.0/bin/node"), "21.99.0");
57
82
  const output = execFileSync("/bin/sh", [resolve(installRoot, "install-agentroam.sh")], {
58
83
  cwd: workspace,
@@ -67,13 +92,13 @@ describe("standalone installer contracts", () => {
67
92
  },
68
93
  });
69
94
  expect(output.trim().split("\n").at(-1)).toBe(expected);
70
- expect(output).toContain("Using Node.js v22.22.2 from NVM");
95
+ expect(output).toContain("Using Node.js v25.8.0 from NVM");
71
96
  }
72
97
  finally {
73
98
  await rm(root, { recursive: true, force: true });
74
99
  }
75
100
  });
76
- it.skipIf(process.platform !== "win32" || Number(process.versions.node.split(".")[0]) !== 22)("selects an installed nvm-windows Node 22 when the active PATH has no Node", async () => {
101
+ it.skipIf(process.platform !== "win32" || !isSupportedNodeVersion(process.versions.node))("selects a compatible nvm-windows Node when the active PATH has no Node", async () => {
77
102
  const root = await mkdtemp(resolve(process.env.TEMP || process.env.TMP || "C:\\Windows\\Temp", "agentroam-nvm-windows-"));
78
103
  try {
79
104
  const home = resolve(root, "home");
@@ -1,5 +1,51 @@
1
1
  import { chmod } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
+ export function probeSQLiteRuntime(runtimeRequire) {
4
+ const Database = runtimeRequire("better-sqlite3");
5
+ const database = new Database(":memory:");
6
+ try {
7
+ database.exec("CREATE TABLE runtime_probe (value INTEGER NOT NULL)");
8
+ database.prepare("INSERT INTO runtime_probe VALUES (?)").run(7);
9
+ if (database.prepare("SELECT value FROM runtime_probe").get()?.value !== 7) {
10
+ throw new Error("SQLite runtime query failed");
11
+ }
12
+ }
13
+ finally {
14
+ database.close();
15
+ }
16
+ }
17
+ export async function probeTerminalRuntime(runtimeRequire, platform = process.platform) {
18
+ const pty = runtimeRequire("node-pty");
19
+ const windows = platform === "win32";
20
+ const marker = "agentroam-runtime-probe";
21
+ const terminal = pty.spawn(windows ? (process.env.ComSpec || "cmd.exe") : "/bin/sh", windows
22
+ ? ["/d", "/s", "/c", `echo ${marker}`]
23
+ : ["-c", `printf ${marker}`], {
24
+ name: "xterm-256color", cols: 80, rows: 24, cwd: process.cwd(), env: process.env,
25
+ });
26
+ await new Promise((resolveProbe, rejectProbe) => {
27
+ let output = "";
28
+ const timer = setTimeout(() => {
29
+ dataListener.dispose();
30
+ exitListener.dispose();
31
+ try {
32
+ terminal.kill();
33
+ }
34
+ catch { }
35
+ rejectProbe(new Error("PTY runtime probe timed out"));
36
+ }, 5_000);
37
+ const dataListener = terminal.onData((chunk) => { output = (output + chunk).slice(-4096); });
38
+ const exitListener = terminal.onExit(({ exitCode }) => {
39
+ clearTimeout(timer);
40
+ dataListener.dispose();
41
+ exitListener.dispose();
42
+ if (exitCode === 0 && output.includes(marker))
43
+ resolveProbe();
44
+ else
45
+ rejectProbe(new Error(`PTY runtime probe failed (exit ${exitCode})`));
46
+ });
47
+ });
48
+ }
3
49
  export async function repairNativeRuntimePermissions(runtimeRoot, platform = process.platform, arch = process.arch) {
4
50
  if (platform === "win32")
5
51
  return;
@@ -1,8 +1,29 @@
1
1
  import { mkdtemp, mkdir, stat, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { resolve } from "node:path";
4
- import { describe, expect, it } from "vitest";
5
- import { repairNativeRuntimePermissions } from "./native-runtime.js";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { probeSQLiteRuntime, probeTerminalRuntime, repairNativeRuntimePermissions } from "./native-runtime.js";
6
+ describe("native runtime health", () => {
7
+ it("detects lazy SQLite binding failures after require succeeds", () => {
8
+ const requireRuntime = (() => class Database {
9
+ constructor() { throw new Error("native binding unavailable"); }
10
+ });
11
+ expect(() => probeSQLiteRuntime(requireRuntime)).toThrow("native binding unavailable");
12
+ });
13
+ it("closes SQLite when its query fails", () => {
14
+ const close = vi.fn();
15
+ const requireRuntime = (() => class Database {
16
+ exec() { throw new Error("database failed"); }
17
+ close = close;
18
+ });
19
+ expect(() => probeSQLiteRuntime(requireRuntime)).toThrow("database failed");
20
+ expect(close).toHaveBeenCalledOnce();
21
+ });
22
+ it("detects PTY spawn failures after require succeeds", async () => {
23
+ const requireRuntime = (() => ({ spawn() { throw new Error("spawn-helper unavailable"); } }));
24
+ await expect(probeTerminalRuntime(requireRuntime)).rejects.toThrow("spawn-helper unavailable");
25
+ });
26
+ });
6
27
  describe("repairNativeRuntimePermissions", () => {
7
28
  it("restores the node-pty helper execute bits stripped by npm pack", async () => {
8
29
  const root = await mkdtemp(resolve(tmpdir(), "agentroam-native-"));
@@ -4,18 +4,22 @@ import { tmpdir } from "node:os";
4
4
  import { resolve } from "node:path";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  // @ts-expect-error The preflight intentionally remains plain ESM for Node 18 bootstrap.
7
- import { findNvmNode22, parsePreflightDataDir, runNodePreflight } from "../bin/node-preflight.mjs";
7
+ import { findNvmNode, parsePreflightDataDir, runNodePreflight } from "../bin/node-preflight.mjs";
8
8
  const temporaryDirectories = [];
9
9
  afterEach(async () => {
10
10
  await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
11
11
  });
12
12
  describe("Node launcher preflight", () => {
13
- it("passes through any Node 22 patch without installing", async () => {
13
+ it.each(["22.22.0", "22.23.0", "24.0.0", "25.8.0", "26.0.0", "100.0.0"])("passes through Node %s without installing or switching", async (nodeVersion) => {
14
14
  const ensureNode = vi.fn();
15
- await expect(runNodePreflight({ nodeVersion: "22.1.0", ensureNode })).resolves.toEqual({ handled: false });
15
+ const findSystemNode = vi.fn();
16
+ const spawnChild = vi.fn();
17
+ await expect(runNodePreflight({ nodeVersion, ensureNode, findSystemNode, spawnChild, environment: { AGENTROAM_MANAGED_NODE: "/older/node" } })).resolves.toEqual({ handled: false });
16
18
  expect(ensureNode).not.toHaveBeenCalled();
19
+ expect(findSystemNode).not.toHaveBeenCalled();
20
+ expect(spawnChild).not.toHaveBeenCalled();
17
21
  });
18
- it.each(["18.20.0", "20.18.0", "24.0.0", "25.1.0"])("re-executes Node %s through the managed runtime", async (nodeVersion) => {
22
+ it.each(["18.20.0", "20.18.0", "22.1.0", "22.21.9"])("re-executes Node %s through the managed runtime", async (nodeVersion) => {
19
23
  const child = fakeChild(0, null);
20
24
  const ensureNode = vi.fn(async () => ({ executable: "/managed/node", version: "22.22.0" }));
21
25
  const spawnChild = vi.fn((_executable, _args, _options) => child);
@@ -27,7 +31,7 @@ describe("Node launcher preflight", () => {
27
31
  launcherPath: "/launcher/agentroam.mjs",
28
32
  environment: { PATH: "" },
29
33
  ensureNode,
30
- findSystemNode22: async () => null,
34
+ findSystemNode: async () => null,
31
35
  spawnChild,
32
36
  processHost: fakeProcessHost(),
33
37
  });
@@ -38,38 +42,40 @@ describe("Node launcher preflight", () => {
38
42
  }));
39
43
  expect(spawnChild).toHaveBeenCalledWith("/managed/node", ["/launcher/agentroam.mjs", "doctor", "--data-dir", "./custom"], expect.objectContaining({
40
44
  stdio: "inherit",
41
- env: expect.objectContaining({ AGENTROAM_MANAGED_NODE: "22.22.0" }),
45
+ env: expect.objectContaining({ AGENTROAM_MANAGED_NODE: "/managed/node" }),
42
46
  }));
43
47
  });
44
- it("reuses another visible system Node 22", async () => {
48
+ it("reuses another visible compatible system Node", async () => {
45
49
  const ensureNode = vi.fn();
46
50
  const spawnChild = vi.fn((_executable, _args, _options) => fakeChild(7, null));
47
51
  await expect(runNodePreflight({
48
52
  argv: ["version"],
49
- nodeVersion: "25.0.0",
53
+ nodeVersion: "20.0.0",
50
54
  platform: "win32",
51
55
  arch: "x64",
52
56
  environment: { PATH: "" },
53
- findSystemNode22: async () => "C:\\Node22\\node.exe",
57
+ findSystemNode: async () => "C:\\Node25\\node.exe",
54
58
  ensureNode,
55
59
  spawnChild,
56
60
  processHost: fakeProcessHost(),
57
61
  })).resolves.toMatchObject({ handled: true, exitCode: 7 });
58
62
  expect(ensureNode).not.toHaveBeenCalled();
59
- expect(spawnChild.mock.calls[0][0]).toBe("C:\\Node22\\node.exe");
63
+ expect(spawnChild.mock.calls[0][0]).toBe("C:\\Node25\\node.exe");
60
64
  });
61
- it.skipIf(process.platform === "win32")("selects the highest inactive NVM Node 22", async () => {
65
+ it.skipIf(process.platform === "win32")("selects the highest compatible inactive NVM Node", async () => {
62
66
  const root = await mkdtemp(resolve(tmpdir(), "agentroam-preflight-nvm-"));
63
67
  temporaryDirectories.push(root);
64
68
  await fakeNodeExecutable(resolve(root, "versions/node/v22.3.0/bin/node"), "22.3.0");
65
- const expected = await fakeNodeExecutable(resolve(root, "versions/node/v22.22.2/bin/node"), "22.22.2");
69
+ await fakeNodeExecutable(resolve(root, "versions/node/v22.22.2/bin/node"), "22.22.2");
66
70
  await fakeNodeExecutable(resolve(root, "versions/node/v23.1.0/bin/node"), "23.1.0");
67
- await expect(findNvmNode22({ NVM_DIR: root, HOME: resolve(root, "home") }, "darwin"))
71
+ const expected = await fakeNodeExecutable(resolve(root, "versions/node/v25.8.0/bin/node"), "25.8.0");
72
+ await fakeNodeExecutable(resolve(root, "versions/node/v26.0.0-rc.1/bin/node"), "26.0.0-rc.1");
73
+ await expect(findNvmNode({ NVM_DIR: root, HOME: resolve(root, "home") }, "darwin"))
68
74
  .resolves.toBe(expected);
69
75
  });
70
76
  it("rejects a failed managed re-entry instead of looping", async () => {
71
77
  await expect(runNodePreflight({
72
- nodeVersion: "25.0.0",
78
+ nodeVersion: "22.21.9",
73
79
  environment: { AGENTROAM_MANAGED_NODE: "22.22.0" },
74
80
  })).rejects.toMatchObject({ message: expect.stringContaining("re-entry failed"), exitCode: 2 });
75
81
  });
@@ -94,7 +100,7 @@ describe("Node launcher preflight", () => {
94
100
  platform: "darwin",
95
101
  arch: "arm64",
96
102
  environment: { PATH: "" },
97
- findSystemNode22: async () => "/node22",
103
+ findSystemNode: async () => "/compatible-node",
98
104
  spawnChild: () => child,
99
105
  processHost,
100
106
  });