agentroam 0.2.0-preview.16 → 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.16.tgz \
24
- ./agentroam-runtime-darwin-arm64-0.2.0-preview.16.tgz \
25
- ./agentroam-tui-darwin-arm64-0.2.0-preview.16.tgz \
26
- ./agentroam-0.2.0-preview.16.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.16.tgz `
34
- ./agentroam-runtime-win32-x64-0.2.0-preview.16.tgz `
35
- ./caoqu-agentroam-tui-win32-x64-0.2.0-preview.16.tgz `
36
- ./agentroam-0.2.0-preview.16.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.16";
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/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";
@@ -138,7 +138,7 @@ export async function main(argv) {
138
138
  }
139
139
  }
140
140
  async function doctor(target, dataDir) {
141
- console.log(`✓ Node ${process.versions.node}`);
141
+ console.log(`✓ Node ${process.versions.node} · Node-API ${process.versions.napi}`);
142
142
  console.log(`✓ Platform ${target}`);
143
143
  const { runtimeRoot } = resolvePlatformRuntime(target);
144
144
  const runtimeRequire = createRequire(`${runtimeRoot}/package.json`);
@@ -149,9 +149,12 @@ async function doctor(target, dataDir) {
149
149
  catch (error) {
150
150
  reportDoctorFailure(error);
151
151
  }
152
- for (const module of ["node-pty", "better-sqlite3"]) {
152
+ for (const [module, probe] of [
153
+ ["node-pty", probeTerminalRuntime],
154
+ ["better-sqlite3", probeSQLiteRuntime],
155
+ ]) {
153
156
  try {
154
- runtimeRequire(module);
157
+ await probe(runtimeRequire);
155
158
  console.log(`✓ ${module}`);
156
159
  }
157
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
  });
@@ -5,7 +5,9 @@ import { delimiter, isAbsolute, relative, resolve, win32 } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { resolveNpmExecutor } from "./codex-runtime-manager.js";
7
7
  const execFileAsync = promisify(execFile);
8
+ // Default managed installation; compatibility has an independent minimum.
8
9
  export const OPENCODE_RUNTIME_VERSION = "1.18.27";
10
+ export const OPENCODE_MINIMUM_VERSION = "1.18.27";
9
11
  const OPENCODE_PACKAGE = `opencode-ai@${OPENCODE_RUNTIME_VERSION}`;
10
12
  const NPM_REGISTRY = "https://registry.npmjs.org";
11
13
  const defaults = {
@@ -49,7 +51,7 @@ export async function resolveOpenCodeRuntime(options) {
49
51
  return install({ ...options, environment, platform, dependencies, managedRoot });
50
52
  }
51
53
  export function parseOpenCodeVersion(output) {
52
- return output.match(/(?:^|\s)(\d+\.\d+\.\d+)(?:\s|$)/)?.[1] ?? null;
54
+ return output.match(/(?:^|\s)((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))(?:\s|$)/)?.[1] ?? null;
53
55
  }
54
56
  export function managedOpenCodeBinaryCandidates(root, platform) {
55
57
  return platform === "win32"
@@ -183,12 +185,22 @@ async function validate(executable, source, _platform, dependencies) {
183
185
  const version = parseOpenCodeVersion(`${result.stdout}\n${result.stderr}`);
184
186
  if (!version)
185
187
  throw new Error(`Unable to parse OpenCode version from ${executable}`);
186
- if (version !== OPENCODE_RUNTIME_VERSION) {
187
- throw new Error(`OpenCode ${version} at ${executable} is incompatible; AgentRoam requires ${OPENCODE_RUNTIME_VERSION}`);
188
+ if (!meetsOpenCodeMinimum(version)) {
189
+ throw new Error(`OpenCode ${version} at ${executable} is incompatible; AgentRoam requires >=${OPENCODE_MINIMUM_VERSION}`);
188
190
  }
189
191
  await dependencies.run(executable, ["serve", "--help"], 5_000);
190
192
  return { executable, version, source };
191
193
  }
194
+ // Compare numeric components, so 1.18.100 and 1.19.0 both exceed 1.18.27.
195
+ function meetsOpenCodeMinimum(version) {
196
+ const actual = version.split(".").map(BigInt);
197
+ const minimum = OPENCODE_MINIMUM_VERSION.split(".").map(BigInt);
198
+ for (let index = 0; index < minimum.length; index += 1) {
199
+ if (actual[index] !== minimum[index])
200
+ return actual[index] > minimum[index];
201
+ }
202
+ return true;
203
+ }
192
204
  async function accessible(path, executable = false) {
193
205
  try {
194
206
  await access(path, executable ? constants.X_OK : constants.F_OK);
@@ -2,7 +2,7 @@ import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
- import { OPENCODE_RUNTIME_VERSION, managedOpenCodeBinaryCandidates, parseOpenCodeVersion, resolveOpenCodeRuntime, } from "./opencode-runtime-manager.js";
5
+ import { OPENCODE_RUNTIME_VERSION, OPENCODE_MINIMUM_VERSION, managedOpenCodeBinaryCandidates, parseOpenCodeVersion, resolveOpenCodeRuntime, } from "./opencode-runtime-manager.js";
6
6
  const roots = [];
7
7
  afterEach(async () => {
8
8
  await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
@@ -31,6 +31,38 @@ describe("resolveOpenCodeRuntime", () => {
31
31
  })).resolves.toEqual({ executable: binary.path, version: OPENCODE_RUNTIME_VERSION, source: "explicit" });
32
32
  expect(run).toHaveBeenCalledWith(binary.path, ["serve", "--help"], 5_000);
33
33
  });
34
+ it.each([OPENCODE_MINIMUM_VERSION, "1.18.30", "1.18.100", "1.19.0", "2.0.0"])("reuses PATH version %s without installation", async (version) => {
35
+ const binary = await executable();
36
+ const run = vi.fn(async (_command, args) => ({
37
+ stdout: args[0] === "--version" ? version : "serve help", stderr: "",
38
+ }));
39
+ await expect(resolveOpenCodeRuntime({
40
+ dataDir: binary.root, target: "darwin-arm64", platform: "darwin",
41
+ environment: { PATH: binary.root }, dependencies: { run },
42
+ })).resolves.toEqual({ executable: binary.path, version, source: "global" });
43
+ expect(run.mock.calls.map(([, args]) => args)).toEqual([["--version"], ["serve", "--help"]]);
44
+ });
45
+ it.each(["1.18.26", "1.18.9", "1.17.99", "0.99.99", "dev", "1.19.0-beta.1", "01.19.0"])("rejects unsupported explicit version %s", async (version) => {
46
+ const binary = await executable();
47
+ const run = vi.fn(async () => ({ stdout: version, stderr: "" }));
48
+ await expect(resolveOpenCodeRuntime({
49
+ dataDir: binary.root, target: "darwin-arm64", platform: "darwin",
50
+ environment: { AGENT_OPENCODE_BIN: binary.path, PATH: "" }, dependencies: { run },
51
+ })).rejects.toThrow(/requires >=|Unable to parse OpenCode version/);
52
+ expect(run).toHaveBeenCalledTimes(1);
53
+ });
54
+ it("rejects a newer executable without serve support", async () => {
55
+ const binary = await executable();
56
+ await expect(resolveOpenCodeRuntime({
57
+ dataDir: binary.root, target: "darwin-arm64", platform: "darwin",
58
+ environment: { AGENT_OPENCODE_BIN: binary.path, PATH: "" },
59
+ dependencies: { run: async (_command, args) => {
60
+ if (args[0] === "serve")
61
+ throw new Error("serve unavailable");
62
+ return { stdout: "1.18.30", stderr: "" };
63
+ } },
64
+ })).rejects.toThrow("serve unavailable");
65
+ });
34
66
  it("rejects relative explicit overrides", async () => {
35
67
  await expect(resolveOpenCodeRuntime({
36
68
  dataDir: "/data",
@@ -1,7 +1,8 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { dirname } from "node:path";
4
- export const AGENTROAM_VERSION = "0.2.0-preview.16";
4
+ import { MINIMUM_NODE_VERSION, assertSupportedNodeVersion } from "../bin/runtime-policy.mjs";
5
+ export const AGENTROAM_VERSION = "0.2.0-preview.17";
5
6
  export const PLATFORM_PACKAGES = {
6
7
  "darwin-arm64": {
7
8
  runtime: "agentroam-runtime-darwin-arm64",
@@ -37,12 +38,14 @@ export function resolvePlatformRuntime(target, requireFromCli = createRequire(im
37
38
  const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
38
39
  if (manifest.packageVersion !== AGENTROAM_VERSION
39
40
  || manifest.target !== target
40
- || manifest.nodeMajor !== 22
41
- || manifest.nodeModuleAbi !== 127
41
+ || manifest.schemaVersion !== 2
42
+ || manifest.minimumNodeVersion !== MINIMUM_NODE_VERSION
42
43
  || !manifest.nativeFiles
43
- || typeof manifest.nativeFiles !== "object") {
44
+ || typeof manifest.nativeFiles !== "object"
45
+ || Array.isArray(manifest.nativeFiles)) {
44
46
  throw new Error(`invalid AgentRoam runtime manifest: ${manifestPath}`);
45
47
  }
48
+ assertSupportedNodeVersion();
46
49
  return {
47
50
  packageName,
48
51
  runtimeRoot: dirname(runtimePackageJson),
@@ -28,8 +28,8 @@ describe("platform packages", () => {
28
28
  writeFileSync(manifestPath, JSON.stringify({
29
29
  packageVersion: AGENTROAM_VERSION,
30
30
  target: "windows-amd64",
31
- nodeMajor: 22,
32
- nodeModuleAbi: 127,
31
+ schemaVersion: 2,
32
+ minimumNodeVersion: "22.22.0",
33
33
  nativeFiles: { "node_modules/example.node": "a".repeat(64) },
34
34
  }));
35
35
  const resolved = resolvePlatformRuntime("windows-amd64", fakeRequire({
@@ -48,8 +48,8 @@ describe("platform packages", () => {
48
48
  writeFileSync(manifestPath, JSON.stringify({
49
49
  packageVersion: "0.0.0",
50
50
  target: "darwin-arm64",
51
- nodeMajor: 22,
52
- nodeModuleAbi: 127,
51
+ schemaVersion: 2,
52
+ minimumNodeVersion: "22.22.0",
53
53
  nativeFiles: {},
54
54
  }));
55
55
  expect(() => resolvePlatformRuntime("windows-amd64", fakeRequire({
@@ -61,6 +61,17 @@ describe("platform packages", () => {
61
61
  const entry = resolvePlatformTui("windows-amd64", fakeRequire({ "/entry": "C:\\agentroam\\agent-tui.js" }));
62
62
  expect(entry).toBe("C:\\agentroam\\agent-tui.js");
63
63
  });
64
+ it("rejects legacy ABI manifests even when their release version matches", () => {
65
+ const root = mkdtempSync(resolve(tmpdir(), "agentroam-runtime-legacy-"));
66
+ const manifestPath = resolve(root, "manifest.json");
67
+ writeFileSync(manifestPath, JSON.stringify({
68
+ packageVersion: AGENTROAM_VERSION, target: "darwin-arm64",
69
+ nodeMajor: 22, nodeModuleAbi: 127, nativeFiles: {},
70
+ }));
71
+ expect(() => resolvePlatformRuntime("darwin-arm64", fakeRequire({
72
+ "/manifest.json": manifestPath, "/runtime": resolve(root, "runtime/package.json"),
73
+ }))).toThrow("invalid AgentRoam runtime manifest");
74
+ });
64
75
  });
65
76
  function fakeRequire(suffixes) {
66
77
  return {
package/dist/platform.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { assertSupportedNodeVersion } from "../bin/runtime-policy.mjs";
1
2
  export function detectPlatformTarget(platform = process.platform, arch = process.arch) {
2
3
  if (platform === "darwin" && arch === "arm64")
3
4
  return "darwin-arm64";
@@ -8,9 +9,6 @@ export function detectPlatformTarget(platform = process.platform, arch = process
8
9
  throw Object.assign(new Error(`unsupported platform: ${platform}-${arch}`), { exitCode: 2 });
9
10
  }
10
11
  export function detectPlatform(platform = process.platform, arch = process.arch, nodeVersion = process.versions.node) {
11
- const major = Number(nodeVersion.split(".")[0]);
12
- if (major !== 22) {
13
- throw Object.assign(new Error(`Node.js 22 required (current ${nodeVersion})`), { exitCode: 2 });
14
- }
12
+ assertSupportedNodeVersion(nodeVersion);
15
13
  return detectPlatformTarget(platform, arch);
16
14
  }
@@ -2,15 +2,15 @@ import { describe, expect, it } from "vitest";
2
2
  import { detectPlatform, detectPlatformTarget } from "./platform.js";
3
3
  describe("detectPlatform", () => {
4
4
  it("maps supported targets", () => {
5
- expect(detectPlatform("darwin", "arm64", "22.1.0")).toBe("darwin-arm64");
6
- expect(detectPlatform("win32", "x64", "22.1.0")).toBe("windows-amd64");
5
+ expect(detectPlatform("darwin", "arm64", "22.22.0")).toBe("darwin-arm64");
6
+ expect(detectPlatform("win32", "x64", "25.8.0")).toBe("windows-amd64");
7
7
  });
8
8
  it("detects a target before Node version validation", () => {
9
9
  expect(detectPlatformTarget("darwin", "arm64")).toBe("darwin-arm64");
10
10
  expect(detectPlatformTarget("win32", "x64")).toBe("windows-amd64");
11
11
  });
12
12
  it("rejects unsupported versions", () => {
13
- expect(() => detectPlatform("darwin", "arm64", "23.0.0")).toThrow("Node.js 22");
13
+ expect(() => detectPlatform("darwin", "arm64", "22.21.9")).toThrow("Node.js >=22.22.0");
14
14
  });
15
15
  it("rejects unsupported platforms", () => {
16
16
  expect(() => detectPlatformTarget("linux", "x64")).toThrow("unsupported platform");
@@ -4,6 +4,7 @@ import { detectPlatform } from "../platform.js";
4
4
  import { renderQr } from "../qr.js";
5
5
  import { MacLaunchAgent } from "./macos-launch-agent.js";
6
6
  import { WindowsTaskService } from "./windows-task-service.js";
7
+ import { assertSupportedNodeVersion } from "../../bin/runtime-policy.mjs";
7
8
  export async function runServiceCommand(options, context) {
8
9
  const platform = context.platform ?? process.platform;
9
10
  const log = context.log ?? console.log;
@@ -19,9 +20,7 @@ export async function runServiceCommand(options, context) {
19
20
  switch (options.serviceAction) {
20
21
  case "install": {
21
22
  const nodeVersion = context.nodeVersion ?? process.versions.node;
22
- if (Number(nodeVersion.split(".")[0]) !== 22) {
23
- throw cliError(`Node.js 22 is required to install the service (current ${nodeVersion})`);
24
- }
23
+ assertSupportedNodeVersion(nodeVersion);
25
24
  const now = context.now?.() ?? new Date();
26
25
  const environment = context.environment ?? process.env;
27
26
  const environmentPath = buildServiceEnvironmentPath(context.nodePath, environment.PATH ?? environment.Path, platform);
@@ -2,6 +2,26 @@ import { resolve } from "node:path";
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
  import { buildServiceEnvironmentPath, runServiceCommand } from "./service-command.js";
4
4
  describe("runServiceCommand", () => {
5
+ it.each(["22.22.0", "24.0.0", "25.8.0", "26.0.0"])("keeps the selected Node %s when installing a service", async (nodeVersion) => {
6
+ const install = vi.fn(async () => ({ definition: "/test/service.plist" }));
7
+ const nodePath = `/runtimes/node-${nodeVersion}/bin/node`;
8
+ const codexResolver = vi.fn(async () => ({ executable: "/tools/codex", version: "test", source: "global" }));
9
+ await runServiceCommand(options("install"), {
10
+ platform: "darwin", arch: "arm64", nodeVersion, nodePath,
11
+ cliPath: "/agentroam.mjs", version: "test", environment: { PATH: "/usr/bin" },
12
+ codexResolver, controller: controller({ install }), log: vi.fn(),
13
+ });
14
+ expect(install).toHaveBeenCalledWith(expect.objectContaining({ nodePath }));
15
+ expect(codexResolver).toHaveBeenCalledWith(expect.objectContaining({ nodeExecutable: nodePath }));
16
+ });
17
+ it("rejects a below-minimum service runtime before installation", async () => {
18
+ const install = vi.fn();
19
+ await expect(runServiceCommand(options("install"), {
20
+ platform: "darwin", nodePath: "/node", nodeVersion: "22.21.9", cliPath: "/agentroam.mjs",
21
+ version: "test", controller: controller({ install }),
22
+ })).rejects.toThrow("Node.js >=22.22.0");
23
+ expect(install).not.toHaveBeenCalled();
24
+ });
5
25
  it("installs the service using absolute runtime context and parsed start options", async () => {
6
26
  const install = vi.fn(async (config) => ({
7
27
  paths: { plistPath: "/Users/test/Library/LaunchAgents/com.agentroam.service.plist" },
@@ -22,7 +22,7 @@ export async function waitForPublicReadiness(publicUrl, options = {}) {
22
22
  try {
23
23
  const response = await fetchImpl(endpoint, {
24
24
  cache: "no-store",
25
- headers: { "user-agent": "agentroam-readiness/0.2.0-preview.16" },
25
+ headers: { "user-agent": "agentroam-readiness/0.2.0-preview.17" },
26
26
  signal: options.signal,
27
27
  });
28
28
  if (response.ok) {
package/install/README.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # AgentRoam bootstrap installers
2
2
 
3
- These versioned installers support macOS Apple Silicon and Windows x64. They reuse a visible Node.js 22 installation when available. Otherwise they install the verified private Node.js `22.22.0` runtime under the AgentRoam data directory. They then register and start the current-user AgentRoam background service. They never modify, replace, or uninstall system Node.js or global power settings.
3
+ These versioned installers support macOS Apple Silicon and Windows x64. They reuse the active Node.js installation when it meets `>=22.22.0`, with no upper version bound. Otherwise they search NVM for the highest compatible version, then fall back to the verified private Node.js `22.22.0` runtime under the AgentRoam data directory. They then register and start the current-user AgentRoam background service. They never modify, replace, or uninstall system Node.js or global power settings.
4
4
 
5
5
  ## macOS Apple Silicon
6
6
 
7
7
  ```sh
