agentroam 0.2.0-preview.19 → 0.2.0-preview.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,20 +20,20 @@ npx agentroam@preview
20
20
  Or install from tarball:
21
21
 
22
22
  ```bash
23
- npm install ./agentroam-cloudflared-darwin-arm64-0.2.0-preview.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
23
+ npm install ./agentroam-cloudflared-darwin-arm64-0.2.0-preview.20.tgz \
24
+ ./agentroam-runtime-darwin-arm64-0.2.0-preview.20.tgz \
25
+ ./agentroam-tui-darwin-arm64-0.2.0-preview.20.tgz \
26
+ ./agentroam-0.2.0-preview.20.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.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
33
+ npm install ./agentroam-cloudflared-win32-x64-0.2.0-preview.20.tgz `
34
+ ./agentroam-runtime-win32-x64-0.2.0-preview.20.tgz `
35
+ ./caoqu-agentroam-tui-win32-x64-0.2.0-preview.20.tgz `
36
+ ./agentroam-0.2.0-preview.20.tgz
37
37
  npx agentroam
38
38
  ```
39
39
 
@@ -1,6 +1,6 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { createHash } from 'node:crypto';
3
2
  import { createWriteStream } from 'node:fs';
3
+ import { readFileSync } from 'node:fs';
4
4
  import { mkdir, mkdtemp, rename, rm } from 'node:fs/promises';
5
5
  import { homedir } from 'node:os';
6
6
  import { join, resolve } from 'node:path';
@@ -24,8 +24,8 @@ export function desktopAsset(manifest, version, platform, arch) {
24
24
  }
25
25
 
26
26
  export async function downloadDesktop(version, { platform = process.platform, arch = process.arch, directory = join(homedir(), 'Downloads'), fetchImpl = fetch } = {}) {
27
- const launcher = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
28
- if (version === launcher.version) version = launcher.optionalDependencies["agentroam-runtime-darwin-arm64"];
27
+ const launcher = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
28
+ if (version === launcher.version) version = launcher.optionalDependencies['agentroam-runtime-darwin-arm64'];
29
29
  // Validate coordinates before making any request.
30
30
  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');
31
31
  const response = await fetchImpl(`${RELEASE_BASE}/v${version}/release-manifest.json`, { signal: AbortSignal.timeout(30_000) });
package/dist/cli.js CHANGED
@@ -16,6 +16,7 @@ import { resolveServicePaths } from "./service/service-files.js";
16
16
  import { selectRelay } from "./tunnel/relay-orchestrator.js";
17
17
  import { currentCliPath, startUpdate } from "./update/update-command.js";
18
18
  import { runUpdateWorker } from "./update/update-worker.js";
19
+ import { lockInstanceStartup, stopPreviousInstances } from "./instance-takeover.js";
19
20
  const VERSION = AGENTROAM_VERSION;
20
21
  export async function main(argv) {
21
22
  if (argv.includes("--help") || argv.includes("-h")) {
@@ -102,23 +103,38 @@ export async function main(argv) {
102
103
  let runtime = null;
103
104
  let relay = null;
104
105
  let closing = false;
106
+ let releaseStartup;
107
+ let closePromise;
108
+ let reportedStarting = false;
105
109
  const serviceReporter = process.env.AGENTROAM_SERVICE === "1"
106
110
  ? new ServiceRuntimeReporter(resolveServicePaths(undefined, options.dataDir), VERSION)
107
111
  : null;
108
- const close = async () => {
109
- if (closing)
110
- return;
112
+ const close = () => {
113
+ if (closePromise)
114
+ return closePromise;
111
115
  closing = true;
112
116
  controller.abort();
113
- await relay?.tunnel?.close().catch(() => { });
114
- await runtime?.close().catch(() => { });
117
+ closePromise = (async () => {
118
+ await relay?.tunnel?.close().catch(() => { });
119
+ await runtime?.close().catch(() => { });
120
+ })();
121
+ return closePromise;
115
122
  };
116
123
  const requestClose = () => void close();
117
124
  process.once("SIGINT", requestClose);
118
125
  process.once("SIGTERM", requestClose);
119
126
  try {
127
+ releaseStartup = await lockInstanceStartup(options.dataDir);
128
+ await stopPreviousInstances(options.dataDir, console.log);
129
+ if (controller.signal.aborted)
130
+ return;
120
131
  await serviceReporter?.starting();
132
+ reportedStarting = Boolean(serviceReporter);
121
133
  runtime = await new RuntimeManager().start(options, target);
134
+ if (controller.signal.aborted) {
135
+ await runtime.close();
136
+ return;
137
+ }
122
138
  void runtime.exited.then(() => controller.abort());
123
139
  console.log(`✓ Local server: ${runtime.localUrl}/web`);
124
140
  const lanUrl = options.localOnly ? findLanUrl(runtime.port) ?? runtime.localUrl : runtime.localUrl;
@@ -134,8 +150,10 @@ export async function main(argv) {
134
150
  onFailure: (provider, message) => console.error(`⚠ ${providerDisplayName(provider)} unavailable: ${message}`),
135
151
  allowLanFallback: options.localOnly,
136
152
  });
137
- if (controller.signal.aborted)
153
+ if (controller.signal.aborted) {
154
+ await relay.tunnel?.close();
138
155
  return;
156
+ }
139
157
  if (relay.provider === "lan") {
140
158
  if (relay.failures.length > 0)
141
159
  console.error(` Continuing with local network access: ${relay.publicUrl}/web`);
@@ -156,6 +174,7 @@ export async function main(argv) {
156
174
  accessUrl,
157
175
  provider: relay.provider,
158
176
  });
177
+ await releaseStartup();
159
178
  if (serviceReporter) {
160
179
  console.log("✓ Background service URL written to the private state file.");
161
180
  }
@@ -179,7 +198,9 @@ export async function main(argv) {
179
198
  process.removeListener("SIGINT", requestClose);
180
199
  process.removeListener("SIGTERM", requestClose);
181
200
  await close();
182
- await serviceReporter?.stopped().catch(() => { });
201
+ await releaseStartup?.();
202
+ if (reportedStarting)
203
+ await serviceReporter?.stopped().catch(() => { });
183
204
  await sleepInhibitor.release();
184
205
  }
185
206
  }
@@ -1,11 +1,44 @@
1
1
  import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
- import { execFileSync } from "node:child_process";
3
+ import { execFileSync, spawnSync } from "node:child_process";
4
4
  import { describe, expect, it } from "vitest";
5
5
  import { MANAGED_NODE_VERSION, NODE_RUNTIME_ASSETS } from "./node-runtime-manager.js";
6
6
  import { MINIMUM_NODE_VERSION, isSupportedNodeVersion } from "../bin/runtime-policy.mjs";
7
7
  const installRoot = resolve(import.meta.dirname, "../install");
8
8
  describe("standalone installer contracts", () => {
9
+ it.skipIf(process.platform === "win32")("continues after failed diagnostics but stops when service registration fails", async () => {
10
+ const root = await mkdtemp(resolve(process.env.TMPDIR || "/tmp", "agentroam-install-diagnostics-"));
11
+ try {
12
+ const script = await readFile(resolve(installRoot, "install-agentroam.sh"), "utf8");
13
+ // Execute the actual post-install flow with a fake CLI, without touching launchd.
14
+ const start = script.indexOf('if ! "$NODE_BIN" "$entry" doctor');
15
+ expect(start).toBeGreaterThan(0);
16
+ const node = resolve(root, "node");
17
+ const calls = resolve(root, "calls");
18
+ await writeFile(node, `#!/bin/sh\nprintf '%s\\n' "$2" >> "$TEST_CALLS"\ncase "$2" in\n doctor) exit 1 ;;\n service) exit "$TEST_SERVICE_EXIT" ;;\nesac\n`);
19
+ await chmod(node, 0o755);
20
+ for (const serviceExit of [0, 1]) {
21
+ await writeFile(calls, "");
22
+ const result = spawnSync("/bin/sh", ["-c", `set -eu\n${script.slice(start)}`], {
23
+ encoding: "utf8",
24
+ env: { ...process.env, NODE_BIN: node, entry: "fake-cli", DATA_DIR: root, SERVICE_ROOT: root,
25
+ WRAPPER_PATH: "agentroam", WRAPPER_DIR: root, AGENTROAM_VERSION: "test",
26
+ AGENTROAM_INSTALL_SKIP_SERVICE: "0", AGENTROAM_INSTALL_DESKTOP: "no",
27
+ TEST_CALLS: calls, TEST_SERVICE_EXIT: String(serviceExit) },
28
+ });
29
+ expect(await readFile(calls, "utf8")).toBe("doctor\nservice\n");
30
+ expect(result.stderr).toContain("some component checks failed");
31
+ expect(result.status).toBe(serviceExit);
32
+ if (serviceExit === 0)
33
+ expect(result.stdout).toContain("installed:");
34
+ else
35
+ expect(result.stdout).not.toContain("installed:");
36
+ }
37
+ }
38
+ finally {
39
+ await rm(root, { recursive: true, force: true });
40
+ }
41
+ });
9
42
  it.skipIf(process.platform !== "darwin" || process.arch !== "arm64")("allows installation from home and keeps a compatible active Node", async () => {
10
43
  const root = await mkdtemp(resolve(process.env.TMPDIR || "/tmp", "agentroam-active-node-"));
11
44
  try {
@@ -0,0 +1,146 @@
1
+ import { readdir, readFile, mkdir, writeFile, rm, realpath } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { runCommand } from "./service/service-controller.js";
6
+ import { MacLaunchAgent } from "./service/macos-launch-agent.js";
7
+ import { WindowsTaskService } from "./service/windows-task-service.js";
8
+ function alive(pid) {
9
+ try {
10
+ process.kill(pid, 0);
11
+ return true;
12
+ }
13
+ catch (error) {
14
+ return error.code !== "ESRCH";
15
+ }
16
+ }
17
+ async function processList() {
18
+ if (process.platform === "win32") {
19
+ const result = await runCommand("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress"]);
20
+ if (result.code !== 0)
21
+ throw new Error("无法核验旧 CLI 进程");
22
+ return JSON.parse(result.stdout).map((p) => ({ pid: p.ProcessId, ppid: p.ParentProcessId, command: p.CommandLine ?? "" }));
23
+ }
24
+ const result = await runCommand("ps", ["-axo", "pid=,ppid=,command="]);
25
+ if (result.code !== 0)
26
+ throw new Error("无法核验旧 CLI 进程");
27
+ return result.stdout.split("\n").flatMap(line => {
28
+ const match = /^\s*(\d+)\s+(\d+)\s+(.+)$/.exec(line);
29
+ return match ? [{ pid: Number(match[1]), ppid: Number(match[2]), command: match[3] }] : [];
30
+ });
31
+ }
32
+ async function stopCli(pid) {
33
+ const service = process.platform === "win32" ? new WindowsTaskService() : new MacLaunchAgent();
34
+ const status = await service.status();
35
+ if (status.running && status.state?.pid === pid) {
36
+ await service.stop();
37
+ return;
38
+ }
39
+ if (process.platform === "win32") {
40
+ const result = await runCommand("taskkill", ["/PID", String(pid), "/T", "/F"]);
41
+ if (result.code !== 0)
42
+ throw new Error("旧 CLI 进程树停止失败");
43
+ }
44
+ else
45
+ process.kill(pid, "SIGTERM");
46
+ }
47
+ async function isPackagedRuntime(command) {
48
+ const match = /(?:^|\s)(?:"([^"]+[\\/]ws-server\.mjs)"|(\S+[\\/]ws-server\.mjs))(?=\s|$)/.exec(command);
49
+ const gateway = match?.[1] ?? match?.[2];
50
+ if (!gateway)
51
+ return false;
52
+ try {
53
+ const path = await realpath(gateway);
54
+ if (!/[\\/]node_modules[\\/]agentroam-runtime-(?:darwin-arm64|win32-x64)[\\/]runtime[\\/]ws-server\.mjs$/.test(path))
55
+ return false;
56
+ const manifest = JSON.parse(await readFile(join(dirname(dirname(path)), "package.json"), "utf8"));
57
+ return ["agentroam-runtime-darwin-arm64", "agentroam-runtime-win32-x64"].includes(manifest.name);
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
63
+ /** Authenticate the local server, then verify its CLI parent before sending any signal. */
64
+ export async function stopPreviousInstances(dataDir, log, dependencies = {}) {
65
+ const canonical = await realpath(dataDir);
66
+ const registry = dependencies.registry ?? process.env.AGENTROAM_DISCOVERY_DIR ?? join(homedir(), ".agentroam/services");
67
+ const names = await readdir(registry).catch((error) => { if (error.code === "ENOENT")
68
+ return []; throw error; });
69
+ for (const name of names.filter(name => name.endsWith(".json"))) {
70
+ let d;
71
+ try {
72
+ d = JSON.parse(await readFile(join(registry, name), "utf8"));
73
+ if (d.protocol !== 1 || !Number.isSafeInteger(d.pid) || d.pid <= 1 || typeof d.instanceId !== "string" || !/^[a-f0-9]{64}$/.test(d.token))
74
+ continue;
75
+ const actual = await realpath(d.dataDir);
76
+ if (actual !== canonical && actual !== join(canonical, "data"))
77
+ continue;
78
+ const url = new URL(d.url);
79
+ if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || url.pathname !== "/" || url.username || url.password || url.search || url.hash)
80
+ continue;
81
+ }
82
+ catch {
83
+ continue;
84
+ }
85
+ let identity;
86
+ try {
87
+ const response = await (dependencies.request ?? fetch)(new URL("/api/desktop/identity", d.url), { headers: { "x-agentroam-desktop-token": d.token }, redirect: "error", signal: AbortSignal.timeout(1500) });
88
+ if (!response.ok)
89
+ throw new Error("实例认证失败");
90
+ identity = await response.json();
91
+ }
92
+ catch {
93
+ if ((dependencies.alive ?? alive)(d.pid))
94
+ throw new Error("同一数据目录的旧实例无法核验,请先停止旧实例再重试");
95
+ continue;
96
+ }
97
+ if (identity.protocol !== 1 || identity.instanceId !== d.instanceId || identity.dataDir !== d.dataDir)
98
+ throw new Error("旧实例身份不匹配,已取消启动");
99
+ const processes = await (dependencies.processes ?? processList)();
100
+ const server = processes.find(p => p.pid === d.pid);
101
+ const parent = processes.find(p => p.pid === server?.ppid);
102
+ const orphan = server && (server.ppid === 1 || !parent) && await isPackagedRuntime(server.command);
103
+ if (!server || server.pid === process.pid || (!orphan && (!parent || parent.pid === process.pid || !/[\\/]ws-server\.mjs(?:["\s]|$)/.test(server.command) || !/[\\/]agentroam[\\/]bin[\\/]agentroam\.mjs(?:["\s]|$)/.test(parent.command)))) {
104
+ throw new Error("同一数据目录已有服务,但无法确认它是可接管的 CLI 实例,请先停止该服务");
105
+ }
106
+ // Re-read the process relationship immediately before stopping, to reject stale PID records.
107
+ const current = await (dependencies.processes ?? processList)();
108
+ if (!current.some(p => p.pid === server.pid && p.ppid === server.ppid && p.command === server.command) || (!orphan && !current.some(p => p.pid === parent.pid && p.command === parent.command)))
109
+ throw new Error("旧实例身份已变化,请重试");
110
+ const targetPid = orphan ? server.pid : parent.pid;
111
+ log(orphan ? "检测到旧 CLI 遗留的服务进程,正在停止…" : "检测到同一数据目录的旧 CLI,正在停止…");
112
+ await (dependencies.stop ?? stopCli)(targetPid);
113
+ const deadline = Date.now() + (dependencies.timeoutMs ?? 15_000);
114
+ while ((dependencies.alive ?? alive)(targetPid) || (dependencies.alive ?? alive)(server.pid)) {
115
+ if (Date.now() >= deadline)
116
+ throw new Error("旧 CLI 尚未退出,已取消新实例启动,请稍后重试");
117
+ await delay(100);
118
+ }
119
+ log("✓ 旧 CLI 已停止,继续启动新实例");
120
+ }
121
+ }
122
+ /** Serialize startup within a data directory; never remove a live owner's lock. */
123
+ export async function lockInstanceStartup(dataDir) {
124
+ await mkdir(dataDir, { recursive: true });
125
+ const path = resolve(await realpath(dataDir), ".cli-start.lock");
126
+ try {
127
+ await mkdir(path, { mode: 0o700 });
128
+ }
129
+ catch (error) {
130
+ if (error.code !== "EEXIST")
131
+ throw error;
132
+ throw new Error("此数据目录已有 CLI 正在启动;请等它完成后重试。若上次异常退出,请确认无启动进程后移除 .cli-start.lock。");
133
+ }
134
+ try {
135
+ await writeFile(join(path, "pid"), String(process.pid), { mode: 0o600 });
136
+ }
137
+ catch (error) {
138
+ await rm(path, { recursive: true, force: true });
139
+ throw error;
140
+ }
141
+ let released = false;
142
+ return async () => { if (!released) {
143
+ released = true;
144
+ await rm(path, { recursive: true, force: true });
145
+ } };
146
+ }
@@ -0,0 +1,121 @@
1
+ import { once } from "node:events";
2
+ import { afterEach, expect, it, vi } from "vitest";
3
+ import { mkdtemp, mkdir, writeFile, rm, realpath } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { lockInstanceStartup, stopPreviousInstances } from "./instance-takeover.js";
7
+ const temporary = [];
8
+ afterEach(async () => { await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true }))); });
9
+ async function fixture() {
10
+ const root = await realpath(await mkdtemp(join(tmpdir(), "cli-takeover-")));
11
+ temporary.push(root);
12
+ const registry = join(root, "registry");
13
+ const dataDir = join(root, "data-dir");
14
+ await mkdir(registry);
15
+ await mkdir(join(dataDir, "data"), { recursive: true });
16
+ const descriptor = { protocol: 1, instanceId: "test-instance", pid: 200, url: "http://127.0.0.1:12345", dataDir: join(dataDir, "data"), token: "a".repeat(64) };
17
+ const save = () => writeFile(join(registry, "test.json"), JSON.stringify(descriptor));
18
+ await save();
19
+ const rows = [{ pid: 200, ppid: 100, command: "/node /runtime/ws-server.mjs" }, { pid: 100, ppid: 1, command: "/node /cache/node_modules/agentroam/bin/agentroam.mjs" }];
20
+ const stop = vi.fn(async () => { });
21
+ const deps = { registry, request: vi.fn(async () => Response.json(descriptor)), processes: vi.fn(async () => rows), stop, alive: () => false, timeoutMs: 1 };
22
+ return { descriptor, save, deps, stop, dataDir, rows };
23
+ }
24
+ it("authenticates the same data directory and stops the CLI parent before proceeding", async () => {
25
+ const f = await fixture();
26
+ await stopPreviousInstances(f.dataDir, () => { }, f.deps);
27
+ expect(f.stop).toHaveBeenCalledTimes(1);
28
+ expect(f.stop).toHaveBeenCalledWith(100);
29
+ expect(f.deps.processes).toHaveBeenCalledTimes(2);
30
+ });
31
+ it("leaves a different data directory untouched without making an authenticated request", async () => {
32
+ const f = await fixture();
33
+ f.descriptor.dataDir = tmpdir();
34
+ await f.save();
35
+ await stopPreviousInstances(f.dataDir, () => { }, f.deps);
36
+ expect(f.deps.request).not.toHaveBeenCalled();
37
+ expect(f.stop).not.toHaveBeenCalled();
38
+ });
39
+ it("does not send the private token to non-loopback addresses", async () => {
40
+ const f = await fixture();
41
+ f.descriptor.url = "https://example.com";
42
+ await f.save();
43
+ await stopPreviousInstances(f.dataDir, () => { }, f.deps);
44
+ expect(f.deps.request).not.toHaveBeenCalled();
45
+ expect(f.stop).not.toHaveBeenCalled();
46
+ });
47
+ it("rejects a matching live server with a non-CLI parent", async () => {
48
+ const f = await fixture();
49
+ f.rows[1].command = "/Applications/Other.app/server";
50
+ await expect(stopPreviousInstances(f.dataDir, () => { }, f.deps)).rejects.toThrow("无法确认");
51
+ expect(f.stop).not.toHaveBeenCalled();
52
+ });
53
+ it("does not signal a reused PID", async () => {
54
+ const f = await fixture();
55
+ f.deps.processes.mockResolvedValueOnce(f.rows).mockResolvedValueOnce([{ ...f.rows[0], ppid: 300 }, f.rows[1]]);
56
+ await expect(stopPreviousInstances(f.dataDir, () => { }, f.deps)).rejects.toThrow("身份已变化");
57
+ expect(f.stop).not.toHaveBeenCalled();
58
+ });
59
+ it("refuses to launch while the previous process still runs", async () => {
60
+ const f = await fixture();
61
+ f.deps.alive = () => true;
62
+ await expect(stopPreviousInstances(f.dataDir, () => { }, f.deps)).rejects.toThrow("尚未退出");
63
+ });
64
+ it("ignores stale dead descriptors but blocks unreachable live instances", async () => {
65
+ const f = await fixture();
66
+ f.deps.request = vi.fn(async () => { throw new Error("offline"); });
67
+ await stopPreviousInstances(f.dataDir, () => { }, f.deps);
68
+ expect(f.stop).not.toHaveBeenCalled();
69
+ f.deps.alive = () => true;
70
+ await expect(stopPreviousInstances(f.dataDir, () => { }, f.deps)).rejects.toThrow("无法核验");
71
+ });
72
+ it("serializes startup and releases the lock for the next launch", async () => {
73
+ const f = await fixture();
74
+ const release = await lockInstanceStartup(f.dataDir);
75
+ await expect(lockInstanceStartup(f.dataDir)).rejects.toThrow("正在启动");
76
+ await release();
77
+ await release();
78
+ await (await lockInstanceStartup(f.dataDir))();
79
+ });
80
+ it.skipIf(process.platform === "win32")("stops an authenticated real CLI parent and waits for its server child to exit", async () => {
81
+ const { spawn } = await import("node:child_process");
82
+ const f = await fixture();
83
+ const cli = join(f.dataDir, "node_modules/agentroam/bin/agentroam.mjs");
84
+ const server = join(f.dataDir, "runtime/ws-server.mjs");
85
+ await mkdir(join(f.dataDir, "node_modules/agentroam/bin"), { recursive: true });
86
+ await mkdir(join(f.dataDir, "runtime"));
87
+ await writeFile(server, `import http from 'node:http';\nimport {writeFileSync} from 'node:fs';\nconst d=JSON.parse(process.env.DESCRIPTOR);d.pid=process.pid;\nconst s=http.createServer((req,res)=>{if(req.headers['x-agentroam-desktop-token']!==d.token){res.writeHead(401).end();return;}res.setHeader('content-type','application/json');res.end(JSON.stringify(d));});\ns.listen(0,'127.0.0.1',()=>{d.url='http://127.0.0.1:'+s.address().port;writeFileSync(process.env.RECORD,JSON.stringify(d));console.log('ready');});`);
88
+ await writeFile(cli, `import {spawn} from 'node:child_process';\nimport {writeFileSync} from 'node:fs';\nconst child=spawn(process.execPath,[process.env.SERVER],{stdio:['ignore','inherit','inherit']});\nprocess.on('SIGTERM',()=>{writeFileSync(process.env.MARKER,'graceful');child.kill('SIGTERM');});\nchild.on('exit',()=>process.exit(0));`);
89
+ const marker = join(f.dataDir, "stopped");
90
+ const child = spawn(process.execPath, [cli], { env: { ...process.env, SERVER: server, RECORD: join(f.deps.registry, "test.json"), DESCRIPTOR: JSON.stringify(f.descriptor), MARKER: marker }, stdio: ["ignore", "pipe", "pipe"] });
91
+ const exited = once(child, "exit");
92
+ try {
93
+ await once(child.stdout, "data");
94
+ await stopPreviousInstances(f.dataDir, () => { }, { registry: f.deps.registry, stop: async (pid) => { expect(pid).toBe(child.pid); process.kill(pid, "SIGTERM"); } });
95
+ await exited;
96
+ const { readFile } = await import("node:fs/promises");
97
+ expect(await readFile(marker, "utf8")).toBe("graceful");
98
+ }
99
+ finally {
100
+ if (child.exitCode === null) {
101
+ child.kill("SIGTERM");
102
+ await exited;
103
+ }
104
+ }
105
+ });
106
+ it("stops an authenticated orphan only when its executable belongs to a runtime package", async () => {
107
+ const f = await fixture();
108
+ const pkg = join(f.dataDir, "launcher/0.2.0-preview.18/node_modules/agentroam-runtime-darwin-arm64");
109
+ await mkdir(join(pkg, "runtime"), { recursive: true });
110
+ await writeFile(join(pkg, "package.json"), JSON.stringify({ name: "agentroam-runtime-darwin-arm64" }));
111
+ await writeFile(join(pkg, "runtime/ws-server.mjs"), "// fixture");
112
+ f.rows.splice(0, f.rows.length, { pid: 200, ppid: 1, command: `/node ${join(pkg, "runtime/ws-server.mjs")}` });
113
+ await stopPreviousInstances(f.dataDir, () => { }, f.deps);
114
+ expect(f.stop).toHaveBeenCalledWith(200);
115
+ });
116
+ it("does not stop an orphan merely because its script is named ws-server.mjs", async () => {
117
+ const f = await fixture();
118
+ f.rows.splice(0, f.rows.length, { pid: 200, ppid: 1, command: "/node /unrelated/ws-server.mjs" });
119
+ await expect(stopPreviousInstances(f.dataDir, () => { }, f.deps)).rejects.toThrow("无法确认");
120
+ expect(f.stop).not.toHaveBeenCalled();
121
+ });
@@ -2,9 +2,8 @@ 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
- const launcherManifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
6
- export const AGENTROAM_VERSION = launcherManifest.version;
7
- export const PLATFORM_DEPENDENCY_VERSIONS = launcherManifest.optionalDependencies;
5
+ export const AGENTROAM_VERSION = "0.2.0-preview.20";
6
+ export const PLATFORM_DEPENDENCY_VERSIONS = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).optionalDependencies;
8
7
  export const PLATFORM_PACKAGES = {
9
8
  "darwin-arm64": {
10
9
  runtime: "agentroam-runtime-darwin-arm64",
@@ -0,0 +1,61 @@
1
+ import { lstat, readFile, readdir, realpath, rm } from "node:fs/promises";
2
+ import { relative, resolve, sep } from "node:path";
3
+ function versionParts(version) {
4
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-preview\.(0|[1-9]\d*))?$/.exec(version);
5
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3]), match[4] === undefined ? Infinity : Number(match[4])] : null;
6
+ }
7
+ function older(candidate, current) {
8
+ const left = versionParts(candidate), right = versionParts(current);
9
+ if (!left || !right)
10
+ return false;
11
+ for (let i = 0; i < left.length; i++)
12
+ if (left[i] !== right[i])
13
+ return left[i] < right[i];
14
+ return false;
15
+ }
16
+ function contains(parent, child) {
17
+ const path = relative(parent, child);
18
+ return path === "" || (!path.startsWith(`..${sep}`) && path !== ".." && !path.startsWith(sep));
19
+ }
20
+ /** Remove only recognized managed launcher versions after the replacement service is ready. */
21
+ export async function cleanupOldLaunchers(config, log) {
22
+ try {
23
+ const dataDir = await realpath(config.dataDir);
24
+ const launcherDir = resolve(dataDir, "launcher");
25
+ if (!(await lstat(launcherDir)).isDirectory() || (await lstat(launcherDir)).isSymbolicLink())
26
+ return;
27
+ const currentDir = resolve(launcherDir, config.version);
28
+ const expectedEntry = resolve(currentDir, "node_modules/agentroam/bin/agentroam.mjs");
29
+ if (await realpath(config.cliPath) !== expectedEntry)
30
+ return;
31
+ const roots = await Promise.all(config.roots.map(async (root) => realpath(root).catch(() => resolve(root))));
32
+ for (const entry of await readdir(launcherDir, { withFileTypes: true })) {
33
+ if (!entry.isDirectory() || !older(entry.name, config.version))
34
+ continue;
35
+ const candidate = resolve(launcherDir, entry.name);
36
+ try {
37
+ if (await lstat(`${candidate}.lock`).then(() => true, () => false))
38
+ continue;
39
+ if (roots.some(root => contains(candidate, root)))
40
+ continue;
41
+ const files = await readdir(candidate);
42
+ if (files.some(file => !["node_modules", "package.json", "package-lock.json"].includes(file)))
43
+ continue;
44
+ const manifestPath = resolve(candidate, "node_modules/agentroam/package.json");
45
+ if (await realpath(manifestPath) !== manifestPath)
46
+ continue;
47
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
48
+ if (manifest.name !== "agentroam" || manifest.version !== entry.name)
49
+ continue;
50
+ await rm(candidate, { recursive: true });
51
+ log(`✓ 已清理旧版 CLI ${entry.name},配置和数据已保留`);
52
+ }
53
+ catch {
54
+ log(`旧版 CLI ${entry.name} 暂未清理,将在下次安装时重试。`);
55
+ }
56
+ }
57
+ }
58
+ catch {
59
+ log("旧版 CLI 清理已跳过,新版服务不受影响。");
60
+ }
61
+ }
@@ -0,0 +1,81 @@
1
+ import { afterEach, expect, it } from "vitest";
2
+ import { mkdtemp, mkdir, writeFile, readFile, rm, symlink, realpath } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { resolve } from "node:path";
5
+ import { cleanupOldLaunchers } from "./launcher-cleanup.js";
6
+ const temporary = [];
7
+ afterEach(async () => { await Promise.all(temporary.splice(0).map(path => rm(path, { recursive: true, force: true }))); });
8
+ async function fixture() {
9
+ const dataDir = await realpath(await mkdtemp(resolve(tmpdir(), "launcher-cleanup-")));
10
+ temporary.push(dataDir);
11
+ async function install(version) {
12
+ const root = resolve(dataDir, "launcher", version);
13
+ const pkg = resolve(root, "node_modules/agentroam");
14
+ await mkdir(resolve(pkg, "bin"), { recursive: true });
15
+ await writeFile(resolve(pkg, "package.json"), JSON.stringify({ name: "agentroam", version }));
16
+ await writeFile(resolve(pkg, "bin/agentroam.mjs"), "// fixture");
17
+ return root;
18
+ }
19
+ const current = await install("0.2.0-preview.19");
20
+ const config = { dataDir, version: "0.2.0-preview.19", cliPath: resolve(current, "node_modules/agentroam/bin/agentroam.mjs"), roots: [] };
21
+ return { dataDir, current, config, install };
22
+ }
23
+ it("removes an older managed program while retaining current, newer versions and user data", async () => {
24
+ const f = await fixture();
25
+ const old = await f.install("0.2.0-preview.18");
26
+ const newer = await f.install("0.2.0-preview.20");
27
+ for (const file of ["config.json", "agent.db", "pairing.db"])
28
+ await writeFile(resolve(f.dataDir, file), "preserve");
29
+ await cleanupOldLaunchers(f.config, () => { });
30
+ await expect(readFile(resolve(old, "node_modules/agentroam/package.json"))).rejects.toMatchObject({ code: "ENOENT" });
31
+ for (const root of [f.current, newer])
32
+ expect(await readFile(resolve(root, "node_modules/agentroam/package.json"), "utf8")).toContain("agentroam");
33
+ for (const file of ["config.json", "agent.db", "pairing.db"])
34
+ expect(await readFile(resolve(f.dataDir, file), "utf8")).toBe("preserve");
35
+ });
36
+ it("preserves project roots, unexpected data, locked installs, and linked versions", async () => {
37
+ const f = await fixture();
38
+ const project = await f.install("0.2.0-preview.15");
39
+ f.config.roots = [project];
40
+ const data = await f.install("0.2.0-preview.16");
41
+ await writeFile(resolve(data, "session.db"), "preserve");
42
+ const locked = await f.install("0.2.0-preview.17");
43
+ await mkdir(`${locked}.lock`);
44
+ await symlink(data, resolve(f.dataDir, "launcher/0.2.0-preview.14"));
45
+ await cleanupOldLaunchers(f.config, () => { });
46
+ for (const root of [project, data, locked])
47
+ expect(await readFile(resolve(root, "node_modules/agentroam/package.json"), "utf8")).toContain("agentroam");
48
+ });
49
+ it("does not clean when the active CLI is outside the managed installation", async () => {
50
+ const f = await fixture();
51
+ const old = await f.install("0.2.0-preview.18");
52
+ f.config.cliPath = resolve(f.dataDir, "npx/agentroam.mjs");
53
+ await cleanupOldLaunchers(f.config, () => { });
54
+ expect(await readFile(resolve(old, "node_modules/agentroam/package.json"), "utf8")).toContain("agentroam");
55
+ });
56
+ it.each(["starting", "failed", "ready"])("cleans only after a replacement service is ready (%s)", async (status) => {
57
+ const { runServiceCommand } = await import("./service-command.js");
58
+ const { parseArgs } = await import("../args.js");
59
+ const f = await fixture();
60
+ const old = await f.install("0.2.0-preview.18");
61
+ const install = async () => {
62
+ if (status === "failed")
63
+ throw new Error("activation failed");
64
+ return { definition: "test", state: { status, version: f.config.version } };
65
+ };
66
+ const action = runServiceCommand(parseArgs(["service", "install", "--data-dir", f.dataDir]), {
67
+ platform: "darwin", arch: "arm64", nodeVersion: "25.8.0", nodePath: process.execPath,
68
+ cliPath: f.config.cliPath, version: f.config.version, log: () => { },
69
+ codexResolver: async () => ({ executable: "/test/codex", version: "test", source: "global" }),
70
+ controller: { install },
71
+ });
72
+ if (status === "failed")
73
+ await expect(action).rejects.toThrow("activation failed");
74
+ else
75
+ await action;
76
+ const manifest = readFile(resolve(old, "node_modules/agentroam/package.json"), "utf8");
77
+ if (status === "ready")
78
+ await expect(manifest).rejects.toMatchObject({ code: "ENOENT" });
79
+ else
80
+ expect(await manifest).toContain("agentroam");
81
+ });
@@ -6,6 +6,7 @@ import { renderQr } from "../qr.js";
6
6
  import { MacLaunchAgent } from "./macos-launch-agent.js";
