agentroam 0.2.0-preview.36 → 0.2.0-preview.38
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/bin/cli-progress.d.mts +9 -0
- package/bin/cli-progress.mjs +65 -0
- package/bin/desktop-download.mjs +2 -1
- package/bin/node-preflight.mjs +6 -6
- package/dist/args.js +12 -1
- package/dist/cli.js +40 -15
- package/dist/codex-desktop-command.js +6 -2
- package/dist/codex-desktop-preflight.js +57 -0
- package/dist/codex-desktop-probe.js +23 -9
- package/dist/codex-desktop-setup.js +2 -1
- package/dist/desktop-authorization.js +148 -0
- package/dist/device-pairing.js +20 -40
- package/dist/instance-takeover.js +25 -4
- package/dist/local-desktop-url.js +1 -1
- package/dist/local-service-request.js +52 -0
- package/dist/runtime-manager.js +4 -3
- package/dist/service/launcher-cleanup.js +19 -1
- package/dist/service/macos-launch-agent.js +13 -7
- package/dist/service/service-command.js +40 -22
- package/dist/service/windows-task-service.js +10 -6
- package/dist/unlock-service.js +16 -8
- package/install/install-agentroam.ps1 +1 -1
- package/install/install-agentroam.sh +72 -8
- package/install/install-manifest.json +4 -4
- package/install/start-codex-desktop-shared.sh +8 -1
- package/package.json +5 -5
package/dist/device-pairing.js
CHANGED
|
@@ -1,49 +1,26 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
2
|
import { setTimeout as delay } from "node:timers/promises";
|
|
3
3
|
import { createReadStream, openSync } from "node:fs";
|
|
4
|
-
import {
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { join, resolve } from "node:path";
|
|
7
7
|
import { renderQr } from "./qr.js";
|
|
8
|
+
import { localServiceRequest, LocalServiceHttpError } from "./local-service-request.js";
|
|
9
|
+
import { withCliProgress, withoutCliProgress } from "../bin/cli-progress.mjs";
|
|
8
10
|
/** Resolve a private local descriptor; never send the administrative token to a tunnel URL. */
|
|
9
11
|
export async function pairingAdmin(dataDir, action, body, options = {}) {
|
|
10
|
-
const request = options.request ?? fetch;
|
|
11
|
-
const directory = options.registry ?? process.env.AGENTROAM_DISCOVERY_DIR ?? join(homedir(), ".agentroam", "services");
|
|
12
|
-
const names = await readdir(directory).catch((error) => { if (error.code === "ENOENT")
|
|
13
|
-
return []; throw error; });
|
|
14
|
-
const candidates = [];
|
|
15
|
-
for (const name of names.filter((name) => name.endsWith(".json"))) {
|
|
16
|
-
try {
|
|
17
|
-
const d = JSON.parse(await readFile(join(directory, name), "utf8"));
|
|
18
|
-
const url = new URL(d.url);
|
|
19
|
-
if (d.protocol !== 1 || typeof d.instanceId !== "string" || !Number.isSafeInteger(d.pid) || d.pid <= 0 || ![resolve(dataDir), resolve(dataDir, "data")].includes(d.dataDir) || !/^[a-f0-9]{64}$/.test(d.token))
|
|
20
|
-
continue;
|
|
21
|
-
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || url.pathname !== "/" || url.username || url.password || url.search || url.hash)
|
|
22
|
-
continue;
|
|
23
|
-
const response = await request(new URL("/api/desktop/identity", url), { headers: { "x-agentroam-desktop-token": d.token }, redirect: "error", signal: AbortSignal.timeout(1500) });
|
|
24
|
-
if (!response.ok)
|
|
25
|
-
continue;
|
|
26
|
-
const identity = await response.json();
|
|
27
|
-
if (identity.protocol === 1 && identity.instanceId === d.instanceId && identity.dataDir === d.dataDir)
|
|
28
|
-
candidates.push(d);
|
|
29
|
-
}
|
|
30
|
-
catch { /* Stale descriptors are expected after a process crash. */ }
|
|
31
|
-
}
|
|
32
|
-
if (candidates.length !== 1)
|
|
33
|
-
throw new Error(candidates.length ? "多个服务使用此数据目录,请先停止多余实例" : "未找到运行中的 AgentRoam 服务,请先启动服务或指定 --data-dir");
|
|
34
|
-
const d = candidates[0];
|
|
35
12
|
const readOnly = ["devices", "requests", "audit"].includes(action);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
13
|
+
try {
|
|
14
|
+
return await localServiceRequest(dataDir, `/api/pairing/admin/${action}`, {
|
|
15
|
+
...options, method: readOnly ? "GET" : "POST", ...(readOnly ? {} : { body: body ?? {} }),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (!(error instanceof LocalServiceHttpError))
|
|
20
|
+
throw error;
|
|
21
|
+
const message = error.status === 423 ? "远程访问已锁定,请先执行 agentroam unlock" : error.status === 410 ? "请求已过期或已处理" : error.status === 409 ? "核对短语不匹配" : `设备授权操作失败 (HTTP ${error.status})`;
|
|
44
22
|
throw new Error(message);
|
|
45
23
|
}
|
|
46
|
-
return response.json();
|
|
47
24
|
}
|
|
48
25
|
export function terminalText(value) { return value.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ""); }
|
|
49
26
|
export function pairingQrPayload(accessUrl, grant) {
|
|
@@ -53,7 +30,7 @@ export function pairingQrPayload(accessUrl, grant) {
|
|
|
53
30
|
return `${url.origin}/pair#pair=${grant}`;
|
|
54
31
|
}
|
|
55
32
|
export async function printPairingCode(dataDir, log = console.log, options = {}) {
|
|
56
|
-
const result = await pairingAdmin(dataDir, "code");
|
|
33
|
+
const result = await withCliProgress("正在生成设备配对码", () => pairingAdmin(dataDir, "code"));
|
|
57
34
|
log(`配对码:${result.code.slice(0, 4)} ${result.code.slice(4)}`);
|
|
58
35
|
log("5 分钟内有效,仅可使用一次。手机输入后仍需在电脑上确认核对短语。");
|
|
59
36
|
if ((options.qr ?? true) && (options.interactive ?? process.stdout.isTTY)) {
|
|
@@ -61,16 +38,19 @@ export async function printPairingCode(dataDir, log = console.log, options = {})
|
|
|
61
38
|
if (accessUrl) {
|
|
62
39
|
// Validate the public address before minting an invitation. Never persist the grant.
|
|
63
40
|
pairingQrPayload(accessUrl, "a".repeat(43));
|
|
64
|
-
const
|
|
41
|
+
const qr = await withCliProgress("正在生成手机连接二维码", async () => {
|
|
42
|
+
const { grant } = await pairingAdmin(dataDir, "qr");
|
|
43
|
+
return renderQr(pairingQrPayload(accessUrl, grant));
|
|
44
|
+
});
|
|
65
45
|
log("手机扫码直接连接(5 分钟、一次有效,请勿分享截图):");
|
|
66
|
-
log(
|
|
46
|
+
log(qr);
|
|
67
47
|
}
|
|
68
48
|
else
|
|
69
49
|
log("生成 App 授权二维码:agentroam pair --url <手机可访问的服务器地址>");
|
|
70
50
|
}
|
|
71
51
|
const approvalInput = resolveApprovalInput(options);
|
|
72
52
|
if (approvalInput) {
|
|
73
|
-
await watchPairingApproval(dataDir, result.expiresAt, { log, signal: options.signal, input: approvalInput });
|
|
53
|
+
await withCliProgress("正在等待手机扫码或输入配对码", () => watchPairingApproval(dataDir, result.expiresAt, { log, signal: options.signal, input: approvalInput }));
|
|
74
54
|
}
|
|
75
55
|
else
|
|
76
56
|
log("查看请求:agentroam approvals;批准:agentroam approve <请求ID> --phrase <手机核对短语>");
|
|
@@ -127,7 +107,7 @@ export async function watchPairingApproval(dataDir, codeExpiresAt, options = {})
|
|
|
127
107
|
log(`请求 ID:${request.id}`);
|
|
128
108
|
log(`核对短语:${request.phrase}`);
|
|
129
109
|
const confirm = options.confirm ?? ((request, signal) => confirmOnTerminal(request, signal, options.input));
|
|
130
|
-
const approved = await confirm(request, options.signal);
|
|
110
|
+
const approved = await withoutCliProgress(() => confirm(request, options.signal));
|
|
131
111
|
if (options.signal?.aborted)
|
|
132
112
|
return;
|
|
133
113
|
if (Date.now() >= request.expires) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { readdir, readFile, mkdir, writeFile, rm, realpath } from "node:fs/promises";
|
|
1
|
+
import { readdir, readFile, mkdir, writeFile, rm, realpath, rename } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { dirname, join, resolve } from "node:path";
|
|
4
5
|
import { setTimeout as delay } from "node:timers/promises";
|
|
@@ -68,8 +69,11 @@ export async function stopPreviousInstances(dataDir, log, dependencies = {}) {
|
|
|
68
69
|
return []; throw error; });
|
|
69
70
|
for (const name of names.filter(name => name.endsWith(".json"))) {
|
|
70
71
|
let d;
|
|
72
|
+
let source;
|
|
73
|
+
const recordPath = join(registry, name);
|
|
71
74
|
try {
|
|
72
|
-
|
|
75
|
+
source = await readFile(recordPath, "utf8");
|
|
76
|
+
d = JSON.parse(source);
|
|
73
77
|
if (d.protocol !== 1 || !Number.isSafeInteger(d.pid) || d.pid <= 1 || typeof d.instanceId !== "string" || !/^[a-f0-9]{64}$/.test(d.token))
|
|
74
78
|
continue;
|
|
75
79
|
const actual = await realpath(d.dataDir);
|
|
@@ -90,8 +94,25 @@ export async function stopPreviousInstances(dataDir, log, dependencies = {}) {
|
|
|
90
94
|
identity = await response.json();
|
|
91
95
|
}
|
|
92
96
|
catch {
|
|
93
|
-
if ((dependencies.alive ?? alive)(d.pid))
|
|
94
|
-
|
|
97
|
+
if ((dependencies.alive ?? alive)(d.pid)) {
|
|
98
|
+
const processes = dependencies.processes ?? processList;
|
|
99
|
+
const owner = (await processes()).find(p => p.pid === d.pid);
|
|
100
|
+
// An unavailable gateway may still own live sessions. Only quarantine
|
|
101
|
+
// when the PID demonstrably belongs to a different kind of process.
|
|
102
|
+
if (!owner?.command.trim() || /[\\/]ws-server\.mjs(?:["\s]|$)|[\\/]agentroam[\\/]bin[\\/]agentroam\.mjs(?:["\s]|$)/.test(owner.command)) {
|
|
103
|
+
throw new Error("同一数据目录的旧实例无法核验,请先停止旧实例再重试");
|
|
104
|
+
}
|
|
105
|
+
const current = (await processes()).find(p => p.pid === d.pid);
|
|
106
|
+
if (!current || current.ppid !== owner.ppid || current.command !== owner.command) {
|
|
107
|
+
throw new Error("旧实例身份已变化,请重试");
|
|
108
|
+
}
|
|
109
|
+
if (await readFile(recordPath, "utf8") !== source)
|
|
110
|
+
throw new Error("旧实例记录已变化,请重试");
|
|
111
|
+
const backupDir = join(registry, "stale");
|
|
112
|
+
await mkdir(backupDir, { recursive: true, mode: 0o700 });
|
|
113
|
+
await rename(recordPath, join(backupDir, `${randomUUID()}-${name}`));
|
|
114
|
+
log("检测到旧服务记录的 PID 已被其他进程复用,已备份失效记录,继续启动。");
|
|
115
|
+
}
|
|
95
116
|
continue;
|
|
96
117
|
}
|
|
97
118
|
if (identity.protocol !== 1 || identity.instanceId !== d.instanceId || identity.dataDir !== d.dataDir)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export function printLocalDesktopUrl(localUrl, log = console.log, platform = process.platform) {
|
|
2
2
|
log(`远程桌面授权地址:${localUrl.replace(/\/$/, '')}/web`);
|
|
3
|
-
log('
|
|
3
|
+
log('重新授权:agentroam desktop authorize;浏览器备用入口:在这台电脑打开上述地址,配对后点击“远程授权”(盾牌图标)。');
|
|
4
4
|
if (platform === 'win32')
|
|
5
5
|
log('点击“开启共享”。请保持 Windows 已登录且未锁屏;暂不支持 UAC 安全桌面和管理员窗口控制。');
|
|
6
6
|
else
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
export class LocalServiceHttpError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
constructor(status) {
|
|
7
|
+
super(`本机服务请求失败 (HTTP ${status})`);
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Resolve an identity-checked loopback service; never send its token to a tunnel. */
|
|
12
|
+
export async function localServiceRequest(dataDir, path, options = {}) {
|
|
13
|
+
if (!path.startsWith("/api/"))
|
|
14
|
+
throw new Error("无效的本机服务路径");
|
|
15
|
+
const request = options.request ?? fetch;
|
|
16
|
+
const directory = options.registry ?? process.env.AGENTROAM_DISCOVERY_DIR ?? join(homedir(), ".agentroam", "services");
|
|
17
|
+
const names = await readdir(directory).catch((error) => { if (error.code === "ENOENT")
|
|
18
|
+
return []; throw error; });
|
|
19
|
+
const candidates = [];
|
|
20
|
+
for (const name of names.filter((name) => name.endsWith(".json"))) {
|
|
21
|
+
try {
|
|
22
|
+
const d = JSON.parse(await readFile(join(directory, name), "utf8"));
|
|
23
|
+
const url = new URL(d.url);
|
|
24
|
+
if (d.protocol !== 1 || typeof d.instanceId !== "string" || !Number.isSafeInteger(d.pid) || d.pid <= 0 || ![resolve(dataDir), resolve(dataDir, "data")].includes(d.dataDir) || !/^[a-f0-9]{64}$/.test(d.token))
|
|
25
|
+
continue;
|
|
26
|
+
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || url.pathname !== "/" || url.username || url.password || url.search || url.hash)
|
|
27
|
+
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) });
|
|
29
|
+
if (!response.ok)
|
|
30
|
+
continue;
|
|
31
|
+
const identity = await response.json();
|
|
32
|
+
if (identity.protocol === 1 && identity.instanceId === d.instanceId && identity.dataDir === d.dataDir)
|
|
33
|
+
candidates.push(d);
|
|
34
|
+
}
|
|
35
|
+
catch { /* Stale descriptors are expected after a process crash. */ }
|
|
36
|
+
}
|
|
37
|
+
if (candidates.length !== 1)
|
|
38
|
+
throw new Error(candidates.length ? "多个服务使用此数据目录,请先停止多余实例" : "未找到运行中的 AgentRoam 服务,请先启动服务或指定 --data-dir");
|
|
39
|
+
const d = candidates[0];
|
|
40
|
+
const url = new URL(path, d.url);
|
|
41
|
+
if (url.origin !== new URL(d.url).origin)
|
|
42
|
+
throw new Error("无效的本机服务路径");
|
|
43
|
+
const response = await request(url, {
|
|
44
|
+
method: options.method ?? "GET",
|
|
45
|
+
headers: { "x-agentroam-desktop-token": d.token, "content-type": "application/json", origin: new URL(d.url).origin },
|
|
46
|
+
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
|
|
47
|
+
redirect: "error", signal: AbortSignal.timeout(options.timeoutMs ?? 5000),
|
|
48
|
+
});
|
|
49
|
+
if (!response.ok)
|
|
50
|
+
throw new LocalServiceHttpError(response.status);
|
|
51
|
+
return response.json();
|
|
52
|
+
}
|
package/dist/runtime-manager.js
CHANGED
|
@@ -7,6 +7,7 @@ import { resolveOpenCodeRuntime, } from "./opencode-runtime-manager.js";
|
|
|
7
7
|
import { resolvePlatformRuntime } from "./platform-packages.js";
|
|
8
8
|
import { findAvailablePort } from "./port.js";
|
|
9
9
|
import { ProcessSupervisor } from "./process-supervisor.js";
|
|
10
|
+
import { withCliProgress } from "../bin/cli-progress.mjs";
|
|
10
11
|
export class RuntimeManager {
|
|
11
12
|
supervisor;
|
|
12
13
|
codexResolver;
|
|
@@ -22,8 +23,8 @@ export class RuntimeManager {
|
|
|
22
23
|
const port = await findAvailablePort(options.port);
|
|
23
24
|
const dataDir = resolve(options.dataDir);
|
|
24
25
|
await Promise.all(["data", "bin", "cache", "logs"].map((name) => mkdir(resolve(dataDir, name), { recursive: true })));
|
|
25
|
-
const codexEnvironment = await prepareCodexRuntimeEnvironment(process.env, { dataDir, target }, this.codexResolver, this.report);
|
|
26
|
-
const childEnvironment = await prepareOpenCodeRuntimeEnvironment(codexEnvironment, { dataDir, target }, this.opencodeResolver, this.report);
|
|
26
|
+
const codexEnvironment = await withCliProgress("正在准备 Codex 组件", () => prepareCodexRuntimeEnvironment(process.env, { dataDir, target }, this.codexResolver, this.report));
|
|
27
|
+
const childEnvironment = await withCliProgress("正在准备 OpenCode 组件", () => prepareOpenCodeRuntimeEnvironment(codexEnvironment, { dataDir, target }, this.opencodeResolver, this.report));
|
|
27
28
|
const { runtimeRoot } = resolvePlatformRuntime(target);
|
|
28
29
|
await repairNativeRuntimePermissions(runtimeRoot);
|
|
29
30
|
const gateway = resolve(runtimeRoot, "ws-server.mjs");
|
|
@@ -53,7 +54,7 @@ export class RuntimeManager {
|
|
|
53
54
|
child.once("exit", (code, signal) => resolveExit({ code, signal }));
|
|
54
55
|
});
|
|
55
56
|
const localUrl = `http://127.0.0.1:${port}`;
|
|
56
|
-
const status = await waitForHealth(`${localUrl}/api/web-auth/status`, child, 20_000);
|
|
57
|
+
const status = await withCliProgress("正在等待本机服务就绪", () => waitForHealth(`${localUrl}/api/web-auth/status`, child, 20_000));
|
|
57
58
|
return {
|
|
58
59
|
port,
|
|
59
60
|
localUrl,
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import { lstat, readFile, readdir, realpath, rm } from "node:fs/promises";
|
|
2
2
|
import { relative, resolve, sep } from "node:path";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
async function processPathsInUse() {
|
|
7
|
+
// Windows has no reliable cwd inventory here: retain old installs instead.
|
|
8
|
+
if (process.platform === "win32")
|
|
9
|
+
throw new Error("Process paths unavailable");
|
|
10
|
+
const { stdout } = await execFileAsync(process.platform === "darwin" ? "/usr/sbin/lsof" : "lsof", ["-nP", "-d", "cwd,txt", "-Fn"], { timeout: 5_000, maxBuffer: 16 * 1024 * 1024 });
|
|
11
|
+
const paths = stdout.split("\n").filter(line => line.startsWith("n/")).map(line => line.slice(1));
|
|
12
|
+
if (!paths.length)
|
|
13
|
+
throw new Error("Incomplete process paths");
|
|
14
|
+
return [process.cwd(), ...paths];
|
|
15
|
+
}
|
|
3
16
|
function versionParts(version) {
|
|
4
17
|
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-preview\.(0|[1-9]\d*))?$/.exec(version);
|
|
5
18
|
return match ? [Number(match[1]), Number(match[2]), Number(match[3]), match[4] === undefined ? Infinity : Number(match[4])] : null;
|
|
@@ -18,7 +31,7 @@ function contains(parent, child) {
|
|
|
18
31
|
return path === "" || (!path.startsWith(`..${sep}`) && path !== ".." && !path.startsWith(sep));
|
|
19
32
|
}
|
|
20
33
|
/** Remove only recognized managed launcher versions after the replacement service is ready. */
|
|
21
|
-
export async function cleanupOldLaunchers(config, log) {
|
|
34
|
+
export async function cleanupOldLaunchers(config, log, context = {}) {
|
|
22
35
|
try {
|
|
23
36
|
const dataDir = await realpath(config.dataDir);
|
|
24
37
|
const launcherDir = resolve(dataDir, "launcher");
|
|
@@ -47,6 +60,11 @@ export async function cleanupOldLaunchers(config, log) {
|
|
|
47
60
|
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
48
61
|
if (manifest.name !== "agentroam" || manifest.version !== entry.name)
|
|
49
62
|
continue;
|
|
63
|
+
const usedPaths = await (context.pathsInUse ?? processPathsInUse)();
|
|
64
|
+
if (usedPaths.some(path => contains(candidate, path))) {
|
|
65
|
+
log(`旧版 CLI ${entry.name} 仍被进程使用,已保留。`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
50
68
|
await rm(candidate, { recursive: true });
|
|
51
69
|
log(`✓ 已清理旧版 CLI ${entry.name},配置和数据已保留`);
|
|
52
70
|
}
|
|
@@ -60,13 +60,13 @@ export class MacLaunchAgent {
|
|
|
60
60
|
const status = await this.status();
|
|
61
61
|
if (!status.installed)
|
|
62
62
|
throw new Error("AgentRoam service is not installed");
|
|
63
|
+
const paths = resolveServicePaths(this.homeDir, status.config?.dataDir);
|
|
63
64
|
if (status.running)
|
|
64
|
-
return
|
|
65
|
+
return this.waitForReady(paths);
|
|
65
66
|
if (status.loaded)
|
|
66
67
|
await this.runRequired("launchctl", ["kickstart", this.serviceTarget]);
|
|
67
68
|
else
|
|
68
69
|
await this.bootstrap(status.definition);
|
|
69
|
-
const paths = resolveServicePaths(this.homeDir, status.config?.dataDir);
|
|
70
70
|
return this.waitForReady(paths, status.state?.pid);
|
|
71
71
|
}
|
|
72
72
|
async stop() {
|
|
@@ -84,13 +84,14 @@ export class MacLaunchAgent {
|
|
|
84
84
|
const config = await readServiceConfig(basePaths);
|
|
85
85
|
const paths = resolveServicePaths(this.homeDir, config?.dataDir);
|
|
86
86
|
const installed = await pathExists(paths.plistPath);
|
|
87
|
-
const job = installed ? await this.inspectJob() : { loaded: false, running: false };
|
|
87
|
+
const job = installed ? await this.inspectJob() : { loaded: false, running: false, pid: undefined };
|
|
88
|
+
const state = await readServiceState(paths);
|
|
88
89
|
return {
|
|
89
90
|
installed,
|
|
90
91
|
loaded: job.loaded,
|
|
91
92
|
running: job.running,
|
|
92
93
|
config,
|
|
93
|
-
state:
|
|
94
|
+
state: job.running && state?.pid !== job.pid ? null : state,
|
|
94
95
|
definition: paths.plistPath,
|
|
95
96
|
};
|
|
96
97
|
}
|
|
@@ -157,9 +158,11 @@ export class MacLaunchAgent {
|
|
|
157
158
|
}
|
|
158
159
|
async inspectJob() {
|
|
159
160
|
const result = await this.runner("launchctl", ["print", this.serviceTarget]);
|
|
161
|
+
const pid = /\bpid\s*=\s*(\d+)\b/.exec(result.stdout);
|
|
160
162
|
return {
|
|
161
163
|
loaded: result.code === 0,
|
|
162
164
|
running: result.code === 0 && /\bstate\s*=\s*running\b/.test(result.stdout),
|
|
165
|
+
pid: pid ? Number(pid[1]) : undefined,
|
|
163
166
|
};
|
|
164
167
|
}
|
|
165
168
|
async bootstrap(definition) {
|
|
@@ -195,10 +198,13 @@ export class MacLaunchAgent {
|
|
|
195
198
|
const deadline = Date.now() + this.readyTimeoutMs;
|
|
196
199
|
do {
|
|
197
200
|
const state = await readServiceState(paths);
|
|
198
|
-
if (state?.status === "ready" && (!previousPid || state.pid !== previousPid))
|
|
199
|
-
|
|
201
|
+
if (state?.status === "ready" && state.accessUrl && (!previousPid || state.pid !== previousPid)) {
|
|
202
|
+
const job = await this.inspectJob();
|
|
203
|
+
if (job.running && job.pid === state.pid)
|
|
204
|
+
return state;
|
|
205
|
+
}
|
|
200
206
|
if (Date.now() >= deadline)
|
|
201
|
-
return state;
|
|
207
|
+
return state?.status === "ready" ? null : state;
|
|
202
208
|
await delay(this.pollIntervalMs);
|
|
203
209
|
} while (true);
|
|
204
210
|
}
|
|
@@ -9,6 +9,8 @@ import { WindowsTaskService } from "./windows-task-service.js";
|
|
|
9
9
|
import { assertSupportedNodeVersion } from "../../bin/runtime-policy.mjs";
|
|
10
10
|
import { cleanupOldLaunchers } from "./launcher-cleanup.js";
|
|
11
11
|
import { setupCodexDesktop } from "../codex-desktop-setup.js";
|
|
12
|
+
import { authorizeRemoteDesktop } from "../desktop-authorization.js";
|
|
13
|
+
import { withCliProgress } from "../../bin/cli-progress.mjs";
|
|
12
14
|
export async function runServiceCommand(options, context) {
|
|
13
15
|
const platform = context.platform ?? process.platform;
|
|
14
16
|
const log = context.log ?? console.log;
|
|
@@ -30,14 +32,14 @@ export async function runServiceCommand(options, context) {
|
|
|
30
32
|
const environmentPath = buildServiceEnvironmentPath(context.nodePath, environment.PATH ?? environment.Path, platform);
|
|
31
33
|
let codexPath;
|
|
32
34
|
try {
|
|
33
|
-
const codex = await (context.codexResolver ?? resolveCodexRuntime)({
|
|
35
|
+
const codex = await withCliProgress("正在检查和安装 Codex 组件", () => (context.codexResolver ?? resolveCodexRuntime)({
|
|
34
36
|
dataDir: options.dataDir,
|
|
35
37
|
target: detectPlatform(platform, context.arch ?? process.arch, nodeVersion),
|
|
36
38
|
environment: { ...environment, PATH: environmentPath },
|
|
37
39
|
platform,
|
|
38
40
|
nodeExecutable: context.nodePath,
|
|
39
41
|
onProgress: (message) => log(`… ${message}`),
|
|
40
|
-
});
|
|
42
|
+
}));
|
|
41
43
|
codexPath = codex.executable;
|
|
42
44
|
}
|
|
43
45
|
catch (error) {
|
|
@@ -58,36 +60,47 @@ export async function runServiceCommand(options, context) {
|
|
|
58
60
|
dataDir: options.dataDir,
|
|
59
61
|
installedAt: now.toISOString(),
|
|
60
62
|
};
|
|
61
|
-
const { definition, state } = await controller.install(config);
|
|
63
|
+
const { definition, state } = await withCliProgress("正在安装后台服务,等待服务和手机访问连接就绪", () => controller.install(config));
|
|
62
64
|
log(`✓ AgentRoam service installed: ${definition}`);
|
|
65
|
+
if (state?.localUrl && state.status !== "stopped")
|
|
66
|
+
printLocalDesktopUrl(state.localUrl, log, platform);
|
|
67
|
+
requireReadyService(state);
|
|
68
|
+
if (state?.localUrl && state.status !== "stopped" && (platform === "darwin" || platform === "win32")) {
|
|
69
|
+
if (environment.AGENTROAM_INSTALL_REMOTE_DESKTOP === "skip") {
|
|
70
|
+
log("已跳过安装时远程桌面授权,可稍后执行 agentroam desktop authorize。");
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
try {
|
|
74
|
+
await (context.desktopAuthorization ?? authorizeRemoteDesktop)(options.dataDir, { platform, isTTY: stdoutIsTTY, log });
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
log(`远程桌面授权未完成:${error instanceof Error ? error.message : error}`);
|
|
78
|
+
log("AgentRoam 安装继续;可稍后执行 agentroam desktop authorize,或在本机浏览器的“远程授权”面板中重试。");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
63
82
|
if (platform === "darwin" && !options.desktopSetup)
|
|
64
83
|
log("官方 Codex 桌面共享后端:agentroam codex-desktop --setup;预演:agentroam codex-desktop --dry-run。");
|
|
65
|
-
if (state
|
|
84
|
+
if (state.version === config.version)
|
|
66
85
|
await cleanupOldLaunchers(config, log);
|
|
67
|
-
if (state?.localUrl && state.status !== "stopped")
|
|
68
|
-
printLocalDesktopUrl(state.localUrl, log, platform);
|
|
69
86
|
if (options.desktopSetup && platform === "darwin") {
|
|
70
|
-
await (context.desktopSetup ?? setupCodexDesktop)(options, { platform, environment, service: config, log });
|
|
71
|
-
}
|
|
72
|
-
if (state?.status === "ready" && state.accessUrl) {
|
|
73
|
-
log(`Open: ${state.accessUrl}`);
|
|
74
|
-
await (context.pairingPrinter ?? printPairingCode)(options.dataDir, log, { accessUrl: state.accessUrl, qr: options.qr, interactive: stdoutIsTTY });
|
|
87
|
+
await withCliProgress("正在配置官方 Codex 桌面共享连接", () => (context.desktopSetup ?? setupCodexDesktop)(options, { platform, environment, service: config, log }));
|
|
75
88
|
}
|
|
76
|
-
|
|
77
|
-
|
|
89
|
+
log(`Open: ${state.accessUrl}`);
|
|
90
|
+
await (context.pairingPrinter ?? printPairingCode)(options.dataDir, log, { accessUrl: state.accessUrl, qr: options.qr, interactive: stdoutIsTTY });
|
|
78
91
|
return;
|
|
79
92
|
}
|
|
80
93
|
case "start": {
|
|
81
|
-
const state = await controller.start();
|
|
82
|
-
log("✓ AgentRoam service started");
|
|
83
|
-
if (state?.status === "ready" && state.accessUrl)
|
|
84
|
-
await logAccessUrl(state.accessUrl);
|
|
94
|
+
const state = await withCliProgress("正在启动后台服务,等待服务和手机访问连接就绪", () => controller.start());
|
|
85
95
|
if (state?.localUrl && state.status !== "stopped")
|
|
86
96
|
printLocalDesktopUrl(state.localUrl, log, platform);
|
|
97
|
+
requireReadyService(state);
|
|
98
|
+
log("✓ AgentRoam service started");
|
|
99
|
+
await logAccessUrl(state.accessUrl);
|
|
87
100
|
return;
|
|
88
101
|
}
|
|
89
102
|
case "stop":
|
|
90
|
-
await controller.stop();
|
|
103
|
+
await withCliProgress("正在停止后台服务", () => controller.stop());
|
|
91
104
|
log("✓ AgentRoam service stopped");
|
|
92
105
|
return;
|
|
93
106
|
case "status": {
|
|
@@ -122,12 +135,12 @@ export async function runServiceCommand(options, context) {
|
|
|
122
135
|
return;
|
|
123
136
|
}
|
|
124
137
|
case "restart": {
|
|
125
|
-
const state = await controller.restart();
|
|
126
|
-
log("✓ AgentRoam service restarted");
|
|
127
|
-
if (state?.status === "ready" && state.accessUrl)
|
|
128
|
-
await logAccessUrl(state.accessUrl);
|
|
138
|
+
const state = await withCliProgress("正在重启后台服务,等待服务和手机访问连接就绪", () => controller.restart());
|
|
129
139
|
if (state?.localUrl && state.status !== "stopped")
|
|
130
140
|
printLocalDesktopUrl(state.localUrl, log, platform);
|
|
141
|
+
requireReadyService(state);
|
|
142
|
+
log("✓ AgentRoam service restarted");
|
|
143
|
+
await logAccessUrl(state.accessUrl);
|
|
131
144
|
return;
|
|
132
145
|
}
|
|
133
146
|
case "uninstall": {
|
|
@@ -140,6 +153,11 @@ export async function runServiceCommand(options, context) {
|
|
|
140
153
|
throw cliError("service action is required");
|
|
141
154
|
}
|
|
142
155
|
}
|
|
156
|
+
function requireReadyService(state) {
|
|
157
|
+
if (state?.status !== "ready" || !state.accessUrl) {
|
|
158
|
+
throw Object.assign(new Error(`AgentRoam 后台服务未就绪(${state?.status ?? "未取得状态"})。请运行 agentroam service logs 查看原因,修复后运行 agentroam service start,再执行 agentroam pair。`), { exitCode: 1 });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
143
161
|
export function buildServiceEnvironmentPath(nodePath, pathValue, platform) {
|
|
144
162
|
const separator = platform === "win32" ? ";" : delimiter;
|
|
145
163
|
const nodeDirectory = platform === "win32" ? win32.dirname(nodePath) : dirname(nodePath);
|
|
@@ -55,9 +55,9 @@ export class WindowsTaskService {
|
|
|
55
55
|
const status = await this.status();
|
|
56
56
|
if (!status.installed)
|
|
57
57
|
throw new Error("AgentRoam service is not installed");
|
|
58
|
-
if (status.running)
|
|
59
|
-
return status.state;
|
|
60
58
|
const paths = resolveServicePaths(this.homeDir, status.config?.dataDir);
|
|
59
|
+
if (status.running)
|
|
60
|
+
return this.waitForReady(paths);
|
|
61
61
|
await Promise.all([removeIfExists(paths.statePath), removeIfExists(paths.urlPath)]);
|
|
62
62
|
await this.runRequired(buildStartTaskScript());
|
|
63
63
|
return this.waitForReady(paths, status.state?.pid);
|
|
@@ -88,12 +88,13 @@ export class WindowsTaskService {
|
|
|
88
88
|
const config = await readServiceConfig(basePaths);
|
|
89
89
|
const paths = resolveServicePaths(this.homeDir, config?.dataDir);
|
|
90
90
|
const task = await this.inspectTask();
|
|
91
|
+
const state = await readServiceState(paths);
|
|
91
92
|
return {
|
|
92
93
|
installed: task.installed,
|
|
93
94
|
loaded: task.installed,
|
|
94
95
|
running: task.running,
|
|
95
96
|
config,
|
|
96
|
-
state:
|
|
97
|
+
state: task.running && state && !this.processExists(state.pid) ? null : state,
|
|
97
98
|
definition: WINDOWS_SERVICE_NAME,
|
|
98
99
|
};
|
|
99
100
|
}
|
|
@@ -185,10 +186,13 @@ export class WindowsTaskService {
|
|
|
185
186
|
const deadline = Date.now() + this.readyTimeoutMs;
|
|
186
187
|
do {
|
|
187
188
|
const state = await readServiceState(paths);
|
|
188
|
-
if (state?.status === "ready" && (!previousPid || state.pid !== previousPid))
|
|
189
|
-
|
|
189
|
+
if (state?.status === "ready" && state.accessUrl && (!previousPid || state.pid !== previousPid)) {
|
|
190
|
+
const task = await this.inspectTask();
|
|
191
|
+
if (task.running && this.processExists(state.pid))
|
|
192
|
+
return state;
|
|
193
|
+
}
|
|
190
194
|
if (Date.now() >= deadline)
|
|
191
|
-
return state;
|
|
195
|
+
return state?.status === "ready" ? null : state;
|
|
192
196
|
await delay(this.pollIntervalMs);
|
|
193
197
|
} while (true);
|
|
194
198
|
}
|
package/dist/unlock-service.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
const SERVICE_NAME = "AgentRoamUnlock";
|
|
4
4
|
const DISPLAY_NAME = "AgentRoam Remote Unlock";
|
|
5
|
+
// Keep the event loop free while PowerShell waits for UAC or service control.
|
|
6
|
+
const runFile = (file, args, options) => new Promise((resolveResult) => {
|
|
7
|
+
execFile(file, [...args], options, (error, stdout, stderr) => {
|
|
8
|
+
resolveResult({ stdout, stderr, status: error ? 1 : 0,
|
|
9
|
+
...(error && typeof error.code !== "number" ? { error } : {}),
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
});
|
|
5
13
|
function psQuote(value) {
|
|
6
14
|
return `'${value.replaceAll("'", "''")}'`;
|
|
7
15
|
}
|
|
@@ -59,36 +67,36 @@ function runPowerShell(script, runner) {
|
|
|
59
67
|
"-EncodedCommand", encodePowerShell(script),
|
|
60
68
|
], { encoding: "utf8", windowsHide: true });
|
|
61
69
|
}
|
|
62
|
-
export function queryUnlockServiceStatus(runner =
|
|
70
|
+
export async function queryUnlockServiceStatus(runner = runFile) {
|
|
63
71
|
const script = `$service = Get-Service -Name '${SERVICE_NAME}' -ErrorAction SilentlyContinue
|
|
64
72
|
if (-not $service) { Write-Output 'missing' } elseif ($service.Status -eq 'Running') { Write-Output 'running' } else { Write-Output 'stopped' }
|
|
65
73
|
`;
|
|
66
|
-
const result = runPowerShell(script, runner);
|
|
74
|
+
const result = await runPowerShell(script, runner);
|
|
67
75
|
if (result.error)
|
|
68
76
|
throw result.error;
|
|
69
77
|
if (result.status !== 0)
|
|
70
78
|
throw new Error((result.stderr || "远程解锁服务状态查询失败").trim());
|
|
71
79
|
return parseUnlockServiceStatus(result.stdout || "");
|
|
72
80
|
}
|
|
73
|
-
export function runUnlockServiceCommand(action, runtimeRoot, options = {}) {
|
|
81
|
+
export async function runUnlockServiceCommand(action, runtimeRoot, options = {}) {
|
|
74
82
|
const platform = options.platform ?? process.platform;
|
|
75
|
-
const runner = options.runner ??
|
|
83
|
+
const runner = options.runner ?? runFile;
|
|
76
84
|
const log = options.log ?? console.log;
|
|
77
85
|
if (platform !== "win32")
|
|
78
86
|
throw new Error("远程解锁服务仅支持 Windows 10/11 x64");
|
|
79
87
|
if (action === "status") {
|
|
80
|
-
const status = queryUnlockServiceStatus(runner);
|
|
88
|
+
const status = await queryUnlockServiceStatus(runner);
|
|
81
89
|
log(status === "running" ? "远程解锁服务正在运行" : status === "stopped" ? "远程解锁服务已安装但未运行" : "远程解锁服务未安装");
|
|
82
90
|
return;
|
|
83
91
|
}
|
|
84
92
|
const executable = unlockServiceExecutable(runtimeRoot);
|
|
85
|
-
const result = runPowerShell(buildElevatedPowerShell(buildUnlockServiceAdminScript(action, executable)), runner);
|
|
93
|
+
const result = await runPowerShell(buildElevatedPowerShell(buildUnlockServiceAdminScript(action, executable)), runner);
|
|
86
94
|
if (result.error)
|
|
87
95
|
throw result.error;
|
|
88
96
|
if (result.status !== 0) {
|
|
89
97
|
throw new Error((result.stderr || (action === "install" ? "远程解锁服务安装失败或已取消管理员授权" : "远程解锁服务卸载失败或已取消管理员授权")).trim());
|
|
90
98
|
}
|
|
91
|
-
const status = queryUnlockServiceStatus(runner);
|
|
99
|
+
const status = await queryUnlockServiceStatus(runner);
|
|
92
100
|
if (action === "install" && status !== "running")
|
|
93
101
|
throw new Error("远程解锁服务安装后未能启动");
|
|
94
102
|
if (action === "uninstall" && status !== "missing")
|
|
@@ -232,7 +232,7 @@ if ($env:AGENTROAM_INSTALL_SKIP_SERVICE -eq "1") {
|
|
|
232
232
|
Write-Host "AgentRoam service registration skipped for isolated verification."
|
|
233
233
|
} else {
|
|
234
234
|
& $NodeBin $Entry service install --root $ServiceRoot --data-dir $DataDir
|
|
235
|
-
if ($LASTEXITCODE -ne 0) { throw "
|
|
235
|
+
if ($LASTEXITCODE -ne 0) { throw "CLI 已安装,但后台服务安装或启动失败(exit $LASTEXITCODE),尚不能扫码连接。请运行 agentroam service logs 查看原因,修复后运行 agentroam service start,再执行 agentroam pair。" }
|
|
236
236
|
}
|
|
237
237
|
Write-Host "`nAgentRoam $AgentRoamVersion installed: $WrapperPath"
|
|
238
238
|
Write-Host "Open a new terminal, then run: agentroam service status"
|