agentroam 0.2.0-preview.39 → 0.2.0-preview.40

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.
@@ -0,0 +1,84 @@
1
+ import { createInterface } from "node:readline";
2
+ import { Readable } from "node:stream";
3
+ export class DesktopAuthorizationInterrupted extends Error {
4
+ kind;
5
+ constructor(kind) {
6
+ super(kind === "skip" ? "已跳过远程桌面授权。" : "远程桌面授权等待超时,已跳过。");
7
+ this.kind = kind;
8
+ }
9
+ }
10
+ /** One terminal owns confirmation and cancellation, including curl | sh installs. */
11
+ export class DesktopAuthorizationPrompt {
12
+ options;
13
+ signal;
14
+ controller = new AbortController();
15
+ timer;
16
+ reader;
17
+ answer;
18
+ constructor(options) {
19
+ this.options = options;
20
+ this.signal = options.signal ? AbortSignal.any([options.signal, this.controller.signal]) : this.controller.signal;
21
+ this.timer = setTimeout(() => this.controller.abort(new DesktopAuthorizationInterrupted("timeout")), options.timeoutMs);
22
+ process.on("SIGINT", this.skip);
23
+ if (options.input) {
24
+ // The controlling tty echoes canonical input; readline must not echo it again.
25
+ this.reader = createInterface({ input: options.input, terminal: false });
26
+ this.reader.on("line", this.onLine);
27
+ this.reader.on("close", this.skip);
28
+ this.reader.on("SIGINT", this.skip);
29
+ this.reader.on("error", this.skip);
30
+ }
31
+ }
32
+ skip = () => { this.controller.abort(new DesktopAuthorizationInterrupted("skip")); };
33
+ onLine = (line) => {
34
+ if (this.answer) {
35
+ const answer = this.answer;
36
+ this.answer = undefined;
37
+ answer(/^(y|yes)$/i.test(line.trim()));
38
+ }
39
+ else if (/^(s|skip|n|no|跳过)$/i.test(line.trim()))
40
+ this.skip();
41
+ };
42
+ confirm() {
43
+ if (!this.reader)
44
+ return Promise.resolve(false);
45
+ return new Promise((resolve) => {
46
+ this.answer = resolve;
47
+ this.options.log("现在授权远程桌面,允许已配对设备查看和控制这台电脑?[y/N](回车跳过):");
48
+ });
49
+ }
50
+ close() {
51
+ clearTimeout(this.timer);
52
+ process.off("SIGINT", this.skip);
53
+ this.reader?.off("line", this.onLine);
54
+ this.reader?.off("close", this.skip);
55
+ this.reader?.off("SIGINT", this.skip);
56
+ this.reader?.off("error", this.skip);
57
+ this.reader?.close();
58
+ this.answer?.(false);
59
+ this.answer = undefined;
60
+ if (this.options.ownsInput && this.options.input instanceof Readable)
61
+ this.options.input.destroy();
62
+ }
63
+ }
64
+ /** A deadline must also cover a request or injected operation that never settles. */
65
+ export function withAuthorizationSignal(operation, signal) {
66
+ signal.throwIfAborted();
67
+ return new Promise((resolve, reject) => {
68
+ const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); };
69
+ signal.addEventListener("abort", abort, { once: true });
70
+ try {
71
+ operation().then((value) => {
72
+ signal.removeEventListener("abort", abort);
73
+ if (signal.aborted)
74
+ reject(signal.reason);
75
+ else
76
+ resolve(value);
77
+ }, (error) => { signal.removeEventListener("abort", abort); reject(error); });
78
+ }
79
+ catch (error) {
80
+ signal.removeEventListener("abort", abort);
81
+ reject(error);
82
+ }
83
+ });
84
+ }
@@ -1,16 +1,15 @@
1
1
  import { execFile } from "node:child_process";
2
- import { createInterface } from "node:readline/promises";
3
- import { Readable } from "node:stream";
4
2
  import { setTimeout as delay } from "node:timers/promises";
5
3
  import { promisify } from "node:util";
6
4
  import { resolveApprovalInput, terminalText } from "./device-pairing.js";
7
5
  import { localServiceRequest } from "./local-service-request.js";
6
+ import { DesktopAuthorizationInterrupted, DesktopAuthorizationPrompt, withAuthorizationSignal } from "./desktop-authorization-prompt.js";
8
7
  import { withCliProgress, withoutCliProgress } from "../bin/cli-progress.mjs";
9
8
  const runFile = promisify(execFile);
10
9
  const endpoint = "/api/remote-authorization";
