agentroam 0.2.0-preview.16 → 0.2.0-preview.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -17
- package/bin/desktop-download.mjs +62 -0
- package/bin/node-preflight.mjs +15 -22
- package/bin/node-runtime-policy.json +4 -0
- package/bin/runtime-policy.d.mts +5 -0
- package/bin/runtime-policy.mjs +37 -0
- package/dist/args.js +31 -2
- package/dist/args.test.js +16 -0
- package/dist/cli.js +64 -15
- 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 +35 -11
- package/dist/native-runtime.js +46 -0
- package/dist/native-runtime.test.js +23 -2
- package/dist/node-preflight.test.js +21 -15
- package/dist/opencode-runtime-manager.js +15 -3
- package/dist/opencode-runtime-manager.test.js +33 -1
- package/dist/platform-packages.js +7 -4
- package/dist/platform-packages.test.js +15 -4
- package/dist/platform.js +2 -4
- package/dist/platform.test.js +3 -3
- package/dist/runtime-manager.js +3 -5
- package/dist/service/service-command.js +10 -6
- package/dist/service/service-command.test.js +21 -0
- package/dist/tunnel/public-readiness.js +1 -1
- package/install/README.md +13 -5
- package/install/install-agentroam.ps1 +45 -13
- package/install/install-agentroam.sh +68 -16
- package/package.json +8 -8
- package/dist/pairing.js +0 -2
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Zero-install mobile remote terminal and file console. **No desktop app, no VPN**
|
|
|
4
4
|
|
|
5
5
|
## Requirements
|
|
6
6
|
|
|
7
|
-
- **Node.js 22** (
|
|
7
|
+
- **Node.js >=22.22.0** (no upper version bound)
|
|
8
8
|
- macOS arm64 or Windows 10/11 x64
|
|
9
9
|
|
|
10
10
|
The release is assembled on macOS arm64. Windows does not need Bun, Python,
|
|
@@ -20,20 +20,20 @@ npx agentroam@preview
|
|
|
20
20
|
Or install from tarball:
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
|
-
npm install ./agentroam-cloudflared-darwin-arm64-0.2.0-preview.
|
|
24
|
-
./agentroam-runtime-darwin-arm64-0.2.0-preview.
|
|
25
|
-
./agentroam-tui-darwin-arm64-0.2.0-preview.
|
|
26
|
-
./agentroam-0.2.0-preview.
|
|
23
|
+
npm install ./agentroam-cloudflared-darwin-arm64-0.2.0-preview.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
|
|
|
@@ -57,16 +57,16 @@ agent-tui # start the bundled terminal Agent UI
|
|
|
57
57
|
|
|
58
58
|
Run the versioned Shell installer on macOS or PowerShell installer on Windows
|
|
59
59
|
from the project directory AgentRoam should expose. The installer verifies or
|
|
60
|
-
installs Node
|
|
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
|
-
|
|
66
|
-
nvm-
|
|
67
|
-
|
|
64
|
+
The installer reuses the active Node when it meets the minimum version. Otherwise
|
|
65
|
+
it checks nvm-sh on macOS and nvm-windows on Windows and uses the highest
|
|
66
|
+
compatible installed version. The service records that executable without
|
|
68
67
|
changing the user's global or active NVM version. A private Node 22.22.0 is
|
|
69
|
-
downloaded only when no installed Node
|
|
68
|
+
downloaded only when no compatible installed Node is available. Higher Node
|
|
69
|
+
versions are never rejected or downgraded solely because of their major version.
|
|
70
70
|
|
|
71
71
|
The service keeps running after the terminal closes, restarts after failure, and
|
|
72
72
|
starts again when the user logs in. macOS uses a user LaunchAgent; Windows uses a
|
|
@@ -117,7 +117,7 @@ The platform cloudflared binary is installed from npm after size and SHA-256 ver
|
|
|
117
117
|
- Next.js **standalone** web gateway + WebSocket PTY server
|
|
118
118
|
- `@agent/core` bundled runtime
|
|
119
119
|
- Optional platform **cloudflared** package (verified size + SHA-256 before execution)
|
|
120
|
-
- Optional platform **agent-tui** package (Node 22 bundle; Bun is not required)
|
|
120
|
+
- Optional platform **agent-tui** package (Node >=22.22.0 bundle; Bun is not required)
|
|
121
121
|
- **Pairing token** + secure cookies behind HTTPS tunnel
|
|
122
122
|
|
|
123
123
|
Not included: Electron desktop app, SDK, dev `.env`, session caches.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createWriteStream } from 'node:fs';
|
|
3
|
+
import { mkdir, mkdtemp, rename, rm } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { Transform, Readable } from 'node:stream';
|
|
8
|
+
import { pipeline } from 'node:stream/promises';
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
const RELEASE_BASE = 'https://gitee.com/caoqu/team-agent/releases/download';
|
|
12
|
+
|
|
13
|
+
export function desktopAsset(manifest, version, platform, arch) {
|
|
14
|
+
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-preview\.(0|[1-9]\d*))?$/.test(version)) throw new Error('Invalid AgentRoam version');
|
|
15
|
+
const target = platform === 'darwin' && arch === 'arm64' ? 'darwin-arm64' : platform === 'win32' && arch === 'x64' ? 'windows-amd64' : null;
|
|
16
|
+
if (!target) throw new Error('Desktop download supports macOS Apple Silicon and Windows x64');
|
|
17
|
+
const asset = manifest?.installers?.desktop?.[target];
|
|
18
|
+
const expected = platform === 'darwin' ? `AgentRoam-${version}-arm64.dmg` : `AgentRoam-Setup-${version}-x64.exe`;
|
|
19
|
+
if (manifest?.schemaVersion !== 2 || manifest.version !== version || manifest.channel !== (version.includes('-preview.') ? 'preview' : 'latest') || asset?.fileName !== expected || !/^[a-f0-9]{64}$/.test(asset?.sha256 ?? '') || asset.signed !== false || (asset.size !== undefined && (!Number.isSafeInteger(asset.size) || asset.size <= 0))) {
|
|
20
|
+
throw new Error('This release has no valid desktop installer for your platform');
|
|
21
|
+
}
|
|
22
|
+
return asset;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function downloadDesktop(version, { platform = process.platform, arch = process.arch, directory = join(homedir(), 'Downloads'), fetchImpl = fetch } = {}) {
|
|
26
|
+
// Validate coordinates before making any request.
|
|
27
|
+
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-preview\.(0|[1-9]\d*))?$/.test(version)) throw new Error('Invalid AgentRoam version');
|
|
28
|
+
const response = await fetchImpl(`${RELEASE_BASE}/v${version}/release-manifest.json`, { signal: AbortSignal.timeout(30_000) });
|
|
29
|
+
if (!response.ok) throw new Error(`Desktop release manifest unavailable (${response.status})`);
|
|
30
|
+
const asset = desktopAsset(await response.json(), version, platform, arch);
|
|
31
|
+
await mkdir(directory, { recursive: true });
|
|
32
|
+
// Unique directory prevents overwriting an existing installer, including concurrent downloads.
|
|
33
|
+
const staging = await mkdtemp(join(directory, 'AgentRoam-'));
|
|
34
|
+
const partial = join(staging, `${asset.fileName}.download`);
|
|
35
|
+
try {
|
|
36
|
+
const binary = await fetchImpl(`${RELEASE_BASE}/v${version}/${encodeURIComponent(asset.fileName)}`, { signal: AbortSignal.timeout(600_000) });
|
|
37
|
+
if (!binary.ok || !binary.body) throw new Error(`Desktop download failed (${binary.status})`);
|
|
38
|
+
const hash = createHash('sha256');
|
|
39
|
+
let size = 0;
|
|
40
|
+
const verifier = new Transform({ transform(chunk, _encoding, callback) { size += chunk.length; hash.update(chunk); callback(null, chunk); } });
|
|
41
|
+
await pipeline(Readable.fromWeb(binary.body), verifier, createWriteStream(partial, { flags: 'wx', mode: 0o600 }));
|
|
42
|
+
if (hash.digest('hex') !== asset.sha256 || (asset.size !== undefined && asset.size !== size)) throw new Error('Desktop installer checksum or size mismatch');
|
|
43
|
+
const destination = join(staging, asset.fileName);
|
|
44
|
+
await rename(partial, destination);
|
|
45
|
+
return destination;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
await rm(staging, { recursive: true, force: true });
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
53
|
+
try {
|
|
54
|
+
const destination = await downloadDesktop(process.argv[2]);
|
|
55
|
+
console.log(`桌面端安装包已下载:${destination}\n请打开安装包完成安装。首次打开 AgentRoam 后,按提示授权远程桌面。`);
|
|
56
|
+
if (process.platform === 'darwin') execFile('/usr/bin/open', ['-R', destination], () => undefined);
|
|
57
|
+
else if (process.platform === 'win32') execFile('explorer.exe', [`/select,${destination}`], () => undefined);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error(`桌面端下载未完成:${error.message}。CLI 已安装,可继续使用。`);
|
|
60
|
+
process.exitCode = 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
package/bin/node-preflight.mjs
CHANGED
|
@@ -4,10 +4,10 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { delimiter, dirname, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
|
+
import { MINIMUM_NODE_VERSION, compareNodeVersions, isSupportedNodeVersion, parseNodeVersion } from "./runtime-policy.mjs";
|
|
7
8
|
|
|
8
9
|
const execFileAsync = promisify(execFile);
|
|
9
|
-
const
|
|
10
|
-
const RELEASE_VERSION = "0.2.0-preview.16";
|
|
10
|
+
const RELEASE_VERSION = "0.2.0-preview.18";
|
|
11
11
|
const RELEASE_BASE_URL = `https://gitee.com/caoqu/team-agent/releases/download/v${RELEASE_VERSION}`;
|
|
12
12
|
const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
13
13
|
|
|
@@ -21,10 +21,10 @@ export async function runNodePreflight(options = {}) {
|
|
|
21
21
|
const processHost = options.processHost ?? process;
|
|
22
22
|
const major = Number(nodeVersion.split(".")[0]);
|
|
23
23
|
|
|
24
|
-
if (
|
|
24
|
+
if (isSupportedNodeVersion(nodeVersion)) return { handled: false };
|
|
25
25
|
if (environment.AGENTROAM_MANAGED_NODE) {
|
|
26
26
|
throw cliError(
|
|
27
|
-
`managed Node.js re-entry failed: expected
|
|
27
|
+
`managed Node.js re-entry failed: expected >=${MINIMUM_NODE_VERSION}, got ${nodeVersion} (${environment.AGENTROAM_MANAGED_NODE})`,
|
|
28
28
|
);
|
|
29
29
|
}
|
|
30
30
|
if (!Number.isFinite(major) || major < 18) {
|
|
@@ -33,8 +33,8 @@ export async function runNodePreflight(options = {}) {
|
|
|
33
33
|
|
|
34
34
|
const target = detectManagedTarget(platform, arch);
|
|
35
35
|
const dataDir = parsePreflightDataDir(argv, environment, options.homeDir ?? homedir());
|
|
36
|
-
const
|
|
37
|
-
const systemNode = await
|
|
36
|
+
const findSystemNode = options.findSystemNode ?? defaultFindSystemNode;
|
|
37
|
+
const systemNode = await findSystemNode(environment, platform, options.currentExecutable ?? process.execPath);
|
|
38
38
|
let executable = systemNode;
|
|
39
39
|
|
|
40
40
|
if (!executable) {
|
|
@@ -55,7 +55,7 @@ export async function runNodePreflight(options = {}) {
|
|
|
55
55
|
const spawnChild = options.spawnChild ?? defaultSpawnChild;
|
|
56
56
|
const child = spawnChild(executable, [launcherPath, ...argv], {
|
|
57
57
|
stdio: "inherit",
|
|
58
|
-
env: { ...environment, AGENTROAM_MANAGED_NODE:
|
|
58
|
+
env: { ...environment, AGENTROAM_MANAGED_NODE: executable },
|
|
59
59
|
});
|
|
60
60
|
const result = await waitForChild(child, processHost);
|
|
61
61
|
return { handled: true, ...result };
|
|
@@ -77,7 +77,7 @@ function detectManagedTarget(platform, arch) {
|
|
|
77
77
|
throw cliError(`managed Node.js is unsupported on ${platform}-${arch}`);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
async function
|
|
80
|
+
async function defaultFindSystemNode(environment, platform, currentExecutable) {
|
|
81
81
|
const executableName = platform === "win32" ? "node.exe" : "node";
|
|
82
82
|
const pathDelimiter = platform === "win32" ? ";" : delimiter;
|
|
83
83
|
const currentDirectory = dirname(currentExecutable);
|
|
@@ -85,12 +85,12 @@ async function defaultFindSystemNode22(environment, platform, currentExecutable)
|
|
|
85
85
|
if (resolve(directory) === resolve(currentDirectory)) continue;
|
|
86
86
|
const candidate = resolve(directory, executableName);
|
|
87
87
|
const version = await inspectNodeVersion(candidate);
|
|
88
|
-
if (version
|
|
88
|
+
if (isSupportedNodeVersion(version)) return candidate;
|
|
89
89
|
}
|
|
90
|
-
return
|
|
90
|
+
return findNvmNode(environment, platform);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
export async function
|
|
93
|
+
export async function findNvmNode(environment = process.env, platform = process.platform) {
|
|
94
94
|
const windows = platform === "win32";
|
|
95
95
|
const homeDir = environment.HOME || environment.USERPROFILE || homedir();
|
|
96
96
|
const roots = windows
|
|
@@ -109,10 +109,10 @@ export async function findNvmNode22(environment = process.env, platform = proces
|
|
|
109
109
|
if (!entry.isDirectory()) continue;
|
|
110
110
|
const candidate = resolve(versionsRoot, entry.name, windows ? "node.exe" : "bin/node");
|
|
111
111
|
const version = await inspectNodeVersion(candidate);
|
|
112
|
-
if (version
|
|
112
|
+
if (isSupportedNodeVersion(version)) candidates.push({ candidate, version });
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
|
-
candidates.sort((left, right) =>
|
|
115
|
+
candidates.sort((left, right) => compareNodeVersions(right.version, left.version));
|
|
116
116
|
return candidates[0]?.candidate ?? null;
|
|
117
117
|
}
|
|
118
118
|
|
|
@@ -124,20 +124,13 @@ async function inspectNodeVersion(candidate) {
|
|
|
124
124
|
timeout: 5_000,
|
|
125
125
|
windowsHide: true,
|
|
126
126
|
});
|
|
127
|
-
const
|
|
128
|
-
return
|
|
127
|
+
const version = stdout.trim();
|
|
128
|
+
return parseNodeVersion(version) ? version : null;
|
|
129
129
|
} catch {
|
|
130
130
|
return null;
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
function compareVersions(left, right) {
|
|
135
|
-
for (let index = 0; index < 3; index++) {
|
|
136
|
-
if (left[index] !== right[index]) return left[index] - right[index];
|
|
137
|
-
}
|
|
138
|
-
return 0;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
134
|
async function defaultSpawnChild(executable, args, options) {
|
|
142
135
|
const { spawn } = await import("node:child_process");
|
|
143
136
|
return spawn(executable, args, options);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const MINIMUM_NODE_VERSION: string;
|
|
2
|
+
export function parseNodeVersion(version: unknown): number[] | null;
|
|
3
|
+
export function compareNodeVersions(left: string, right: string): number;
|
|
4
|
+
export function isSupportedNodeVersion(version: unknown, minimum?: string): boolean;
|
|
5
|
+
export function assertSupportedNodeVersion(version?: string): void;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const policy = JSON.parse(readFileSync(new URL("./node-runtime-policy.json", import.meta.url), "utf8"));
|
|
4
|
+
|
|
5
|
+
export const MINIMUM_NODE_VERSION = policy.minimumNodeVersion;
|
|
6
|
+
|
|
7
|
+
export function parseNodeVersion(version) {
|
|
8
|
+
if (typeof version !== "string") return null;
|
|
9
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(version);
|
|
10
|
+
if (!match) return null;
|
|
11
|
+
const parts = match.slice(1, 4).map(Number);
|
|
12
|
+
return parts.every(Number.isSafeInteger) ? parts : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function compareNodeVersions(left, right) {
|
|
16
|
+
const leftParts = parseNodeVersion(left);
|
|
17
|
+
const rightParts = parseNodeVersion(right);
|
|
18
|
+
if (!leftParts || !rightParts) throw new Error("invalid Node.js version");
|
|
19
|
+
for (let index = 0; index < 3; index++) {
|
|
20
|
+
if (leftParts[index] !== rightParts[index]) return leftParts[index] - rightParts[index];
|
|
21
|
+
}
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isSupportedNodeVersion(version, minimum = MINIMUM_NODE_VERSION) {
|
|
26
|
+
return parseNodeVersion(version) !== null && compareNodeVersions(version, minimum) >= 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function assertSupportedNodeVersion(version = process.versions.node) {
|
|
30
|
+
if (!isSupportedNodeVersion(version)) {
|
|
31
|
+
throw Object.assign(new Error(`Node.js >=${MINIMUM_NODE_VERSION} required (current ${version})`), { exitCode: 2 });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (policy.schemaVersion !== 1 || !parseNodeVersion(MINIMUM_NODE_VERSION)) {
|
|
36
|
+
throw new Error("invalid AgentRoam Node.js runtime policy");
|
|
37
|
+
}
|
package/dist/args.js
CHANGED
|
@@ -3,7 +3,7 @@ import { resolve } from "node:path";
|
|
|
3
3
|
export function parseArgs(argv) {
|
|
4
4
|
const values = [...argv];
|
|
5
5
|
let command = "start";
|
|
6
|
-
if (values[0] && ["start", "doctor", "version", "service", "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
|
@@ -3,13 +3,12 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
import { parseArgs } from "./args.js";
|
|
4
4
|
import { ensureCloudflared } from "./cloudflared/installer.js";
|
|
5
5
|
import { resolveCodexRuntime } from "./codex-runtime-manager.js";
|
|
6
|
-
import { repairNativeRuntimePermissions } from "./native-runtime.js";
|
|
6
|
+
import { probeSQLiteRuntime, probeTerminalRuntime, repairNativeRuntimePermissions } from "./native-runtime.js";
|
|
7
7
|
import { findLanUrl } from "./network.js";
|
|
8
|
-
import {
|
|
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([
|
|
@@ -138,7 +184,7 @@ export async function main(argv) {
|
|
|
138
184
|
}
|
|
139
185
|
}
|
|
140
186
|
async function doctor(target, dataDir) {
|
|
141
|
-
console.log(`✓ Node ${process.versions.node}`);
|
|
187
|
+
console.log(`✓ Node ${process.versions.node} · Node-API ${process.versions.napi}`);
|
|
142
188
|
console.log(`✓ Platform ${target}`);
|
|
143
189
|
const { runtimeRoot } = resolvePlatformRuntime(target);
|
|
144
190
|
const runtimeRequire = createRequire(`${runtimeRoot}/package.json`);
|
|
@@ -149,9 +195,12 @@ async function doctor(target, dataDir) {
|
|
|
149
195
|
catch (error) {
|
|
150
196
|
reportDoctorFailure(error);
|
|
151
197
|
}
|
|
152
|
-
for (const module of [
|
|
198
|
+
for (const [module, probe] of [
|
|
199
|
+
["node-pty", probeTerminalRuntime],
|
|
200
|
+
["better-sqlite3", probeSQLiteRuntime],
|
|
201
|
+
]) {
|
|
153
202
|
try {
|
|
154
|
-
runtimeRequire
|
|
203
|
+
await probe(runtimeRequire);
|
|
155
204
|
console.log(`✓ ${module}`);
|
|
156
205
|
}
|
|
157
206
|
catch (error) {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
6
|
+
// @ts-expect-error Standalone bootstrap helper ships as JavaScript.
|
|
7
|
+
import { desktopAsset, downloadDesktop } from '../bin/desktop-download.mjs';
|
|
8
|
+
const version = '0.2.0-preview.17';
|
|
9
|
+
const body = Buffer.from('desktop test artifact');
|
|
10
|
+
function manifest() {
|
|
11
|
+
return { schemaVersion: 2, version, channel: 'preview', installers: { desktop: {
|
|
12
|
+
'darwin-arm64': { fileName: `AgentRoam-${version}-arm64.dmg`, sha256: createHash('sha256').update(body).digest('hex'), size: body.length, signed: false },
|
|
13
|
+
'windows-amd64': { fileName: `AgentRoam-Setup-${version}-x64.exe`, sha256: createHash('sha256').update(body).digest('hex'), signed: false },
|
|
14
|
+
} } };
|
|
15
|
+
}
|
|
16
|
+
describe('optional desktop installer download', () => {
|
|
17
|
+
it('selects the versioned Windows asset and rejects wrong versions and filenames', () => {
|
|
18
|
+
expect(desktopAsset(manifest(), version, 'win32', 'x64').fileName).toMatch(/\.exe$/);
|
|
19
|
+
expect(() => desktopAsset(manifest(), '0.2.0-preview.18', 'darwin', 'arm64')).toThrow();
|
|
20
|
+
const invalid = manifest();
|
|
21
|
+
invalid.installers.desktop['darwin-arm64'].fileName = '../unsafe.dmg';
|
|
22
|
+
expect(() => desktopAsset(invalid, version, 'darwin', 'arm64')).toThrow();
|
|
23
|
+
});
|
|
24
|
+
it('downloads and verifies into unique directories without overwriting previous installs', async () => {
|
|
25
|
+
const directory = await mkdtemp(join(tmpdir(), 'desktop-download-'));
|
|
26
|
+
const fetchImpl = vi.fn(async (url) => url.endsWith('.json') ? Response.json(manifest()) : new Response(body));
|
|
27
|
+
try {
|
|
28
|
+
const first = await downloadDesktop(version, { directory, platform: 'darwin', arch: 'arm64', fetchImpl });
|
|
29
|
+
const second = await downloadDesktop(version, { directory, platform: 'darwin', arch: 'arm64', fetchImpl });
|
|
30
|
+
expect(await readFile(first)).toEqual(body);
|
|
31
|
+
expect(second).not.toBe(first);
|
|
32
|
+
expect(fetchImpl.mock.calls[1][0]).toContain(`/v${version}/AgentRoam-${version}-arm64.dmg`);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await rm(directory, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
it('cleans up partial files on checksum mismatch', async () => {
|
|
39
|
+
const directory = await mkdtemp(join(tmpdir(), 'desktop-download-'));
|
|
40
|
+
const fetchImpl = vi.fn(async (url) => url.endsWith('.json') ? Response.json(manifest()) : new Response('corrupted'));
|
|
41
|
+
try {
|
|
42
|
+
await expect(downloadDesktop(version, { directory, platform: 'darwin', arch: 'arm64', fetchImpl })).rejects.toThrow('checksum');
|
|
43
|
+
expect(await readdir(directory)).toEqual([]);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
await rm(directory, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
it('rejects missing platform artifacts before downloading binaries', async () => {
|
|
50
|
+
const fetchImpl = vi.fn(async () => Response.json({ ...manifest(), installers: {} }));
|
|
51
|
+
await expect(downloadDesktop(version, { platform: 'darwin', arch: 'arm64', fetchImpl })).rejects.toThrow('no valid');
|
|
52
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
// Exercise the actual installer tail with an isolated launcher. Nothing installs
|
|
56
|
+
// on the machine; the helper records whether the optional branch ran.
|
|
57
|
+
describe.skipIf(process.platform === 'win32')('Shell desktop choice after CLI installation', () => {
|
|
58
|
+
it.each(['no', 'yes', undefined])('handles choice %s without reading piped script input', async (choice) => {
|
|
59
|
+
const { execFileSync } = await import('node:child_process');
|
|
60
|
+
const { mkdir, writeFile } = await import('node:fs/promises');
|
|
61
|
+
const root = await mkdtemp(join(tmpdir(), 'desktop-choice-'));
|
|
62
|
+
try {
|
|
63
|
+
const bin = join(root, 'node_modules/agentroam/bin');
|
|
64
|
+
await mkdir(bin, { recursive: true });
|
|
65
|
+
const marker = join(root, 'selected');
|
|
66
|
+
await writeFile(join(bin, 'desktop-download.mjs'), `import { writeFileSync } from 'node:fs'; writeFileSync(process.env.CHOICE_MARKER, process.argv[2]);`);
|
|
67
|
+
const installer = await readFile(new URL('../install/install-agentroam.sh', import.meta.url), 'utf8');
|
|
68
|
+
const section = installer.slice(installer.indexOf('# stdin is the script itself'));
|
|
69
|
+
const env = { ...process.env, LAUNCHER_ROOT: root, NODE_BIN: process.execPath, AGENTROAM_VERSION: version, CHOICE_MARKER: marker };
|
|
70
|
+
delete env.AGENTROAM_INSTALL_DESKTOP;
|
|
71
|
+
if (choice)
|
|
72
|
+
Object.assign(env, { AGENTROAM_INSTALL_DESKTOP: choice });
|
|
73
|
+
execFileSync('/bin/sh', ['-c', section], { env, input: 'yes\n' });
|
|
74
|
+
if (choice === 'yes')
|
|
75
|
+
expect(await readFile(marker, 'utf8')).toBe(version);
|
|
76
|
+
else
|
|
77
|
+
expect(await readdir(root)).not.toContain('selected');
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
await rm(root, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|