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 +9 -10
- package/bin/desktop-download.mjs +62 -0
- package/bin/node-preflight.mjs +1 -1
- package/dist/args.js +31 -2
- package/dist/args.test.js +16 -0
- package/dist/cli.js +57 -11
- package/dist/desktop-download.test.js +83 -0
- package/dist/device-pairing.js +146 -0
- package/dist/device-pairing.test.js +33 -0
- package/dist/install-script-contract.test.js +6 -7
- package/dist/platform-packages.js +1 -1
- package/dist/runtime-manager.js +3 -5
- package/dist/service/service-command.js +8 -3
- package/dist/service/service-command.test.js +1 -0
- package/dist/tunnel/public-readiness.js +1 -1
- package/install/README.md +12 -4
- package/install/install-agentroam.ps1 +32 -6
- package/install/install-agentroam.sh +44 -8
- package/package.json +7 -7
- package/dist/pairing.js +0 -2
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.
|
|
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.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.
|
|
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.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.
|
|
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
|
+
}
|
package/bin/node-preflight.mjs
CHANGED
|
@@ -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.
|
|
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 === "--
|
|
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 {
|
|
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,
|
|
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:
|
|
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
|
|
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.
|
|
117
|
-
console.log
|
|
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")("
|
|
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:
|
|
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(
|
|
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("
|
|
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(
|
|
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("
|
|
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.
|
|
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",
|
package/dist/runtime-manager.js
CHANGED
|
@@ -18,7 +18,7 @@ export class RuntimeManager {
|
|
|
18
18
|
this.report = report;
|
|
19
19
|
this.opencodeResolver = opencodeResolver;
|
|
20
20
|
}
|
|
21
|
-
async start(options,
|
|
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
|
-
|
|
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.
|
|
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/
|
|
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/
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
30
|
-
"agentroam-runtime-win32-x64": "0.2.0-preview.
|
|
31
|
-
"agentroam-cloudflared-darwin-arm64": "0.2.0-preview.
|
|
32
|
-
"agentroam-cloudflared-win32-x64": "0.2.0-preview.
|
|
33
|
-
"agentroam-tui-darwin-arm64": "0.2.0-preview.
|
|
34
|
-
"@caoqu/agentroam-tui-win32-x64": "0.2.0-preview.
|
|
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() }; }
|