8
- curl -fsSL https://gitee.com/caoqu/team-agent/releases/download/v0.2.0-preview.16/install-agentroam.sh | sh
8
+ curl -fsSL https://gitee.com/caoqu/team-agent/releases/download/v0.2.0-preview.17/install-agentroam.sh | sh
9
9
  ```
10
10
 
11
11
  The command installs the wrapper at `~/.local/bin/agentroam`. If that directory is not already in `PATH`, the installer prints the required shell setting without editing shell profiles.
@@ -13,7 +13,7 @@ The command installs the wrapper at `~/.local/bin/agentroam`. If that directory
13
13
  ## Windows x64
14
14
 
15
15
  ```powershell
16
- irm https://gitee.com/caoqu/team-agent/releases/download/v0.2.0-preview.16/install-agentroam.ps1 | iex
16
+ irm https://gitee.com/caoqu/team-agent/releases/download/v0.2.0-preview.17/install-agentroam.ps1 | iex
17
17
  ```
18
18
 
19
19
  The command installs `%USERPROFILE%\.agentroam\bin\agentroam.cmd` and adds only that user directory to the user-level `PATH`. It does not require administrator access or change the machine-level `PATH`.
@@ -40,7 +40,7 @@ agentroam service uninstall
40
40
 
41
41
  ## Data and updates
42
42
 
43
- Set `AGENTROAM_DATA_DIR` before running either installer to override the default `~/.agentroam` data directory. Node is stored at `<data-dir>/runtimes/node/22.22.0`, and the launcher is stored at `<data-dir>/launcher/0.2.0-preview.16`.
43
+ Set `AGENTROAM_DATA_DIR` before running either installer to override the default `~/.agentroam` data directory. Node is stored at `<data-dir>/runtimes/node/22.22.0`, and the launcher is stored at `<data-dir>/launcher/0.2.0-preview.17`.
44
44
 
45
45
  Each AgentRoam release pins its own Node patch and installer assets. Updates happen only by running a newer versioned installer or installing a newer AgentRoam release; startup never follows a moving Node.js release channel.
46
46
 
@@ -2,7 +2,8 @@ $ErrorActionPreference = "Stop"
2
2
  $ProgressPreference = "SilentlyContinue"
3
3
 
4
4
  $NodeVersion = "22.22.0"
5
- $AgentRoamVersion = "0.2.0-preview.16"
5
+ $MinimumNodeVersion = "22.22.0"
6
+ $AgentRoamVersion = "0.2.0-preview.17"
6
7
  $NodeArchive = "node-v22.22.0-win-x64.zip"
7
8
  $NodeSha256 = "c97fa376d2becdc8863fcd3ca2dd9a83a9f3468ee7ccf7a6d076ec66a645c77a"
8
9
  $NodeUrl = "https://nodejs.org/dist/v22.22.0/$NodeArchive"
@@ -27,9 +28,14 @@ if (-not $ServiceRootExplicit -and $ServiceRoot -eq $HomeRoot) {
27
28
 
28
29
  New-Item -ItemType Directory -Force -Path $NodeParent, $LauncherParent, $WrapperDir | Out-Null
29
30
 
30
- function Test-Node22([string]$NodeBin) {
31
+ function Test-NodeVersion([string]$VersionText) {
32
+ if ($VersionText -notmatch '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$') { return $false }
33
+ try { return [Version]($VersionText.Split('+')[0]) -ge [Version]$MinimumNodeVersion } catch { return $false }
34
+ }
35
+
36
+ function Test-SupportedNode([string]$NodeBin) {
31
37
  if (-not (Test-Path -LiteralPath $NodeBin -PathType Leaf)) { return $false }
32
- try { return (& $NodeBin -p 'process.versions.node.split(".")[0]' 2>$null) -eq "22" } catch { return $false }
38
+ try { return Test-NodeVersion ((& $NodeBin -p 'process.versions.node' 2>$null).Trim()) } catch { return $false }
33
39
  }
34
40
 
35
41
  function Get-NvmRoots {
@@ -50,15 +56,15 @@ function Get-NvmRoots {
50
56
  @($Candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_ -PathType Container) } | Select-Object -Unique)
51
57
  }
52
58
 
53
- function Find-NvmNode22 {
59
+ function Find-NvmNode {
54
60
  $VersionMatches = foreach ($NvmRoot in (Get-NvmRoots)) {
55
61
  foreach ($VersionDirectory in Get-ChildItem -LiteralPath $NvmRoot -Directory -ErrorAction SilentlyContinue) {
56
62
  $Candidate = Join-Path $VersionDirectory.FullName "node.exe"
57
63
  if (-not (Test-Path -LiteralPath $Candidate -PathType Leaf)) { continue }
58
64
  try {
59
65
  $VersionText = (& $Candidate -p 'process.versions.node' 2>$null).Trim()
60
- $Version = [Version]$VersionText
61
- if ($Version.Major -eq 22) {
66
+ if (Test-NodeVersion $VersionText) {
67
+ $Version = [Version]($VersionText.Split('+')[0])
62
68
  [pscustomobject]@{ Path = $Candidate; Version = $Version }
63
69
  }
64
70
  } catch {}
@@ -97,10 +103,10 @@ function Enter-InstallLock([string]$Path) {
97
103
 
98
104
  $SystemNode = Get-Command node.exe -ErrorAction SilentlyContinue
99
105
  if ($env:AGENTROAM_BOOTSTRAP_TEST -eq "1" -and $env:AGENTROAM_FORCE_PRIVATE_NODE -eq "1") { $SystemNode = $null }
100
- if ($SystemNode -and (Test-Node22 $SystemNode.Source)) {
106
+ if ($SystemNode -and (Test-SupportedNode $SystemNode.Source)) {
101
107
  $NodeBin = $SystemNode.Source
102
108
  } else {
103
- $NvmNode = if ($env:AGENTROAM_BOOTSTRAP_TEST -eq "1" -and $env:AGENTROAM_FORCE_PRIVATE_NODE -eq "1") { $null } else { Find-NvmNode22 }
109
+ $NvmNode = if ($env:AGENTROAM_BOOTSTRAP_TEST -eq "1" -and $env:AGENTROAM_FORCE_PRIVATE_NODE -eq "1") { $null } else { Find-NvmNode }
104
110
  if ($NvmNode) {
105
111
  $NodeBin = $NvmNode
106
112
  Write-Host "Using Node.js $(& $NodeBin --version) from NVM: $NodeBin"
@@ -2,7 +2,8 @@
2
2
  set -eu
3
3
 
4
4
  NODE_VERSION="22.22.0"
5
- AGENTROAM_VERSION="0.2.0-preview.16"
5
+ MINIMUM_NODE_VERSION="22.22.0"
6
+ AGENTROAM_VERSION="0.2.0-preview.17"
6
7
  NODE_ARCHIVE="node-v22.22.0-darwin-arm64.tar.xz"
7
8
  NODE_SHA256="2bd596bbfc4a275ceb8721a5954ee97daea5ebe673e96a185ebd732f6fb023ac"
8
9
  NODE_URL="https://nodejs.org/dist/v22.22.0/$NODE_ARCHIVE"
@@ -56,25 +57,40 @@ SERVICE_ROOT="$service_root_physical"
56
57
 
57
58
  mkdir -p "$NODE_PARENT" "$LAUNCHER_PARENT" "$WRAPPER_DIR"
58
59
 
59
- node_is_22() {
60
- [ -x "$1" ] && [ "$("$1" -p 'process.versions.node.split(".")[0]' 2>/dev/null || true)" = "22" ]
60
+ version_is_supported() {
61
+ awk -v version="$1" -v minimum="$MINIMUM_NODE_VERSION" 'BEGIN {
62
+ if (version !~ /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/) exit 1;
63
+ sub(/\+.*/, "", version);
64
+ split(version, actual, "."); split(minimum, required, ".");
65
+ for (component = 1; component <= 3; component++) {
66
+ if (actual[component] + 0 > required[component] + 0) exit 0;
67
+ if (actual[component] + 0 < required[component] + 0) exit 1;
68
+ }
69
+ exit 0;
70
+ }'
61
71
  }
62
72
 
63
- find_nvm_node_22() {
73
+ node_is_supported() {
74
+ [ -x "$1" ] && version_is_supported "$("$1" -p 'process.versions.node' 2>/dev/null || true)"
75
+ }
76
+
77
+ find_nvm_node() {
64
78
  best_bin=""
79
+ best_major=-1
65
80
  best_minor=-1
66
81
  best_patch=-1
67
82
  for nvm_root in "${NVM_DIR:-}" "$HOME/.nvm"; do
68
83
  [ -n "$nvm_root" ] || continue
69
- for candidate in "$nvm_root"/versions/node/v22*/bin/node; do
84
+ for candidate in "$nvm_root"/versions/node/v*/bin/node; do
70
85
  [ -x "$candidate" ] || continue
71
86
  version=$("$candidate" -p 'process.versions.node' 2>/dev/null || true)
87
+ version_is_supported "$version" || continue
72
88
  major=$(printf '%s\n' "$version" | awk -F. '{ print $1 + 0 }')
73
89
  minor=$(printf '%s\n' "$version" | awk -F. '{ print $2 + 0 }')
74
90
  patch=$(printf '%s\n' "$version" | awk -F. '{ sub(/[^0-9].*$/, "", $3); print $3 + 0 }')
75
- [ "$major" -eq 22 ] 2>/dev/null || continue
76
- if [ "$minor" -gt "$best_minor" ] || { [ "$minor" -eq "$best_minor" ] && [ "$patch" -gt "$best_patch" ]; }; then
91
+ if [ "$major" -gt "$best_major" ] || { [ "$major" -eq "$best_major" ] && { [ "$minor" -gt "$best_minor" ] || { [ "$minor" -eq "$best_minor" ] && [ "$patch" -gt "$best_patch" ]; }; }; }; then
77
92
  best_bin="$candidate"
93
+ best_major=$major
78
94
  best_minor=$minor
79
95
  best_patch=$patch
80
96
  fi
@@ -112,13 +128,13 @@ SYSTEM_NODE=$(command -v node 2>/dev/null || true)
112
128
  if [ "${AGENTROAM_BOOTSTRAP_TEST:-}" = "1" ] && [ "${AGENTROAM_FORCE_PRIVATE_NODE:-}" = "1" ]; then
113
129
  SYSTEM_NODE=""
114
130
  fi
115
- if [ -n "$SYSTEM_NODE" ] && node_is_22 "$SYSTEM_NODE"; then
131
+ if [ -n "$SYSTEM_NODE" ] && node_is_supported "$SYSTEM_NODE"; then
116
132
  NODE_BIN="$SYSTEM_NODE"
117
133
  else
118
134
  if [ "${AGENTROAM_BOOTSTRAP_TEST:-}" = "1" ] && [ "${AGENTROAM_FORCE_PRIVATE_NODE:-}" = "1" ]; then
119
135
  NVM_NODE=""
120
136
  else
121
- NVM_NODE=$(find_nvm_node_22 || true)
137
+ NVM_NODE=$(find_nvm_node || true)
122
138
  fi
123
139
  if [ -n "$NVM_NODE" ]; then
124
140
  NODE_BIN="$NVM_NODE"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentroam",
3
- "version": "0.2.0-preview.16",
3
+ "version": "0.2.0-preview.17",
4
4
  "description": "Zero-install mobile remote terminal and file console",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  "agent-tui": "./bin/agent-tui.mjs"
9
9
  },
10
10
  "engines": {
11
- "node": ">=22 <23"
11
+ "node": ">=22.22.0"
12
12
  },
13
13
  "files": [
14
14
  "bin",
@@ -26,12 +26,12 @@
26
26
  "undici": "^6.25.0"
27
27
  },
28
28
  "optionalDependencies": {
29
- "agentroam-runtime-darwin-arm64": "0.2.0-preview.16",
30
- "agentroam-runtime-win32-x64": "0.2.0-preview.16",
31
- "agentroam-cloudflared-darwin-arm64": "0.2.0-preview.16",
32
- "agentroam-cloudflared-win32-x64": "0.2.0-preview.16",
33
- "agentroam-tui-darwin-arm64": "0.2.0-preview.16",
34
- "@caoqu/agentroam-tui-win32-x64": "0.2.0-preview.16"
29
+ "agentroam-runtime-darwin-arm64": "0.2.0-preview.17",
30
+ "agentroam-runtime-win32-x64": "0.2.0-preview.17",
31
+ "agentroam-cloudflared-darwin-arm64": "0.2.0-preview.17",
32
+ "agentroam-cloudflared-win32-x64": "0.2.0-preview.17",
33
+ "agentroam-tui-darwin-arm64": "0.2.0-preview.17",
34
+ "@caoqu/agentroam-tui-win32-x64": "0.2.0-preview.17"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^22.0.0",