agentroam 0.2.0-preview.17 → 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
@@ -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.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
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.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
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
 
@@ -59,8 +59,7 @@ 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
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
64
  The installer reuses the active Node when it meets the minimum version. Otherwise
66
65
  it checks nvm-sh on macOS and nvm-windows on Windows and uses the highest
@@ -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
+ }
@@ -7,7 +7,7 @@ import { promisify } from "node:util";
7
7
  import { MINIMUM_NODE_VERSION, compareNodeVersions, isSupportedNodeVersion, parseNodeVersion } from "./runtime-policy.mjs";
8
8
 
9
9
  const execFileAsync = promisify(execFile);
10
- const RELEASE_VERSION = "0.2.0-preview.17";
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
 
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
@@ -5,11 +5,10 @@ import { ensureCloudflared } from "./cloudflared/installer.js";
5
5
  import { resolveCodexRuntime } from "./codex-runtime-manager.js";
6
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([
@@ -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
+ });
@@ -0,0 +1,146 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+ import { readdir, readFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+ import { renderQr } from "./qr.js";
7
+ /** Resolve a private local descriptor; never send the administrative token to a tunnel URL. */
8
+ export async function pairingAdmin(dataDir, action, body, options = {}) {
9
+ const request = options.request ?? fetch;
10
+ const directory = options.registry ?? process.env.AGENTROAM_DISCOVERY_DIR ?? join(homedir(), ".agentroam", "services");
11
+ const names = await readdir(directory).catch((error) => { if (error.code === "ENOENT")
12
+ return []; throw error; });
13
+ const candidates = [];
14
+ for (const name of names.filter((name) => name.endsWith(".json"))) {
15
+ try {
16
+ const d = JSON.parse(await readFile(join(directory, name), "utf8"));
17
+ const url = new URL(d.url);
18
+ if (d.protocol !== 1 || typeof d.instanceId !== "string" || !Number.isSafeInteger(d.pid) || d.pid <= 0 || ![resolve(dataDir), resolve(dataDir, "data")].includes(d.dataDir) || !/^[a-f0-9]{64}$/.test(d.token))
19
+ continue;
20
+ if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || url.pathname !== "/" || url.username || url.password || url.search || url.hash)
21
+ continue;
22
+ const response = await request(new URL("/api/desktop/identity", url), { headers: { "x-agentroam-desktop-token": d.token }, redirect: "error", signal: AbortSignal.timeout(1500) });
23
+ if (!response.ok)
24
+ continue;
25
+ const identity = await response.json();
26
+ if (identity.protocol === 1 && identity.instanceId === d.instanceId && identity.dataDir === d.dataDir)
27
+ candidates.push(d);
28
+ }
29
+ catch { /* Stale descriptors are expected after a process crash. */ }
30
+ }
31
+ if (candidates.length !== 1)
32
+ throw new Error(candidates.length ? "多个服务使用此数据目录,请先停止多余实例" : "未找到运行中的 AgentRoam 服务,请先启动服务或指定 --data-dir");
33
+ const d = candidates[0];
34
+ const readOnly = ["devices", "requests", "audit"].includes(action);
35
+ const response = await request(new URL(`/api/pairing/admin/${action}`, d.url), {
36
+ method: readOnly ? "GET" : "POST",
37
+ headers: { "x-agentroam-desktop-token": d.token, "content-type": "application/json" },
38
+ ...(readOnly ? {} : { body: JSON.stringify(body ?? {}) }),
39
+ redirect: "error", signal: AbortSignal.timeout(5000),
40
+ });
41
+ if (!response.ok) {
42
+ const message = response.status === 423 ? "远程访问已锁定,请先执行 agentroam unlock" : response.status === 410 ? "请求已过期或已处理" : response.status === 409 ? "核对短语不匹配" : `设备授权操作失败 (HTTP ${response.status})`;
43
+ throw new Error(message);
44
+ }
45
+ return response.json();
46
+ }
47
+ export function terminalText(value) { return value.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ""); }
48
+ export function pairingQrPayload(accessUrl, grant) {
49
+ const url = new URL(accessUrl);
50
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash || !/^[A-Za-z0-9_-]{43}$/.test(grant))
51
+ throw new Error("无效的配对地址或授权凭据");
52
+ return `${url.origin}/pair#pair=${grant}`;
53
+ }
54
+ export async function printPairingCode(dataDir, log = console.log, options = {}) {
55
+ const result = await pairingAdmin(dataDir, "code");
56
+ log(`配对码:${result.code.slice(0, 4)} ${result.code.slice(4)}`);
57
+ log("5 分钟内有效,仅可使用一次。手机输入后仍需在电脑上确认核对短语。");
58
+ if ((options.qr ?? true) && (options.interactive ?? process.stdout.isTTY)) {
59
+ const accessUrl = options.accessUrl ?? await readFile(join(dataDir, "tunnel.url"), "utf8").then((value) => value.trim(), () => null);
60
+ if (accessUrl) {
61
+ // Validate the public address before minting an invitation. Never persist the grant.
62
+ pairingQrPayload(accessUrl, "a".repeat(43));
63
+ const { grant } = await pairingAdmin(dataDir, "qr");
64
+ log("手机扫码直接连接(5 分钟、一次有效,请勿分享截图):");
65
+ log(await renderQr(pairingQrPayload(accessUrl, grant)));
66
+ }
67
+ else
68
+ log("生成 App 授权二维码:agentroam pair --url <手机可访问的服务器地址>");
69
+ }
70
+ if (options.interactive ?? (process.stdin.isTTY && process.stdout.isTTY)) {
71
+ await watchPairingApproval(dataDir, result.expiresAt, { log, signal: options.signal });
72
+ }
73
+ else
74
+ log("查看请求:agentroam approvals;批准:agentroam approve <请求ID> --phrase <手机核对短语>");
75
+ }
76
+ /** Watch only while the printed code is usable; approval gets the request's own deadline. */
77
+ export async function watchPairingApproval(dataDir, codeExpiresAt, options = {}) {
78
+ const admin = options.admin ?? pairingAdmin;
79
+ const log = options.log ?? console.log;
80
+ log("等待手机发起连接…");
81
+ while (Date.now() < codeExpiresAt && !options.signal?.aborted) {
82
+ const result = await admin(dataDir, "requests");
83
+ if (result.locked) {
84
+ log("远程访问已锁定");
85
+ return;
86
+ }
87
+ if (result.qrClaimed) {
88
+ log("App 已扫码连接。");
89
+ return;
90
+ }
91
+ const request = result.requests.find((r) => r.created >= codeExpiresAt - 300_000);
92
+ if (request) {
93
+ log(`新设备请求连接:${terminalText(request.name)}`);
94
+ log(`请求 ID:${request.id}`);
95
+ log(`核对短语:${request.phrase}`);
96
+ const approved = await (options.confirm ?? confirmOnTerminal)(request, options.signal);
97
+ if (options.signal?.aborted)
98
+ return;
99
+ if (Date.now() >= request.expires) {
100
+ log("授权请求已过期,请重新配对。");
101
+ return;
102
+ }
103
+ await admin(dataDir, approved ? "approve" : "deny", { id: request.id, ...(approved ? { phrase: request.phrase } : {}) });
104
+ log(approved ? "已批准,手机将自动进入。" : "已拒绝此设备。");
105
+ return;
106
+ }
107
+ try {
108
+ await delay(800, undefined, { signal: options.signal });
109
+ }
110
+ catch {
111
+ return;
112
+ }
113
+ }
114
+ if (!options.signal?.aborted)
115
+ log("配对码已过期,执行 agentroam pair 生成新码。");
116
+ }
117
+ async function confirmOnTerminal(request, signal) {
118
+ const controller = new AbortController();
119
+ const timer = setTimeout(() => controller.abort(), Math.max(0, request.expires - Date.now()));
120
+ const input = createInterface({ input: process.stdin, output: process.stdout });
121
+ input.once("SIGINT", () => controller.abort());
122
+ try {
123
+ const answer = await input.question("确认手机显示相同短语?输入 yes 批准,其他输入拒绝:", { signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal });
124
+ return answer.trim().toLowerCase() === "yes";
125
+ }
126
+ catch {
127
+ return false;
128
+ }
129
+ finally {
130
+ clearTimeout(timer);
131
+ input.close();
132
+ }
133
+ }
134
+ export async function installedDataDir(fallback) {
135
+ if (resolve(fallback) !== resolve(homedir(), ".agentroam"))
136
+ return fallback;
137
+ try {
138
+ const config = JSON.parse(await readFile(join(homedir(), ".agentroam", "service", "config.json"), "utf8"));
139
+ return typeof config.dataDir === "string" ? resolve(config.dataDir) : fallback;
140
+ }
141
+ catch (error) {
142
+ if (error.code === "ENOENT")
143
+ return fallback;
144
+ throw error;
145
+ }
146
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { parseArgs } from "./args.js";
3
+ import { pairingQrPayload, watchPairingApproval } from "./device-pairing.js";
4
+ describe("local approval commands", () => {
5
+ it("encodes a separate QR invitation and validates the server URL", () => {
6
+ expect(parseArgs(["pair", "--url", "https://phone.example/web"]).pairingUrl).toBe("https://phone.example/web");
7
+ const link = new URL(pairingQrPayload("https://phone.example/web", "a".repeat(43)));
8
+ expect(link.origin + link.pathname).toBe("https://phone.example/pair");
9
+ expect(link.search).toBe("");
10
+ expect(link.hash).toBe(`#pair=${"a".repeat(43)}`);
11
+ expect(() => pairingQrPayload("https://user:password@phone.example", "a".repeat(43))).toThrow();
12
+ });
13
+ const id = "12345678-1234-1234-1234-123456789abc";
14
+ it("requires a request ID and matching phrase for explicit approval", () => {
15
+ expect(() => parseArgs(["approve", id])).toThrow(/phrase/);
16
+ expect(parseArgs(["approve", id, "--phrase", "松林·月光·1234"])).toMatchObject({ approvalRequestId: id, approvalPhrase: "松林·月光·1234" });
17
+ for (const command of ["lock", "unlock", "audit", "approvals"])
18
+ expect(parseArgs([command]).command).toBe(command);
19
+ expect(parseArgs(["deny", id]).approvalRequestId).toBe(id);
20
+ });
21
+ it.each([true, false])("displays the phrase and sends only the local user's %s decision", async (approve) => {
22
+ const now = Date.now();
23
+ const request = { id, name: "Phone\u001b[31m", phrase: "松林·月光·1234", created: now, expires: now + 300_000, status: "waiting" };
24
+ const admin = vi.fn().mockResolvedValueOnce({ requests: [request], locked: false }).mockResolvedValueOnce({ status: approve ? "approved" : "denied" });
25
+ const log = vi.fn();
26
+ const confirm = vi.fn(async () => approve);
27
+ await watchPairingApproval("/tmp/unused", now + 300_000, { admin: admin, log, confirm });
28
+ expect(log).toHaveBeenCalledWith(`核对短语:${request.phrase}`);
29
+ expect(confirm).toHaveBeenCalledWith(request, undefined);
30
+ expect(admin).toHaveBeenLastCalledWith("/tmp/unused", approve ? "approve" : "deny", { id, ...(approve ? { phrase: request.phrase } : {}) });
31
+ expect(log.mock.calls.flat().join("\n")).not.toContain("\u001b");
32
+ });
33
+ });
@@ -3,11 +3,10 @@ import { resolve } from "node:path";
3
3
  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
- import { AGENTROAM_VERSION } from "./platform-packages.js";
7
6
  import { MINIMUM_NODE_VERSION, isSupportedNodeVersion } from "../bin/runtime-policy.mjs";
8
7
  const installRoot = resolve(import.meta.dirname, "../install");
9
8
  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 () => {
9
+ it.skipIf(process.platform !== "darwin" || process.arch !== "arm64")("allows installation from home and keeps a compatible active Node", async () => {
11
10
  const root = await mkdtemp(resolve(process.env.TMPDIR || "/tmp", "agentroam-active-node-"));
12
11
  try {
13
12
  const homeDir = resolve(root, "home");
@@ -18,7 +17,7 @@ describe("standalone installer contracts", () => {
18
17
  const expected = await fakeNode(resolve(activeBin, "node"), "24.13.0");
19
18
  await fakeNode(resolve(nvmRoot, "versions/node/v26.0.0/bin/node"), "26.0.0");
20
19
  const output = execFileSync("/bin/sh", [resolve(installRoot, "install-agentroam.sh")], {
21
- cwd: workspace, encoding: "utf8",
20
+ cwd: homeDir, encoding: "utf8",
22
21
  env: { ...process.env, HOME: homeDir, NVM_DIR: nvmRoot, PATH: `${activeBin}:/usr/bin:/bin`,
23
22
  AGENTROAM_BOOTSTRAP_TEST: "1", AGENTROAM_BOOTSTRAP_NODE_DISCOVERY_ONLY: "1" },
24
23
  });
@@ -33,7 +32,7 @@ describe("standalone installer contracts", () => {
33
32
  const script = await readFile(scriptPath, "utf8");
34
33
  expect(script).toContain(`NODE_VERSION="${MANAGED_NODE_VERSION}"`);
35
34
  expect(script).toContain(`MINIMUM_NODE_VERSION="${MINIMUM_NODE_VERSION}"`);
36
- expect(script).toContain(`AGENTROAM_VERSION="${AGENTROAM_VERSION}"`);
35
+ expect(script).toContain('AGENTROAM_VERSION_REQUEST="${AGENTROAM_VERSION:-preview}"');
37
36
  expect(script).toContain(NODE_RUNTIME_ASSETS["darwin-arm64"].archive);
38
37
  expect(script).toContain(NODE_RUNTIME_ASSETS["darwin-arm64"].sha256);
39
38
  expect(script).toContain("https://registry.npmjs.org");
@@ -42,7 +41,7 @@ describe("standalone installer contracts", () => {
42
41
  expect(script).toContain('AGENTROAM_ROOT');
43
42
  expect(script).toContain('AGENTROAM_INSTALL_SKIP_SERVICE');
44
43
  expect(script).toContain('service install --root "$SERVICE_ROOT" --data-dir "$DATA_DIR"');
45
- expect(script).toContain("refusing to expose the entire home directory implicitly");
44
+ expect(script).toContain('SERVICE_ROOT="$PWD"');
46
45
  expect(script).toContain("find_nvm_node");
47
46
  expect(script).toContain('${NVM_DIR:-}');
48
47
  expect(script).not.toMatch(/\b(?:brew|sudo|fnm|volta)\b/);
@@ -52,7 +51,7 @@ describe("standalone installer contracts", () => {
52
51
  const script = await readFile(resolve(installRoot, "install-agentroam.ps1"), "utf8");
53
52
  expect(script).toContain(`$NodeVersion = "${MANAGED_NODE_VERSION}"`);
54
53
  expect(script).toContain(`$MinimumNodeVersion = "${MINIMUM_NODE_VERSION}"`);
55
- expect(script).toContain(`$AgentRoamVersion = "${AGENTROAM_VERSION}"`);
54
+ expect(script).toContain('$AgentRoamVersionRequest = if ($env:AGENTROAM_VERSION) { $env:AGENTROAM_VERSION } else { "preview" }');
56
55
  expect(script).toContain(NODE_RUNTIME_ASSETS["windows-amd64"].archive);
57
56
  expect(script).toContain(NODE_RUNTIME_ASSETS["windows-amd64"].sha256);
58
57
  expect(script).toContain("https://registry.npmjs.org");
@@ -61,7 +60,7 @@ describe("standalone installer contracts", () => {
61
60
  expect(script).toContain("$env:AGENTROAM_ROOT");
62
61
  expect(script).toContain("$env:AGENTROAM_INSTALL_SKIP_SERVICE");
63
62
  expect(script).toContain("service install --root $ServiceRoot --data-dir $DataDir");
64
- expect(script).toContain("Refusing to expose the entire home directory implicitly");
63
+ expect(script).toContain("(Get-Location).Path");
65
64
  expect(script).toContain("Find-NvmNode");
66
65
  expect(script).toContain("$env:NVM_HOME");
67
66
  expect(script).not.toMatch(/\b(?:winget|choco|scoop|Start-Process\s+.*RunAs)\b/i);
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { dirname } from "node:path";
4
4
  import { MINIMUM_NODE_VERSION, assertSupportedNodeVersion } from "../bin/runtime-policy.mjs";
5
- export const AGENTROAM_VERSION = "0.2.0-preview.17";
5
+ export const AGENTROAM_VERSION = "0.2.0-preview.18";
6
6
  export const PLATFORM_PACKAGES = {
7
7
  "darwin-arm64": {
8
8
  runtime: "agentroam-runtime-darwin-arm64",
@@ -18,7 +18,7 @@ export class RuntimeManager {
18
18
  this.report = report;
19
19
  this.opencodeResolver = opencodeResolver;
20
20
  }
21
- async start(options, pairing, target) {
21
+ async start(options, target) {
22
22
  const port = await findAvailablePort(options.port);
23
23
  const dataDir = resolve(options.dataDir);
24
24
  await Promise.all(["data", "bin", "cache", "logs"].map((name) => mkdir(resolve(dataDir, name), { recursive: true })));
@@ -27,19 +27,17 @@ export class RuntimeManager {
27
27
  const { runtimeRoot } = resolvePlatformRuntime(target);
28
28
  await repairNativeRuntimePermissions(runtimeRoot);
29
29
  const gateway = resolve(runtimeRoot, "ws-server.mjs");
30
- const child = this.supervisor.spawn(process.execPath, [gateway], {
30
+ const child = this.supervisor.spawn(process.execPath, [gateway, ...(options.testNoPairing ? ["--test-no-pairing"] : [])], {
31
31
  cwd: runtimeRoot,
32
32
  env: {
33
33
  ...childEnvironment,
34
34
  NODE_ENV: "production",
35
35
  NEXT_DIST_DIR: ".next",
36
36
  PORT: String(port),
37
- HOST: "127.0.0.1",
37
+ HOST: options.localOnly ? "0.0.0.0" : "127.0.0.1",
38
38
  AGENT_DATA_DIR: resolve(dataDir, "data"),
39
39
  AGENT_WEB_ROOTS: options.roots.join(delimiter),
40
40
  AGENT_TRUST_TUNNEL_PROXY: "1",
41
- AGENT_PAIRING_HASH: pairing?.hashHex || "",
42
- AGENT_PAIRING_EXPIRES_AT: pairing?.expiresAt || "",
43
41
  AGENTROAM_VERSION: resolvePlatformRuntime(target).manifest.packageVersion,
44
42
  AGENTROAM_DATA_DIR: dataDir,
45
43
  AGENTROAM_CLI_PATH: fileURLToPath(new URL("../bin/agentroam.mjs", import.meta.url)),
@@ -1,6 +1,7 @@
1
1
  import { delimiter, dirname, isAbsolute, resolve, win32 } from "node:path";
2
2
  import { resolveCodexRuntime } from "../codex-runtime-manager.js";
3
3
  import { detectPlatform } from "../platform.js";
4
+ import { printPairingCode } from "../device-pairing.js";
4
5
  import { renderQr } from "../qr.js";
5
6
  import { MacLaunchAgent } from "./macos-launch-agent.js";
6
7
  import { WindowsTaskService } from "./windows-task-service.js";
@@ -56,10 +57,14 @@ export async function runServiceCommand(options, context) {
56
57
  };
57
58
  const { definition, state } = await controller.install(config);
58
59
  log(`✓ AgentRoam service installed: ${definition}`);
59
- if (state?.status === "ready" && state.accessUrl)
60
- await logAccessUrl(state.accessUrl);
60
+ if (state?.status === "ready" && state.accessUrl) {
61
+ log(`Open: ${state.accessUrl}`);
62
+ if (state.localUrl)
63
+ log(`电脑本机:${state.localUrl.replace(/\/$/, "")}/web(配对后可进行远程授权)`);
64
+ await (context.pairingPrinter ?? printPairingCode)(options.dataDir, log, { accessUrl: state.accessUrl, qr: options.qr, interactive: stdoutIsTTY });
65
+ }
61
66
  else
62
- log("Service is starting. Run `agentroam service url` shortly.");
67
+ log("Service is starting. Run `agentroam service url` and `agentroam pair` shortly.");
63
68
  return;
64
69
  }
65
70
  case "start": {
@@ -40,6 +40,7 @@ describe("runServiceCommand", () => {
40
40
  now: () => new Date("2026-09-03T00:00:00.000Z"),
41
41
  log,
42
42
  controller: controller({ install }),
43
+ pairingPrinter: vi.fn(async () => { }),
43
44
  });
44
45
  expect(install).toHaveBeenCalledWith(expect.objectContaining({
45
46
  nodePath: "/node",
@@ -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.17" },
25
+ headers: { "user-agent": "agentroam-readiness/0.2.0-preview.18" },
26
26
  signal: options.signal,
27
27
  });
28
28
  if (response.ok) {
package/install/README.md CHANGED
@@ -5,7 +5,7 @@ These versioned installers support macOS Apple Silicon and Windows x64. They reu
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.17/install-agentroam.sh | sh
8
+ curl -fsSL https://gitee.com/caoqu/team-agent/raw/master/packages/cli/install/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,14 +13,14 @@ 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.17/install-agentroam.ps1 | iex
16
+ irm https://gitee.com/caoqu/team-agent/raw/master/packages/cli/install/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`.
20
20
 
21
21
  ## Service behavior
22
22
 
23
- Run the installer from the project directory AgentRoam should expose, or set `AGENTROAM_ROOT` explicitly. An installer launched from the home directory without an explicit root stops before service registration so it cannot expose the entire home directory accidentally.
23
+ Run the installer from the project directory AgentRoam should expose, or set `AGENTROAM_ROOT` explicitly. The current directory is accepted even when it is the home directory.
24
24
 
25
25
  The installer registers and starts the service after Node, CLI, and `doctor` validation. When a previous AgentRoam user service is installed, the new CLI stops it, waits for its process and platform registration to exit, removes only the old service registration, and then registers the replacement. Session data, pairing state, managed runtimes, and logs remain in the data directory. While foreground AgentRoam or the service is running, it prevents idle system sleep but still allows the display to dim and turn off. Lid close, explicit sleep, hibernation, shutdown, and low-battery forced sleep remain controlled by the operating system.
26
26
 
@@ -40,8 +40,16 @@ 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.17`.
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.18`.
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
 
47
47
  To uninstall the wrapper, remove `~/.local/bin/agentroam` on macOS or `%USERPROFILE%\.agentroam\bin\agentroam.cmd` on Windows. The private runtime, launcher, session data, and configuration under the data directory are preserved until explicitly removed by the user.
48
+
49
+ ## Optional desktop app
50
+
51
+ After CLI setup succeeds, interactive installers ask whether to download the desktop app. Answer `y` to download the matching macOS DMG or Windows installer into a unique folder under Downloads, verify its release-manifest SHA-256, and show the file. Open it to complete installation. Answer `n` or press Enter to keep using CLI only. Noninteractive runs skip the prompt.
52
+
53
+ Set `AGENTROAM_INSTALL_DESKTOP=yes` or `no` explicitly for automation. The Shell installer reads `/dev/tty`, so `curl ... | sh` remains interactive. A missing desktop artifact or failed download does not undo CLI installation. This requires a published CLI that includes `bin/desktop-download.mjs` and matching desktop release assets.
54
+
55
+ On macOS, the desktop app opens its own permission guide on first launch, even before connecting to a service. Screen Recording enables phone viewing; Accessibility enables keyboard and mouse control. Returning from System Settings rechecks permissions and resumes an enabled session. Restart the desktop app if macOS requires it. Desktop live publishing follows the selected CLI service, including its actual port. Windows desktop download is supported, but the current screen/input helper is macOS-only.
@@ -3,7 +3,9 @@ $ProgressPreference = "SilentlyContinue"
3
3
 
4
4
  $NodeVersion = "22.22.0"
5
5
  $MinimumNodeVersion = "22.22.0"
6
- $AgentRoamVersion = "0.2.0-preview.17"
6
+ # 默认跟随 npm preview 最新标签;可用 $env:AGENTROAM_VERSION 钉精确版本
7
+ $AgentRoamVersionRequest = if ($env:AGENTROAM_VERSION) { $env:AGENTROAM_VERSION } else { "preview" }
8
+ $AgentRoamVersion = "pending-version"
7
9
  $NodeArchive = "node-v22.22.0-win-x64.zip"
8
10
  $NodeSha256 = "c97fa376d2becdc8863fcd3ca2dd9a83a9f3468ee7ccf7a6d076ec66a645c77a"
9
11
  $NodeUrl = "https://nodejs.org/dist/v22.22.0/$NodeArchive"
@@ -21,11 +23,6 @@ $ServiceRootExplicit = -not [string]::IsNullOrWhiteSpace($env:AGENTROAM_ROOT)
21
23
  $ServiceRoot = if ($ServiceRootExplicit) { $env:AGENTROAM_ROOT } else { (Get-Location).Path }
22
24
  if (-not (Test-Path -LiteralPath $ServiceRoot -PathType Container)) { throw "AgentRoam root is not a directory: $ServiceRoot" }
23
25
  $ServiceRoot = [System.IO.Path]::GetFullPath($ServiceRoot).TrimEnd([System.IO.Path]::DirectorySeparatorChar)
24
- $HomeRoot = [System.IO.Path]::GetFullPath($HOME).TrimEnd([System.IO.Path]::DirectorySeparatorChar)
25
- if (-not $ServiceRootExplicit -and $ServiceRoot -eq $HomeRoot) {
26
- throw "Refusing to expose the entire home directory implicitly; run from a project directory or set AGENTROAM_ROOT explicitly"
27
- }
28
-
29
26
  New-Item -ItemType Directory -Force -Path $NodeParent, $LauncherParent, $WrapperDir | Out-Null
30
27
 
31
28
  function Test-NodeVersion([string]$VersionText) {
@@ -164,6 +161,17 @@ $NpmCandidates = @(
164
161
  $NpmCli = $NpmCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
165
162
  if (-not $NpmCli) { throw "npm CLI was not found beside $NodeBin" }
166
163
 
164
+ # 解析 dist-tag → 精确版本(安装最总是最新发布)
165
+ if ($AgentRoamVersionRequest -notmatch '\d$') {
166
+ $Resolved = (& $NodeBin $NpmCli view "agentroam@$AgentRoamVersionRequest" version --registry $NpmRegistry 2>$null | Select-Object -Last 1)
167
+ if (-not $Resolved) { throw "could not resolve agentroam@$AgentRoamVersionRequest from $NpmRegistry" }
168
+ $AgentRoamVersion = ([string]$Resolved).Trim()
169
+ Write-Host "Installing AgentRoam $AgentRoamVersion (resolved from $AgentRoamVersionRequest)"
170
+ } else {
171
+ $AgentRoamVersion = $AgentRoamVersionRequest
172
+ }
173
+ $LauncherRoot = Join-Path $LauncherParent $AgentRoamVersion
174
+ $LauncherLock = Join-Path $LauncherRoot ".lock"
167
175
  $PackageSpec = "agentroam@$AgentRoamVersion"
168
176
  $ExtraPackageSpecs = @()
169
177
  if ($env:AGENTROAM_BOOTSTRAP_TEST -eq "1" -and $env:AGENTROAM_PACKAGE_SPEC) {
@@ -227,3 +235,21 @@ if ($env:AGENTROAM_INSTALL_SKIP_SERVICE -eq "1") {
227
235
  }
228
236
  Write-Host "`nAgentRoam $AgentRoamVersion installed: $WrapperPath"
229
237
  Write-Host "Open a new terminal, then run: agentroam service status"
238
+
239
+ $DesktopChoice = if ($env:AGENTROAM_INSTALL_DESKTOP) { $env:AGENTROAM_INSTALL_DESKTOP } else { "ask" }
240
+ if ($env:AGENTROAM_INSTALL_SKIP_SERVICE -eq "1" -and $DesktopChoice -eq "ask") { $DesktopChoice = "no" }
241
+ if ($DesktopChoice -eq "ask") {
242
+ $DesktopChoice = "no"
243
+ if ([Environment]::UserInteractive -and -not [Console]::IsInputRedirected) {
244
+ try { $DesktopChoice = Read-Host "是否下载桌面端?用于手机查看和控制电脑桌面 [y/N]" } catch { $DesktopChoice = "no" }
245
+ }
246
+ }
247
+ if ($DesktopChoice -match '^(y|yes|是)$') {
248
+ $DesktopHelper = Join-Path $LauncherRoot "node_modules\agentroam\bin\desktop-download.mjs"
249
+ if (Test-Path -LiteralPath $DesktopHelper -PathType Leaf) {
250
+ & $NodeBin $DesktopHelper $AgentRoamVersion
251
+ if ($LASTEXITCODE -ne 0) { Write-Warning "CLI 安装已完成,桌面端可稍后重新下载。" }
252
+ } else {
253
+ Write-Warning "当前发布的 CLI 尚未包含桌面下载功能,请在新版发布后重试。CLI 安装已完成。"
254
+ }
255
+ } else { Write-Host "已跳过桌面端下载,CLI 可正常使用。" }
@@ -3,7 +3,10 @@ set -eu
3
3
 
4
4
  NODE_VERSION="22.22.0"
5
5
  MINIMUM_NODE_VERSION="22.22.0"
6
- AGENTROAM_VERSION="0.2.0-preview.17"
6
+ # Default to the newest published preview; pin an exact version via
7
+ # AGENTROAM_VERSION=0.2.0-preview.18 sh install-agentroam.sh
8
+ AGENTROAM_VERSION_REQUEST="${AGENTROAM_VERSION:-preview}"
9
+ AGENTROAM_VERSION=""
7
10
  NODE_ARCHIVE="node-v22.22.0-darwin-arm64.tar.xz"
8
11
  NODE_SHA256="2bd596bbfc4a275ceb8721a5954ee97daea5ebe673e96a185ebd732f6fb023ac"
9
12
  NODE_URL="https://nodejs.org/dist/v22.22.0/$NODE_ARCHIVE"
@@ -13,14 +16,12 @@ NODE_PARENT="$DATA_DIR/runtimes/node"
13
16
  NODE_ROOT="$NODE_PARENT/$NODE_VERSION"
14
17
  NODE_LOCK="$NODE_ROOT.lock"
15
18
  LAUNCHER_PARENT="$DATA_DIR/launcher"
16
- LAUNCHER_ROOT="$LAUNCHER_PARENT/$AGENTROAM_VERSION"
19
+ LAUNCHER_ROOT="$LAUNCHER_PARENT/pending-version"
17
20
  LAUNCHER_LOCK="$LAUNCHER_ROOT.lock"
18
21
  WRAPPER_DIR="$HOME/.local/bin"
19
22
  WRAPPER_PATH="$WRAPPER_DIR/agentroam"
20
- SERVICE_ROOT_EXPLICIT=0
21
23
  if [ "${AGENTROAM_ROOT+x}" = "x" ]; then
22
24
  SERVICE_ROOT="$AGENTROAM_ROOT"
23
- SERVICE_ROOT_EXPLICIT=1
24
25
  else
25
26
  SERVICE_ROOT="$PWD"
26
27
  fi
@@ -49,10 +50,6 @@ command -v shasum >/dev/null 2>&1 || fail "shasum is required"
49
50
  [ -x /usr/bin/tar ] || fail "/usr/bin/tar is required"
50
51
  [ -d "$SERVICE_ROOT" ] || fail "AgentRoam root is not a directory: $SERVICE_ROOT"
51
52
  service_root_physical=$(cd "$SERVICE_ROOT" && pwd -P)
52
- home_physical=$(cd "$HOME" && pwd -P)
53
- if [ "$SERVICE_ROOT_EXPLICIT" -eq 0 ] && [ "$service_root_physical" = "$home_physical" ]; then
54
- fail "refusing to expose the entire home directory implicitly; run from a project directory or set AGENTROAM_ROOT explicitly"
55
- fi
56
53
  SERVICE_ROOT="$service_root_physical"
57
54
 
58
55
  mkdir -p "$NODE_PARENT" "$LAUNCHER_PARENT" "$WRAPPER_DIR"
@@ -200,7 +197,23 @@ find_npm_cli() {
200
197
  }
201
198
 
202
199
  NPM_CLI=$(find_npm_cli)
200
+ case "$AGENTROAM_VERSION_REQUEST" in
201
+ *[0-9])
202
+ # Looks like an exact version — honor the pin as-is.
203
+ AGENTROAM_VERSION="$AGENTROAM_VERSION_REQUEST"
204
+ ;;
205
+ *)
206
+ # A dist-tag (default "preview") — resolve to the newest published version
207
+ # so installs always pick up the latest release without touching this file.
208
+ resolved_version="$("$NODE_BIN" "$NPM_CLI" view "agentroam@$AGENTROAM_VERSION_REQUEST" version --registry "$NPM_REGISTRY" 2>/dev/null | tail -1 | tr -d '[:space:]')"
209
+ [ -n "$resolved_version" ] || fail "could not resolve agentroam@$AGENTROAM_VERSION_REQUEST from $NPM_REGISTRY"
210
+ AGENTROAM_VERSION="$resolved_version"
211
+ printf 'Installing AgentRoam %s (resolved from %s)\n' "$AGENTROAM_VERSION" "$AGENTROAM_VERSION_REQUEST"
212
+ ;;
213
+ esac
203
214
  PACKAGE_SPEC="agentroam@$AGENTROAM_VERSION"
215
+ LAUNCHER_ROOT="$LAUNCHER_PARENT/$AGENTROAM_VERSION"
216
+ LAUNCHER_LOCK="$LAUNCHER_ROOT.lock"
204
217
  EXTRA_PACKAGE_SPECS=""
205
218
  if [ "${AGENTROAM_BOOTSTRAP_TEST:-}" = "1" ] && [ -n "${AGENTROAM_PACKAGE_SPEC:-}" ]; then
206
219
  PACKAGE_SPEC="$AGENTROAM_PACKAGE_SPEC"
@@ -251,3 +264,26 @@ case ":${PATH:-}:" in
251
264
  *":$WRAPPER_DIR:"*) ;;
252
265
  *) printf 'Add this directory to PATH: export PATH="%s:$PATH"\n' "$WRAPPER_DIR" ;;
253
266
  esac
267
+
268
+ # stdin is the script itself when launched with curl | sh. Read the choice
269
+ # from the controlling terminal instead; unattended installs never block.
270
+ desktop_choice="${AGENTROAM_INSTALL_DESKTOP:-ask}"
271
+ if [ "${AGENTROAM_INSTALL_SKIP_SERVICE:-}" = "1" ] && [ "$desktop_choice" = "ask" ]; then desktop_choice=no; fi
272
+ if [ "$desktop_choice" = "ask" ]; then
273
+ desktop_choice=no
274
+ if ( : </dev/tty ) 2>/dev/null; then
275
+ printf '\n是否下载桌面端?用于手机查看和控制电脑桌面 [y/N]:' >/dev/tty
276
+ IFS= read -r desktop_choice </dev/tty || desktop_choice=no
277
+ fi
278
+ fi
279
+ case "$desktop_choice" in
280
+ y|Y|yes|YES|是)
281
+ desktop_helper="$LAUNCHER_ROOT/node_modules/agentroam/bin/desktop-download.mjs"
282
+ if [ -f "$desktop_helper" ]; then
283
+ "$NODE_BIN" "$desktop_helper" "$AGENTROAM_VERSION" || printf 'CLI 安装已完成,桌面端可稍后重新下载。\n'
284
+ else
285
+ printf '当前发布的 CLI 尚未包含桌面下载功能,请在新版发布后重试。CLI 安装已完成。\n'
286
+ fi
287
+ ;;
288
+ *) printf '已跳过桌面端下载,CLI 可正常使用。\n' ;;
289
+ esac
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentroam",
3
- "version": "0.2.0-preview.17",
3
+ "version": "0.2.0-preview.18",
4
4
  "description": "Zero-install mobile remote terminal and file console",
5
5
  "type": "module",
6
6
  "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.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"
29
+ "agentroam-runtime-darwin-arm64": "0.2.0-preview.18",
30
+ "agentroam-runtime-win32-x64": "0.2.0-preview.18",
31
+ "agentroam-cloudflared-darwin-arm64": "0.2.0-preview.18",
32
+ "agentroam-cloudflared-win32-x64": "0.2.0-preview.18",
33
+ "agentroam-tui-darwin-arm64": "0.2.0-preview.18",
34
+ "@caoqu/agentroam-tui-win32-x64": "0.2.0-preview.18"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^22.0.0",
package/dist/pairing.js DELETED
@@ -1,2 +0,0 @@
1
- import { createHash, randomBytes } from "node:crypto";
2
- export function createPairingSecret(now = Date.now()) { const token = randomBytes(32).toString("base64url"); return { token, hashHex: createHash("sha256").update(token).digest("hex"), expiresAt: new Date(now + 5 * 60 * 1000).toISOString() }; }