agentroam 0.2.0-preview.16 → 0.2.0-preview.18

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.18.tgz \
24
+ ./agentroam-runtime-darwin-arm64-0.2.0-preview.18.tgz \
25
+ ./agentroam-tui-darwin-arm64-0.2.0-preview.18.tgz \
26
+ ./agentroam-0.2.0-preview.18.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.18.tgz `
34
+ ./agentroam-runtime-win32-x64-0.2.0-preview.18.tgz `
35
+ ./caoqu-agentroam-tui-win32-x64-0.2.0-preview.18.tgz `
36
+ ./agentroam-0.2.0-preview.18.tgz
37
37
  npx agentroam
38
38
  ```
39
39
 
@@ -57,16 +57,16 @@ 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
- different root explicitly. An implicit root equal to the home directory is
63
- rejected.
62
+ different root explicitly. The current directory, including the home directory, is accepted.
64
63
 
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
64
+ The installer reuses the active Node when it meets the minimum version. Otherwise
65
+ it checks nvm-sh on macOS and nvm-windows on Windows and uses the highest
66
+ compatible installed version. The service records that executable without
68
67
  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.
68
+ downloaded only when no compatible installed Node is available. Higher Node
69
+ versions are never rejected or downgraded solely because of their major version.
70
70
 
71
71
  The service keeps running after the terminal closes, restarts after failure, and
72
72
  starts again when the user logs in. macOS uses a user LaunchAgent; Windows uses a
@@ -117,7 +117,7 @@ The platform cloudflared binary is installed from npm after size and SHA-256 ver
117
117
  - Next.js **standalone** web gateway + WebSocket PTY server
118
118
  - `@agent/core` bundled runtime
119
119
  - Optional platform **cloudflared** package (verified size + SHA-256 before execution)
120
- - Optional platform **agent-tui** package (Node 22 bundle; Bun is not required)
120
+ - Optional platform **agent-tui** package (Node >=22.22.0 bundle; Bun is not required)
121
121
  - **Pairing token** + secure cookies behind HTTPS tunnel
122
122
 
123
123
  Not included: Electron desktop app, SDK, dev `.env`, session caches.