11
- async function requestAuthorization(dataDir, body) {
10
+ async function requestAuthorization(dataDir, body, signal) {
12
11
  const status = await localServiceRequest(dataDir, endpoint, {
13
- method: body ? "POST" : "GET", body, timeoutMs: body?.action === "request-permission" ? 135_000 : 30_000,
12
+ method: body ? "POST" : "GET", body, signal, timeoutMs: body?.action === "request-permission" ? 135_000 : 30_000,
14
13
  });
15
14
  if (status.local !== true || typeof status.supported !== "boolean" || typeof status.installed !== "boolean"
16
15
  || typeof status.enabled !== "boolean" || typeof status.screen !== "boolean" || typeof status.accessibility !== "boolean"
@@ -20,31 +19,11 @@ async function requestAuthorization(dataDir, body) {
20
19
  }
21
20
  return status;
22
21
  }
23
- /** A native macOS dialog; no browser, shell interpolation, or piped stdin. */
24
- export async function confirmDesktopAuthorization(platform) {
25
- if (platform === "darwin") {
26
- const script = 'display dialog "是否启用 AgentRoam 远程桌面?\n接下来将请求屏幕录制和辅助功能权限。授权后,已配对的设备可以查看和控制这台电脑。" with title "AgentRoam 远程桌面授权" buttons {"稍后", "开始授权"} default button "开始授权" cancel button "稍后" giving up after 120';
27
- try {
28
- const { stdout } = await runFile("/usr/bin/osascript", ["-e", script], { timeout: 125_000, maxBuffer: 4096 });
29
- return stdout.includes("button returned:开始授权") && !stdout.includes("gave up:true");
30
- }
31
- catch {
32
- return false;
33
- }
34
- }
35
- const input = resolveApprovalInput({ interactive: true });
36
- if (!input)
37
- return false;
38
- const reader = createInterface({ input, output: process.stdout });
39
- try {
40
- const answer = await withoutCliProgress(() => reader.question("启用远程桌面,允许已配对设备查看和控制这台电脑?[y/N]:"));
41
- return /^(y|yes)$/i.test(answer.trim());
42
- }
43
- finally {
44
- reader.close();
45
- if (input !== process.stdin && input instanceof Readable)
46
- input.destroy();
47
- }
22
+ /** Clear only this app's stale permission; deleting the old bundle does not clear TCC. */
23
+ export async function resetMacDesktopPermission(permission, signal, run = runFile) {
24
+ await run("/usr/bin/tccutil", ["reset", permission === "screen" ? "ScreenCapture" : "Accessibility", "com.agentroam.remote-desktop"], {
25
+ signal, timeout: 5000, maxBuffer: 4096,
26
+ });
48
27
  }
49
28
  function recoveryHint(log) {
50
29
  log("稍后重新授权:agentroam desktop authorize;也可在本机浏览器的“远程授权”面板中操作。");
@@ -61,61 +40,85 @@ export async function authorizeRemoteDesktop(dataDir, context = {}) {
61
40
  return false;
62
41
  }
63
42
  const request = context.request ?? requestAuthorization;
64
- let status = await withCliProgress("正在检查本机远程桌面权限", () => request(dataDir));
65
- if (!status.supported)
66
- throw new Error("远程桌面支持 macOS 14 及以上的 Apple Silicon 电脑和 Windows 10/11 x64");
67
- if (!status.installed)
68
- throw new Error("当前 CLI 未包含远程授权组件,请升级 CLI");
69
- if (status.enabled) {
70
- status = await request(dataDir, { action: "recheck" });
71
- if (complete(status)) {
72
- log("✓ 远程桌面已授权并开启共享。");
73
- return true;
43
+ const input = context.input === undefined ? resolveApprovalInput({ interactive: true }) : context.input ?? undefined;
44
+ const prompt = new DesktopAuthorizationPrompt({ input, ownsInput: context.input === undefined && input !== process.stdin,
45
+ signal: context.signal, timeoutMs: context.timeoutMs ?? 60_000, log });
46
+ const run = (operation) => withAuthorizationSignal(operation, prompt.signal);
47
+ const check = (body) => run(() => request(dataDir, body, prompt.signal));
48
+ log("远程桌面授权可选;输入 s 后回车或按 Ctrl+C 可跳过,稍后可重新授权。");
49
+ try {
50
+ let status = await withCliProgress("正在检查本机远程桌面权限", () => check());
51
+ if (!status.supported)
52
+ throw new Error("远程桌面支持 macOS 14 及以上的 Apple Silicon 电脑和 Windows 10/11 x64");
53
+ if (!status.installed)
54
+ throw new Error("当前 CLI 未包含远程授权组件,请升级 CLI");
55
+ if (status.enabled) {
56
+ status = await check({ action: "recheck" });
57
+ if (complete(status)) {
58
+ log("✓ 远程桌面已授权并开启共享。");
59
+ return true;
60
+ }
74
61
  }
75
- }
76
- const platform = context.platform ?? process.platform;
77
- const confirm = context.confirm ?? (() => confirmDesktopAuthorization(platform));
78
- const confirmed = await (platform === "darwin"
79
- ? withCliProgress("正在等待本机授权弹窗确认", confirm)
80
- : withoutCliProgress(confirm));
81
- if (!confirmed) {
82
- log("已跳过远程桌面授权。");
83
- recoveryHint(log);
84
- return false;
85
- }
86
- status = await request(dataDir, { action: "recheck" });
87
- const wait = context.wait ?? delay;
88
- if (platform === "darwin") {
89
- for (const permission of ["screen", "accessibility"]) {
90
- if (status[permission])
91
- continue;
92
- const name = permission === "screen" ? "屏幕录制" : "辅助功能";
93
- log(`正在请求${name}权限,请在系统设置中允许 AgentRoam Remote Desktop…`);
94
- status = await withCliProgress(`正在等待${name}权限,请在系统弹窗或设置中允许`, async () => {
95
- let current = await request(dataDir, { action: "request-permission", permission });
96
- for (let attempt = 0; !current[permission] && attempt < (context.pollAttempts ?? 60); attempt++) {
97
- await wait(2000);
98
- current = await request(dataDir, { action: "recheck" });
62
+ const platform = context.platform ?? process.platform;
63
+ const confirm = context.confirm ?? (() => prompt.confirm());
64
+ const confirmed = await withoutCliProgress(() => run(confirm));
65
+ if (!confirmed) {
66
+ log("已跳过远程桌面授权。");
67
+ recoveryHint(log);
68
+ return false;
69
+ }
70
+ status = await check({ action: "recheck" });
71
+ const wait = context.wait ?? ((milliseconds) => delay(milliseconds, undefined, { signal: prompt.signal }));
72
+ if (platform === "darwin") {
73
+ for (const permission of ["screen", "accessibility"]) {
74
+ if (status[permission])
75
+ continue;
76
+ const name = permission === "screen" ? "屏幕录制" : "辅助功能";
77
+ log(`正在清理 AgentRoam 的旧${name}授权记录,以便重新请求权限…`);
78
+ try {
79
+ await run(() => (context.resetPermission ?? resetMacDesktopPermission)(permission, prompt.signal));
80
+ }
81
+ catch (error) {
82
+ prompt.signal.throwIfAborted();
83
+ log(`未能清理旧${name}授权:${terminalText(error instanceof Error ? error.message : String(error))}。可在系统设置中移除 AgentRoam Remote Desktop 后重试。`);
84
+ }
85
+ log(`正在请求${name}权限,请在系统设置中允许 AgentRoam Remote Desktop…`);
86
+ status = await withCliProgress(`正在等待${name}权限;输入 s 后回车可跳过`, async () => {
87
+ let current = await check({ action: "request-permission", permission });
88
+ for (let attempt = 0; !current[permission] && attempt < (context.pollAttempts ?? 60); attempt++) {
89
+ await run(() => wait(2000));
90
+ current = await check({ action: "recheck" });
91
+ }
92
+ return current;
93
+ });
94
+ if (!status[permission]) {
95
+ log(`${name}权限尚未授予,本次未开启共享。`);
96
+ recoveryHint(log);
97
+ return false;
99
98
  }
100
- return current;
101
- });
102
- if (!status[permission]) {
103
- log(`${name}权限尚未授予,本次未开启共享。`);
104
- recoveryHint(log);
105
- return false;
106
99
  }
107
100
  }
101
+ if (!status.screen || !status.accessibility) {
102
+ log("远程桌面尚不可用,请确认系统权限及当前桌面状态,本次未开启共享。");
103
+ recoveryHint(log);
104
+ return false;
105
+ }
106
+ status = await withCliProgress("正在开启并检查远程桌面共享", () => check({ action: "enable" }));
107
+ if (!complete(status))
108
+ throw new Error(status.error ? terminalText(status.error) : "远程桌面共享未就绪,请重新检查系统权限");
109
+ log("✓ 远程桌面授权完成,已开启共享。手机首次连接仍需设备配对。");
110
+ return true;
108
111
  }
109
- if (!status.screen || !status.accessibility) {
110
- log("远程桌面尚不可用,请确认系统权限及当前桌面状态,本次未开启共享。");
112
+ catch (error) {
113
+ if (!prompt.signal.aborted)
114
+ throw error;
115
+ log(prompt.signal.reason instanceof DesktopAuthorizationInterrupted ? prompt.signal.reason.message : "已取消远程桌面授权。");
111
116
  recoveryHint(log);
112
117
  return false;
113
118
  }
114
- status = await withCliProgress("正在开启并检查远程桌面共享", () => request(dataDir, { action: "enable" }));
115
- if (!complete(status))
116
- throw new Error(status.error ? terminalText(status.error) : "远程桌面共享未就绪,请重新检查系统权限");
117
- log("✓ 远程桌面授权完成,已开启共享。手机首次连接仍需设备配对。");
118
- return true;
119
+ finally {
120
+ prompt.close();
121
+ }
119
122
  }
120
123
  export async function runDesktopCommand(options, context = {}) {
121
124
  const log = context.log ?? console.log;
@@ -1,6 +1,7 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { setTimeout as delay } from "node:timers/promises";
3
- import { createReadStream, openSync } from "node:fs";
3
+ import { openSync } from "node:fs";
4
+ import { ReadStream } from "node:tty";
4
5
  import { readFile } from "node:fs/promises";
5
6
  import { homedir } from "node:os";
6
7
  import { join, resolve } from "node:path";
@@ -66,7 +67,9 @@ function openControllingTerminal() {
66
67
  catch {
67
68
  return undefined;
68
69
  }
69
- return createReadStream("/dev/tty", { fd, autoClose: true });
70
+ // Use libuv's cancellable TTY handle. A filesystem read on /dev/tty can keep
71
+ // the process alive after destroy(), waiting for one more line of input.
72
+ return new ReadStream(fd);
70
73
  }
71
74
  /**
72
75
  * Pick the stream the approval phrase is read from.
@@ -13,6 +13,9 @@ export async function localServiceRequest(dataDir, path, options = {}) {
13
13
  if (!path.startsWith("/api/"))
14
14
  throw new Error("无效的本机服务路径");
15
15
  const request = options.request ?? fetch;
16
+ options.signal?.throwIfAborted();
17
+ const requestSignal = (timeoutMs) => options.signal
18
+ ? AbortSignal.any([options.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
16
19
  const directory = options.registry ?? process.env.AGENTROAM_DISCOVERY_DIR ?? join(homedir(), ".agentroam", "services");
17
20
  const names = await readdir(directory).catch((error) => { if (error.code === "ENOENT")
18
21
  return []; throw error; });
@@ -25,15 +28,19 @@ export async function localServiceRequest(dataDir, path, options = {}) {
25
28
  continue;
26
29
  if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || url.pathname !== "/" || url.username || url.password || url.search || url.hash)
27
30
  continue;
28
- const response = await request(new URL("/api/desktop/identity", url), { headers: { "x-agentroam-desktop-token": d.token }, redirect: "error", signal: AbortSignal.timeout(1500) });
31
+ options.signal?.throwIfAborted();
32
+ const response = await request(new URL("/api/desktop/identity", url), { headers: { "x-agentroam-desktop-token": d.token }, redirect: "error", signal: requestSignal(1500) });
29
33
  if (!response.ok)
30
34
  continue;
31
35
  const identity = await response.json();
32
36
  if (identity.protocol === 1 && identity.instanceId === d.instanceId && identity.dataDir === d.dataDir)
33
37
  candidates.push(d);
34
38
  }
35
- catch { /* Stale descriptors are expected after a process crash. */ }
39
+ catch {
40
+ options.signal?.throwIfAborted(); /* Stale descriptors are expected after a process crash. */
41
+ }
36
42
  }
43
+ options.signal?.throwIfAborted();
37
44
  if (candidates.length !== 1)
38
45
  throw new Error(candidates.length ? "多个服务使用此数据目录,请先停止多余实例" : "未找到运行中的 AgentRoam 服务,请先启动服务或指定 --data-dir");
39
46
  const d = candidates[0];
@@ -44,7 +51,7 @@ export async function localServiceRequest(dataDir, path, options = {}) {
44
51
  method: options.method ?? "GET",
45
52
  headers: { "x-agentroam-desktop-token": d.token, "content-type": "application/json", origin: new URL(d.url).origin },
46
53
  ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
47
- redirect: "error", signal: AbortSignal.timeout(options.timeoutMs ?? 5000),
54
+ redirect: "error", signal: requestSignal(options.timeoutMs ?? 5000),
48
55
  });
49
56
  if (!response.ok)
50
57
  throw new LocalServiceHttpError(response.status);
@@ -71,7 +71,7 @@ export async function runServiceCommand(options, context) {
71
71
  }
72
72
  else {
73
73
  try {
74
- await (context.desktopAuthorization ?? authorizeRemoteDesktop)(options.dataDir, { platform, isTTY: stdoutIsTTY, log });
74
+ await (context.desktopAuthorization ?? authorizeRemoteDesktop)(options.dataDir, { platform, isTTY: stdoutIsTTY, timeoutMs: 60_000, log });
75
75
  }
76
76
  catch (error) {
77
77
  log(`远程桌面授权未完成:${error instanceof Error ? error.message : error}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentroam",
3
- "version": "0.2.0-preview.39",
3
+ "version": "0.2.0-preview.40",
4
4
  "description": "Zero-install mobile remote terminal and file console",
5
5
  "type": "module",
6
6
  "bin": {