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 +16 -15
- package/bin/node-preflight.mjs +15 -22
- package/bin/node-runtime-policy.json +4 -0
- package/bin/runtime-policy.d.mts +5 -0
- package/bin/runtime-policy.mjs +37 -0
- package/dist/args.js +17 -2
- package/dist/args.test.js +5 -0
- package/dist/cli.js +20 -4
- package/dist/install-script-contract.test.js +31 -6
- package/dist/native-runtime.js +46 -0
- package/dist/native-runtime.test.js +23 -2
- package/dist/node-preflight.test.js +21 -15
- package/dist/opencode-runtime-manager.js +15 -3
- package/dist/opencode-runtime-manager.test.js +33 -1
- package/dist/platform-packages.js +7 -4
- package/dist/platform-packages.test.js +15 -4
- package/dist/platform.js +2 -4
- package/dist/platform.test.js +3 -3
- package/dist/runtime-manager.js +4 -0
- package/dist/service/service-command.js +2 -3
- package/dist/service/service-command.test.js +20 -0
- package/dist/tunnel/public-readiness.js +1 -1
- package/dist/update/update-channel.js +38 -0
- package/dist/update/update-command.js +102 -0
- package/dist/update/update-command.test.js +70 -0
- package/dist/update/update-state.js +43 -0
- package/dist/update/update-worker.js +71 -0
- package/install/README.md +4 -4
- package/install/install-agentroam.ps1 +14 -8
- package/install/install-agentroam.sh +25 -9
- package/package.json +8 -8
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** (
|
|
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.
|
|
24
|
-
./agentroam-runtime-darwin-arm64-0.2.0-preview.
|
|
25
|
-
./agentroam-tui-darwin-arm64-0.2.0-preview.
|
|
26
|
-
./agentroam-0.2.0-preview.
|
|
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.
|
|
34
|
-
./agentroam-runtime-win32-x64-0.2.0-preview.
|
|
35
|
-
./caoqu-agentroam-tui-win32-x64-0.2.0-preview.
|
|
36
|
-
./agentroam-0.2.0-preview.
|
|
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
|
|
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
|
-
|
|
66
|
-
nvm-
|
|
67
|
-
|
|
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
|
|
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.
|
package/bin/node-preflight.mjs
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
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
|
|
37
|
-
const systemNode = await
|
|
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:
|
|
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
|
|
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
|
|
88
|
+
if (isSupportedNodeVersion(version)) return candidate;
|
|
89
89
|
}
|
|
90
|
-
return
|
|
90
|
+
return findNvmNode(environment, platform);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
export async function
|
|
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
|
|
112
|
+
if (isSupportedNodeVersion(version)) candidates.push({ candidate, version });
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
|
-
candidates.sort((left, right) =>
|
|
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
|
|
128
|
-
return
|
|
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,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 [
|
|
152
|
+
for (const [module, probe] of [
|
|
153
|
+
["node-pty", probeTerminalRuntime],
|
|
154
|
+
["better-sqlite3", probeSQLiteRuntime],
|
|
155
|
+
]) {
|
|
140
156
|
try {
|
|
141
|
-
runtimeRequire
|
|
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("
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
|
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" ||
|
|
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");
|
package/dist/native-runtime.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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", "
|
|
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
|
-
|
|
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: "
|
|
45
|
+
env: expect.objectContaining({ AGENTROAM_MANAGED_NODE: "/managed/node" }),
|
|
42
46
|
}));
|
|
43
47
|
});
|
|
44
|
-
it("reuses another visible system Node
|
|
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: "
|
|
53
|
+
nodeVersion: "20.0.0",
|
|
50
54
|
platform: "win32",
|
|
51
55
|
arch: "x64",
|
|
52
56
|
environment: { PATH: "" },
|
|
53
|
-
|
|
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:\\
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
103
|
+
findSystemNode: async () => "/compatible-node",
|
|
98
104
|
spawnChild: () => child,
|
|
99
105
|
processHost,
|
|
100
106
|
});
|