@@ -0,0 +1,62 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { mkdir, mkdtemp, rename, rm } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { join, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { Transform, Readable } from 'node:stream';
8
+ import { pipeline } from 'node:stream/promises';
9
+ import { execFile } from 'node:child_process';
10
+
11
+ const RELEASE_BASE = 'https://gitee.com/caoqu/team-agent/releases/download';
12
+
13
+ export function desktopAsset(manifest, version, platform, arch) {
14
+ if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-preview\.(0|[1-9]\d*))?$/.test(version)) throw new Error('Invalid AgentRoam version');
15
+ const target = platform === 'darwin' && arch === 'arm64' ? 'darwin-arm64' : platform === 'win32' && arch === 'x64' ? 'windows-amd64' : null;
16
+ if (!target) throw new Error('Desktop download supports macOS Apple Silicon and Windows x64');
17
+ const asset = manifest?.installers?.desktop?.[target];
18
+ const expected = platform === 'darwin' ? `AgentRoam-${version}-arm64.dmg` : `AgentRoam-Setup-${version}-x64.exe`;
19
+ if (manifest?.schemaVersion !== 2 || manifest.version !== version || manifest.channel !== (version.includes('-preview.') ? 'preview' : 'latest') || asset?.fileName !== expected || !/^[a-f0-9]{64}$/.test(asset?.sha256 ?? '') || asset.signed !== false || (asset.size !== undefined && (!Number.isSafeInteger(asset.size) || asset.size <= 0))) {
20
+ throw new Error('This release has no valid desktop installer for your platform');
21
+ }
22
+ return asset;
23
+ }
24
+
25
+ export async function downloadDesktop(version, { platform = process.platform, arch = process.arch, directory = join(homedir(), 'Downloads'), fetchImpl = fetch } = {}) {
26
+ // Validate coordinates before making any request.
27
+ if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-preview\.(0|[1-9]\d*))?$/.test(version)) throw new Error('Invalid AgentRoam version');
28
+ const response = await fetchImpl(`${RELEASE_BASE}/v${version}/release-manifest.json`, { signal: AbortSignal.timeout(30_000) });
29
+ if (!response.ok) throw new Error(`Desktop release manifest unavailable (${response.status})`);
30
+ const asset = desktopAsset(await response.json(), version, platform, arch);
31
+ await mkdir(directory, { recursive: true });
32
+ // Unique directory prevents overwriting an existing installer, including concurrent downloads.
33
+ const staging = await mkdtemp(join(directory, 'AgentRoam-'));
34
+ const partial = join(staging, `${asset.fileName}.download`);
35
+ try {
36
+ const binary = await fetchImpl(`${RELEASE_BASE}/v${version}/${encodeURIComponent(asset.fileName)}`, { signal: AbortSignal.timeout(600_000) });
37
+ if (!binary.ok || !binary.body) throw new Error(`Desktop download failed (${binary.status})`);
38
+ const hash = createHash('sha256');
39
+ let size = 0;
40
+ const verifier = new Transform({ transform(chunk, _encoding, callback) { size += chunk.length; hash.update(chunk); callback(null, chunk); } });
41
+ await pipeline(Readable.fromWeb(binary.body), verifier, createWriteStream(partial, { flags: 'wx', mode: 0o600 }));
42
+ if (hash.digest('hex') !== asset.sha256 || (asset.size !== undefined && asset.size !== size)) throw new Error('Desktop installer checksum or size mismatch');
43
+ const destination = join(staging, asset.fileName);
44
+ await rename(partial, destination);
45
+ return destination;
46
+ } catch (error) {
47
+ await rm(staging, { recursive: true, force: true });
48
+ throw error;
49
+ }
50
+ }
51
+
52
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
53
+ try {
54
+ const destination = await downloadDesktop(process.argv[2]);
55
+ console.log(`桌面端安装包已下载:${destination}\n请打开安装包完成安装。首次打开 AgentRoam 后,按提示授权远程桌面。`);
56
+ if (process.platform === 'darwin') execFile('/usr/bin/open', ['-R', destination], () => undefined);
57
+ else if (process.platform === 'win32') execFile('explorer.exe', [`/select,${destination}`], () => undefined);
58
+ } catch (error) {
59
+ console.error(`桌面端下载未完成:${error.message}。CLI 已安装,可继续使用。`);
60
+ process.exitCode = 1;
61
+ }
62
+ }
@@ -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.18";
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", "update", "update-worker"].includes(values[0])) {
6
+ if (values[0] && ["start", "doctor", "version", "service", "update", "update-worker", "pair", "devices", "revoke", "approvals", "approve", "deny", "lock", "unlock", "audit"].includes(values[0])) {
7
7
  command = values.shift();
8
8
  }
9
9
  let serviceAction = null;
@@ -17,6 +17,23 @@ export function parseArgs(argv) {
17
17
  throw cliError(`service ${serviceAction} does not accept options`);
18
18
  }
19
19
  }
20
+ let revokeDeviceId = null;
21
+ let revokeAll = false;
22
+ if (command === "revoke") {
23
+ const device = values.shift();
24
+ if (device === "--all")
25
+ revokeAll = true;
26
+ else if (device && /^[a-f0-9-]{36}$/.test(device))
27
+ revokeDeviceId = device;
28
+ else
29
+ throw cliError("revoke requires a device ID or --all");
30
+ }
31
+ let approvalRequestId;
32
+ if (command === "approve" || command === "deny") {
33
+ approvalRequestId = values.shift();
34
+ if (!approvalRequestId || !/^[a-f0-9-]{36}$/.test(approvalRequestId))
35
+ throw cliError(`${command} requires a request ID`);
36
+ }
20
37
  let updateVersion = null;
21
38
  let updateStateFile = null;
22
39
  if (command === "update" && values[0] && !values[0].startsWith("--"))
@@ -37,6 +54,8 @@ export function parseArgs(argv) {
37
54
  localOnly: false,
38
55
  qr: true,
39
56
  dataDir: resolve(homedir(), ".agentroam"),
57
+ ...(command === "revoke" ? { revokeDeviceId, revokeAll } : {}),
58
+ ...(approvalRequestId ? { approvalRequestId } : {}),
40
59
  updateVersion,
41
60
  updateStateFile,
42
61
  };
@@ -48,7 +67,9 @@ export function parseArgs(argv) {
48
67
  throw cliError(`missing value for ${arg}`);
49
68
  return value;
50
69
  };
51
- if (arg === "--root")
70
+ if (arg === "--url" && command === "pair")
71
+ options.pairingUrl = next();
72
+ else if (arg === "--root")
52
73
  options.roots.push(resolve(next()));