7
7
  import { WindowsTaskService } from "./windows-task-service.js";
8
8
  import { assertSupportedNodeVersion } from "../../bin/runtime-policy.mjs";
9
+ import { cleanupOldLaunchers } from "./launcher-cleanup.js";
9
10
  export async function runServiceCommand(options, context) {
10
11
  const platform = context.platform ?? process.platform;
11
12
  const log = context.log ?? console.log;
@@ -57,10 +58,15 @@ export async function runServiceCommand(options, context) {
57
58
  };
58
59
  const { definition, state } = await controller.install(config);
59
60
  log(`✓ AgentRoam service installed: ${definition}`);
61
+ if (state?.status === "ready" && state.version === config.version)
62
+ await cleanupOldLaunchers(config, log);
60
63
  if (state?.status === "ready" && state.accessUrl) {
61
64
  log(`Open: ${state.accessUrl}`);
62
- if (state.localUrl)
63
- log(`电脑本机:${state.localUrl.replace(/\/$/, "")}/web(配对后可进行远程授权)`);
65
+ if (state.localUrl) {
66
+ log(`远程桌面授权地址:${state.localUrl.replace(/\/$/, "")}/web`);
67
+ log('请在运行 CLI 的这台电脑上用浏览器打开此地址,配对后点击“远程授权”(盾牌图标)。');
68
+ log('按提示授予“屏幕录制”和“辅助功能”权限,然后点击“开启共享”。');
69
+ }
64
70
  await (context.pairingPrinter ?? printPairingCode)(options.dataDir, log, { accessUrl: state.accessUrl, qr: options.qr, interactive: stdoutIsTTY });
65
71
  }
66
72
  else
@@ -25,7 +25,7 @@ describe("runServiceCommand", () => {
25
25
  it("installs the service using absolute runtime context and parsed start options", async () => {
26
26
  const install = vi.fn(async (config) => ({
27
27
  paths: { plistPath: "/Users/test/Library/LaunchAgents/com.agentroam.service.plist" },
28
- state: { status: "ready", accessUrl: "https://ready.example/web" },
28
+ state: { status: "ready", accessUrl: "https://ready.example/web", localUrl: "http://127.0.0.1:4317/" },
29
29
  definition: "/Users/test/Library/LaunchAgents/com.agentroam.service.plist",
30
30
  }));
31
31
  const log = vi.fn();
@@ -53,6 +53,8 @@ describe("runServiceCommand", () => {
53
53
  installedAt: "2026-09-03T00:00:00.000Z",
54
54
  }));
55
55
  expect(log).toHaveBeenCalledWith("Open: https://ready.example/web");
56
+ expect(log).toHaveBeenCalledWith("远程桌面授权地址:http://127.0.0.1:4317/web");
57
+ expect(log).toHaveBeenCalledWith(expect.stringContaining("屏幕录制"));
56
58
  });
57
59
  it("prints a scannable QR code after the URL on interactive terminals", async () => {
58
60
  const log = vi.fn();
package/install/README.md CHANGED
@@ -40,7 +40,7 @@ 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.18`.
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.20`.
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
 
@@ -226,7 +226,7 @@ if ($PathParts -notcontains $WrapperDir) {
226
226
  }
227
227
 
228
228
  & $NodeBin $Entry doctor --data-dir $DataDir
229
- if ($LASTEXITCODE -ne 0) { throw "AgentRoam doctor failed with exit code $LASTEXITCODE" }
229
+ if ($LASTEXITCODE -ne 0) { Write-Warning "Some component checks failed; continuing background service installation. Affected features may be unavailable. Run agentroam doctor to retry." }
230
230
  if ($env:AGENTROAM_INSTALL_SKIP_SERVICE -eq "1") {
231
231
  Write-Host "AgentRoam service registration skipped for isolated verification."
232
232
  } else {
@@ -4,7 +4,7 @@ set -eu
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
7
+ # AGENTROAM_VERSION=0.2.0-preview.20 sh install-agentroam.sh
8
8
  AGENTROAM_VERSION_REQUEST="${AGENTROAM_VERSION:-preview}"
9
9
  AGENTROAM_VERSION=""
10
10
  NODE_ARCHIVE="node-v22.22.0-darwin-arm64.tar.xz"
@@ -253,7 +253,9 @@ wrapper_temp=$(mktemp "$WRAPPER_DIR/.agentroam.XXXXXX")
253
253
  chmod 755 "$wrapper_temp"
254
254
  mv "$wrapper_temp" "$WRAPPER_PATH"
255
255
 
256
- "$NODE_BIN" "$entry" doctor --data-dir "$DATA_DIR"
256
+ if ! "$NODE_BIN" "$entry" doctor --data-dir "$DATA_DIR"; then
257
+ printf 'Warning: some component checks failed; continuing background service installation. Affected features may be unavailable. Run agentroam doctor to retry.\n' >&2
258
+ fi
257
259
  if [ "${AGENTROAM_INSTALL_SKIP_SERVICE:-}" = "1" ]; then
258
260
  printf 'AgentRoam service registration skipped for isolated verification.\n'
259
261
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentroam",
3
- "version": "0.2.0-preview.19",
3
+ "version": "0.2.0-preview.20",
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.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.19",
34
- "@caoqu/agentroam-tui-win32-x64": "0.2.0-preview.19"
29
+ "agentroam-runtime-darwin-arm64": "0.2.0-preview.20",
30
+ "agentroam-runtime-win32-x64": "0.2.0-preview.20",
31
+ "agentroam-cloudflared-darwin-arm64": "0.2.0-preview.20",
32
+ "agentroam-cloudflared-win32-x64": "0.2.0-preview.20",
33
+ "agentroam-tui-darwin-arm64": "0.2.0-preview.20",
34
+ "@caoqu/agentroam-tui-win32-x64": "0.2.0-preview.20"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "^22.0.0",