53
74
  else if (arg === "--port") {
54
75
  const port = Number(next());
@@ -67,8 +88,12 @@ export function parseArgs(argv) {
67
88
  options.tunnelCommand = next();
68
89
  else if (arg === "--data-dir")
69
90
  options.dataDir = resolve(next());
91
+ else if (arg === "--phrase" && command === "approve")
92
+ options.approvalPhrase = next();
70
93
  else if (arg === "--local-only")
71
94
  options.localOnly = true;
95
+ else if (arg === "--test-no-pairing")
96
+ options.testNoPairing = true;
72
97
  else if (arg === "--no-qr")
73
98
  options.qr = false;
74
99
  else if (arg === "--help" || arg === "-h")
@@ -76,9 +101,13 @@ export function parseArgs(argv) {
76
101
  else
77
102
  throw cliError(`unknown argument: ${arg}`);
78
103
  }
104
+ if (command === "approve" && !options.approvalPhrase)
105
+ throw cliError("approve requires --phrase matching the phone");
79
106
  if (command === "update" && updateVersion && !/^\d+\.\d+\.\d+$/.test(updateVersion)) {
80
107
  throw cliError("update version must be an exact stable X.Y.Z version");
81
108
  }
109
+ if (options.testNoPairing && (command !== "start" || !options.localOnly || options.tunnelCommand || options.relay !== "auto"))
110
+ throw cliError("--test-no-pairing requires start --local-only and cannot use a tunnel or background service");
82
111
  if (!options.roots.length)
83
112
  options.roots = [process.cwd()];
84
113
  if (options.relay === "custom" && !options.tunnelCommand)
package/dist/args.test.js CHANGED
@@ -57,3 +57,19 @@ describe("parseArgs", () => {
57
57
  expect(() => parseArgs(["update", "1.2.3-preview.1"])).toThrow("exact stable");
58
58
  });
59
59
  });
60
+ describe("device authorization commands", () => {
61
+ it("parses pairing, listing and individual/all revocation", () => {
62
+ expect(parseArgs(["pair"]).command).toBe("pair");
63
+ expect(parseArgs(["devices"]).command).toBe("devices");
64
+ expect(parseArgs(["revoke", "--all"]).revokeAll).toBe(true);
65
+ expect(parseArgs(["revoke", "12345678-1234-1234-1234-123456789abc"]).revokeDeviceId).toBe("12345678-1234-1234-1234-123456789abc");
66
+ expect(() => parseArgs(["revoke"])).toThrow();
67
+ expect(parseArgs(["pair", "--data-dir", "/tmp/device-test"]).dataDir).toBe("/tmp/device-test");
68
+ });
69
+ });
70
+ it("restricts no-pairing test mode to explicit local foreground starts", () => {
71
+ expect(parseArgs(["start", "--local-only", "--test-no-pairing"]).testNoPairing).toBe(true);
72
+ expect(parseArgs([]).testNoPairing).toBeUndefined();
73
+ for (const args of [["--test-no-pairing"], ["service", "install", "--local-only", "--test-no-pairing"], ["start", "--local-only", "--test-no-pairing", "--relay", "pinggy"]])
74
+ expect(() => parseArgs(args)).toThrow();
75
+ });
package/dist/cli.js CHANGED
@@ -3,13 +3,12 @@ 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
- import { createPairingSecret } from "./pairing.js";
8
+ import { installedDataDir, pairingAdmin, printPairingCode, terminalText } from "./device-pairing.js";
9
9
  import { detectPlatform } from "./platform.js";
10
10
  import { AGENTROAM_VERSION, resolvePlatformRuntime, resolvePlatformTui } from "./platform-packages.js";
11
11
  import { acquireSleepInhibitor } from "./power/sleep-inhibitor.js";
12
- import { renderQr } from "./qr.js";
13
12
  import { RuntimeManager } from "./runtime-manager.js";
14
13
  import { ServiceRuntimeReporter } from "./service/runtime-state.js";
15
14
  import { runServiceCommand } from "./service/service-command.js";
@@ -19,7 +18,56 @@ import { currentCliPath, startUpdate } from "./update/update-command.js";
19
18
  import { runUpdateWorker } from "./update/update-worker.js";
20
19
  const VERSION = AGENTROAM_VERSION;
21
20
  export async function main(argv) {
21
+ if (argv.includes("--help") || argv.includes("-h")) {
22
+ console.log(`AgentRoam
23
+ agentroam [start] [--local-only] [--test-no-pairing] [--data-dir PATH]
24
+ agentroam pair 生成一次性设备配对码
25
+ agentroam pair --url <服务器地址> 生成 App 扫码直连授权(终端显示)
26
+ agentroam approvals 查看待批准请求和核对短语
27
+ agentroam approve <请求ID> --phrase <核对短语>
28
+ agentroam deny <请求ID> 拒绝请求
29
+ agentroam lock 紧急锁定全部远程访问
30
+ agentroam unlock 本机解锁(需要重新配对)
31
+ agentroam audit 查看安全审计记录
32
+ agentroam devices 查看已授权设备
33
+ agentroam revoke <设备ID> 移除一台设备
34
+ agentroam revoke --all 全部退出并取消待用配对码
35
+ agentroam service install|start|stop|restart|status|url|logs|uninstall
36
+ 设备命令支持 --data-dir PATH;配对码有效期 5 分钟,设备授权有效期 30 天。`);
37
+ return;
38
+ }
22
39
  const options = parseArgs(argv);
40
+ if (["pair", "devices", "revoke", "approvals", "approve", "deny", "lock", "unlock", "audit"].includes(options.command)) {
41
+ const dataDir = argv.includes("--data-dir") ? options.dataDir : await installedDataDir(options.dataDir);
42
+ if (options.command === "pair")
43
+ await printPairingCode(dataDir, console.log, { accessUrl: options.pairingUrl, qr: options.qr });
44
+ else if (options.command === "approvals") {
45
+ const { requests, locked } = await pairingAdmin(dataDir, "requests");
46
+ console.log(locked ? "远程访问已锁定" : requests.length ? requests.map((r) => `${r.id} ${terminalText(r.name)} 核对短语:${r.phrase} 到期 ${new Date(r.expires).toLocaleString()}`).join("\n") : "暂无待授权请求");
47
+ }
48
+ else if (options.command === "approve" || options.command === "deny") {
49
+ await pairingAdmin(dataDir, options.command, { id: options.approvalRequestId, phrase: options.approvalPhrase });
50
+ console.log(options.command === "approve" ? "已批准,手机将自动进入。" : "已拒绝此设备。");
51
+ }
52
+ else if (options.command === "lock" || options.command === "unlock") {
53
+ await pairingAdmin(dataDir, options.command);
54
+ console.log(options.command === "lock" ? "远程访问已锁定,设备授权和待用配对已取消。已提交的本机任务不会自动终止。" : "远程访问已解锁。旧设备不会恢复授权,请执行 agentroam pair 重新配对。");
55
+ }
56
+ else if (options.command === "audit") {
57
+ const { events } = await pairingAdmin(dataDir, "audit");
58
+ console.log(events.length ? events.map((e) => `${new Date(e.at).toISOString()} ${e.action} ${e.outcome} actor=${e.actor ?? "-"} target=${e.target ?? "-"} count=${e.count}`).join("\n") : "暂无审计记录");
59
+ }
60
+ else if (options.command === "devices") {
61
+ const { devices } = await pairingAdmin(dataDir, "devices");
62
+ console.log(devices.length ? devices.map((d) => `${d.id} ${d.name.replace(/[\x00-\x1f\x7f-\x9f]/g, "")} 最近使用 ${new Date(d.seen).toLocaleString()} 到期 ${new Date(d.expires).toLocaleString()}`).join("\n") : "暂无已授权设备");
63
+ console.log("移除设备:agentroam revoke <设备ID>;全部退出:agentroam revoke --all");
64
+ }
65
+ else {
66
+ const result = await pairingAdmin(dataDir, "revoke", options.revokeAll ? { all: true } : { id: options.revokeDeviceId });
67
+ console.log(`已撤销 ${result.revoked} 台设备的授权`);
68
+ }
69
+ return;
70
+ }
23
71
  if (options.command === "update-worker") {
24
72
  await runUpdateWorker(options.updateStateFile);
25
73
  return;
@@ -50,7 +98,6 @@ export async function main(argv) {
50
98
  }
51
99
  console.log(`AgentRoam ${VERSION}\n✓ Node ${process.versions.node} · ${target}`);
52
100
  const sleepInhibitor = await acquireSleepInhibitor();
53
- const pairing = createPairingSecret();
54
101
  const controller = new AbortController();
55
102
  let runtime = null;
56
103
  let relay = null;
@@ -71,9 +118,10 @@ export async function main(argv) {
71
118
  process.once("SIGTERM", requestClose);
72
119
  try {
73
120
  await serviceReporter?.starting();
74
- runtime = await new RuntimeManager().start(options, pairing, target);
121
+ runtime = await new RuntimeManager().start(options, target);
122
+ void runtime.exited.then(() => controller.abort());
75
123
  console.log(`✓ Local server: ${runtime.localUrl}/web`);
76
- const lanUrl = findLanUrl(runtime.port) ?? runtime.localUrl;
124
+ const lanUrl = options.localOnly ? findLanUrl(runtime.port) ?? runtime.localUrl : runtime.localUrl;
77
125
  relay = await selectRelay({
78
126
  cli: options,
79
127
  target,
@@ -84,7 +132,7 @@ export async function main(argv) {
84
132
  log: (line) => process.stderr.write(`${line}\n`),
85
133
  onAttempt: (provider) => console.log(`▲ Trying ${providerDisplayName(provider)} relay...`),
86
134
  onFailure: (provider, message) => console.error(`⚠ ${providerDisplayName(provider)} unavailable: ${message}`),
87
- allowLanFallback: serviceReporter === null,
135
+ allowLanFallback: options.localOnly,
88
136
  });
89
137
  if (controller.signal.aborted)
90
138
  return;
@@ -101,7 +149,7 @@ export async function main(argv) {
101
149
  console.log(" Pinggy free tunnels expire after 60 minutes and use a new hostname each session.");
102
150
  console.log(" The phone browser may show a one-time Pinggy security confirmation before pairing.");
103
151
  }
104
- const accessUrl = `${relay.publicUrl}/web${runtime.needsSetup ? `?pair=${encodeURIComponent(pairing.token)}` : ""}`;
152
+ const accessUrl = `${relay.publicUrl}/web`;
105
153
  await serviceReporter?.ready({
106
154
  localUrl: runtime.localUrl,
107
155
  publicUrl: relay.publicUrl,
@@ -113,10 +161,8 @@ export async function main(argv) {
113
161
  }
114
162
  else {
115
163
  console.log(`\nOpen: ${accessUrl}`);
116
- if (options.qr)
117
- console.log(`\n${await renderQr(accessUrl)}`);
118
- if (runtime.needsSetup)
119
- console.log("First pairing link expires in 5 minutes.");
164
+ if (!options.testNoPairing)
165
+ await printPairingCode(options.dataDir, console.log, { signal: controller.signal, accessUrl, qr: options.qr });
120
166
  console.log("Ctrl+C stops the tunnel and local server.");
121
167
  }
122
168
  await Promise.race([
@@ -138,7 +184,7 @@ export async function main(argv) {
138
184
  }
139
185
  }
140
186
  async function doctor(target, dataDir) {
141
- console.log(`✓ Node ${process.versions.node}`);
187
+ console.log(`✓ Node ${process.versions.node} · Node-API ${process.versions.napi}`);
142
188
  console.log(`✓ Platform ${target}`);
143
189
  const { runtimeRoot } = resolvePlatformRuntime(target);
144
190
  const runtimeRequire = createRequire(`${runtimeRoot}/package.json`);
@@ -149,9 +195,12 @@ async function doctor(target, dataDir) {
149
195
  catch (error) {
150
196
  reportDoctorFailure(error);
151
197
  }
152
- for (const module of ["node-pty", "better-sqlite3"]) {
198
+ for (const [module, probe] of [
199
+ ["node-pty", probeTerminalRuntime],
200
+ ["better-sqlite3", probeSQLiteRuntime],
201
+ ]) {
153
202
  try {
154
- runtimeRequire(module);
203
+ await probe(runtimeRequire);
155
204
  console.log(`✓ ${module}`);
156
205
  }
157
206
  catch (error) {
@@ -0,0 +1,83 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+ // @ts-expect-error Standalone bootstrap helper ships as JavaScript.
7
+ import { desktopAsset, downloadDesktop } from '../bin/desktop-download.mjs';
8
+ const version = '0.2.0-preview.17';
9
+ const body = Buffer.from('desktop test artifact');
10
+ function manifest() {
11
+ return { schemaVersion: 2, version, channel: 'preview', installers: { desktop: {
12
+ 'darwin-arm64': { fileName: `AgentRoam-${version}-arm64.dmg`, sha256: createHash('sha256').update(body).digest('hex'), size: body.length, signed: false },
13
+ 'windows-amd64': { fileName: `AgentRoam-Setup-${version}-x64.exe`, sha256: createHash('sha256').update(body).digest('hex'), signed: false },
14
+ } } };
15
+ }
16
+ describe('optional desktop installer download', () => {
17
+ it('selects the versioned Windows asset and rejects wrong versions and filenames', () => {
18
+ expect(desktopAsset(manifest(), version, 'win32', 'x64').fileName).toMatch(/\.exe$/);
19
+ expect(() => desktopAsset(manifest(), '0.2.0-preview.18', 'darwin', 'arm64')).toThrow();
20
+ const invalid = manifest();
21
+ invalid.installers.desktop['darwin-arm64'].fileName = '../unsafe.dmg';
22
+ expect(() => desktopAsset(invalid, version, 'darwin', 'arm64')).toThrow();
23
+ });
24
+ it('downloads and verifies into unique directories without overwriting previous installs', async () => {
25
+ const directory = await mkdtemp(join(tmpdir(), 'desktop-download-'));
26
+ const fetchImpl = vi.fn(async (url) => url.endsWith('.json') ? Response.json(manifest()) : new Response(body));
27
+ try {
28
+ const first = await downloadDesktop(version, { directory, platform: 'darwin', arch: 'arm64', fetchImpl });
29
+ const second = await downloadDesktop(version, { directory, platform: 'darwin', arch: 'arm64', fetchImpl });
30
+ expect(await readFile(first)).toEqual(body);
31
+ expect(second).not.toBe(first);
32
+ expect(fetchImpl.mock.calls[1][0]).toContain(`/v${version}/AgentRoam-${version}-arm64.dmg`);
33
+ }
34
+ finally {
35
+ await rm(directory, { recursive: true, force: true });
36
+ }
37
+ });
38
+ it('cleans up partial files on checksum mismatch', async () => {
39
+ const directory = await mkdtemp(join(tmpdir(), 'desktop-download-'));
40
+ const fetchImpl = vi.fn(async (url) => url.endsWith('.json') ? Response.json(manifest()) : new Response('corrupted'));
41
+ try {
42
+ await expect(downloadDesktop(version, { directory, platform: 'darwin', arch: 'arm64', fetchImpl })).rejects.toThrow('checksum');
43
+ expect(await readdir(directory)).toEqual([]);
44
+ }
45
+ finally {
46
+ await rm(directory, { recursive: true, force: true });
47
+ }
48
+ });
49
+ it('rejects missing platform artifacts before downloading binaries', async () => {
50
+ const fetchImpl = vi.fn(async () => Response.json({ ...manifest(), installers: {} }));
51
+ await expect(downloadDesktop(version, { platform: 'darwin', arch: 'arm64', fetchImpl })).rejects.toThrow('no valid');
52
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
53
+ });
54
+ });
55
+ // Exercise the actual installer tail with an isolated launcher. Nothing installs
56
+ // on the machine; the helper records whether the optional branch ran.
57
+ describe.skipIf(process.platform === 'win32')('Shell desktop choice after CLI installation', () => {
58
+ it.each(['no', 'yes', undefined])('handles choice %s without reading piped script input', async (choice) => {
59
+ const { execFileSync } = await import('node:child_process');
60
+ const { mkdir, writeFile } = await import('node:fs/promises');
61
+ const root = await mkdtemp(join(tmpdir(), 'desktop-choice-'));
62
+ try {
63
+ const bin = join(root, 'node_modules/agentroam/bin');
64
+ await mkdir(bin, { recursive: true });
65
+ const marker = join(root, 'selected');
66
+ await writeFile(join(bin, 'desktop-download.mjs'), `import { writeFileSync } from 'node:fs'; writeFileSync(process.env.CHOICE_MARKER, process.argv[2]);`);
67
+ const installer = await readFile(new URL('../install/install-agentroam.sh', import.meta.url), 'utf8');
68
+ const section = installer.slice(installer.indexOf('# stdin is the script itself'));
69
+ const env = { ...process.env, LAUNCHER_ROOT: root, NODE_BIN: process.execPath, AGENTROAM_VERSION: version, CHOICE_MARKER: marker };
70
+ delete env.AGENTROAM_INSTALL_DESKTOP;
71
+ if (choice)
72
+ Object.assign(env, { AGENTROAM_INSTALL_DESKTOP: choice });
73
+ execFileSync('/bin/sh', ['-c', section], { env, input: 'yes\n' });
74
+ if (choice === 'yes')
75
+ expect(await readFile(marker, 'utf8')).toBe(version);
76
+ else
77
+ expect(await readdir(root)).not.toContain('selected');
78
+ }
79
+ finally {
80
+ await rm(root, { recursive: true, force: true });
81
+ }
82
+ });
83
+ });