agent-hub-runner 0.1.2 → 0.1.3
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/ops.mjs +44 -7
- package/package.json +27 -5
- package/runner.mjs +73 -6
package/ops.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @意图#R636(2026-10-27): 运行器在你电脑上真正执行 read / write / edit / bash。路径只在这里判断(S6 结论:服务器不解析用户电脑的路径):
|
|
2
2
|
// 先按授权目录换算成真实路径(解开符号链接,包括指向不存在目标的符号链接),不在授权目录里的一律拒绝;bash 的工作目录就是授权目录。
|
|
3
3
|
// 第一轮没有沙箱(king 定的范围):bash 里的命令本身能碰到目录外的东西,只能靠你看清批准卡片再点。
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
5
|
import fs from "node:fs/promises";
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
@@ -68,7 +68,7 @@ function imageMime(buf) {
|
|
|
68
68
|
export async function opRead(args, root) {
|
|
69
69
|
const real = await resolveInside(args.path, root);
|
|
70
70
|
const st = await fs.stat(real);
|
|
71
|
-
if (st.isDirectory()) throw new Error(`${args.path} 是目录,不是文件(看目录里有什么用 bash 的 ls)`);
|
|
71
|
+
if (st.isDirectory()) throw new Error(`${args.path} 是目录,不是文件(看目录里有什么用 ${process.platform === "win32" ? "PowerShell 的 Get-ChildItem" : "bash 的 ls"})`);
|
|
72
72
|
if (st.size > MAX_READ_BYTES) throw new Error(`${args.path} 太大(${st.size} 字节),超过 ${MAX_READ_BYTES} 字节;用 bash 的 head/sed 看一部分`);
|
|
73
73
|
const buf = await fs.readFile(real);
|
|
74
74
|
const mime = imageMime(buf);
|
|
@@ -102,18 +102,54 @@ export async function opEdit(args, root, { onChange } = {}) {
|
|
|
102
102
|
return { replaced: args.edits.length };
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
function killGroup(child) {
|
|
105
|
+
function killGroup(child, platform = process.platform) {
|
|
106
106
|
if (!child.pid) return;
|
|
107
|
+
// @意图#R721(2026-11-02): Windows 没有负进程组,要 taskkill /T /F 才能连子进程一起杀
|
|
108
|
+
if (platform === "win32") {
|
|
109
|
+
try { spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }); } catch { /* 进程已经没了 */ }
|
|
110
|
+
try { child.kill("SIGKILL"); } catch { /* ignore */ }
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
107
113
|
try { process.kill(-child.pid, "SIGKILL"); } catch { /* 进程组已经没了 */ }
|
|
108
114
|
try { child.kill("SIGKILL"); } catch { /* ignore */ }
|
|
109
115
|
}
|
|
110
116
|
|
|
117
|
+
/**
|
|
118
|
+
* 按平台选执行器。
|
|
119
|
+
* Windows 上没有 bash:用 PowerShell,整段脚本转 UTF-16LE base64 传给 -EncodedCommand。
|
|
120
|
+
* 这样一次解决两个实现时踩到的坑:① 中文参数走 -Command 会按 ANSI 代码页解码成乱码;
|
|
121
|
+
* ② powershell -Command 的退出码恒为 0,得靠脚本尾巴的 exit $LASTEXITCODE 才拿得到真实结果。
|
|
122
|
+
*/
|
|
123
|
+
// @意图#R721(2026-11-02): Windows 用 PowerShell 跑命令(客户是小白,不能要求装 Git Bash)
|
|
124
|
+
export function shellFor(command, platform = process.platform) {
|
|
125
|
+
if (platform === "win32") {
|
|
126
|
+
// $ProgressPreference 必须关:不加的话 PowerShell 会把进度流序列化成 #< CLIXML ... 写到 stderr,模型会看到一堆垃圾。
|
|
127
|
+
// `2>&1 | ForEach-Object { "$_" }`:错误也要转成纯字符串,否则 ErrorRecord 会被序列化成 CLIXML;
|
|
128
|
+
// 用管道逐条处理,保持边跑边输出(不缓冲)。
|
|
129
|
+
const script = [
|
|
130
|
+
"$ProgressPreference = 'SilentlyContinue'",
|
|
131
|
+
"[Console]::OutputEncoding=[Text.Encoding]::UTF8",
|
|
132
|
+
"$OutputEncoding=[Text.Encoding]::UTF8",
|
|
133
|
+
"& {",
|
|
134
|
+
command,
|
|
135
|
+
'} 2>&1 | ForEach-Object { "$_" }',
|
|
136
|
+
"exit $LASTEXITCODE",
|
|
137
|
+
"",
|
|
138
|
+
].join("\n");
|
|
139
|
+
return {
|
|
140
|
+
file: "powershell.exe",
|
|
141
|
+
args: ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-OutputFormat", "Text", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return { file: "bash", args: ["-c", command] };
|
|
145
|
+
}
|
|
146
|
+
|
|
111
147
|
/**
|
|
112
148
|
* bash:在授权目录里用你终端的环境变量跑(服务器的环境变量不下发)。输出边跑边交给 onData;
|
|
113
149
|
* 超时或 signal 中止时杀掉整个进程组。返回 { exitCode, timedOut, aborted }。
|
|
114
150
|
*/
|
|
115
151
|
// @意图#R636(2026-10-27): 跑命令(已经过批准,只执行这一次),有超时,超时杀进程
|
|
116
|
-
export function opBash(args, root, { onData, signal } = {}) {
|
|
152
|
+
export function opBash(args, root, { onData, signal, platform = process.platform } = {}) {
|
|
117
153
|
const command = args.command;
|
|
118
154
|
if (typeof command !== "string" || !command.trim()) return Promise.reject(new Error("command 是空的"));
|
|
119
155
|
let timeoutS = DEFAULT_BASH_TIMEOUT_S;
|
|
@@ -122,12 +158,13 @@ export function opBash(args, root, { onData, signal } = {}) {
|
|
|
122
158
|
if (!Number.isFinite(t) || t <= 0) return Promise.reject(new Error("Invalid timeout: must be a finite number of seconds"));
|
|
123
159
|
timeoutS = t;
|
|
124
160
|
}
|
|
161
|
+
const shell = shellFor(command, platform);
|
|
125
162
|
return new Promise((resolve, reject) => {
|
|
126
|
-
const child = spawn(
|
|
163
|
+
const child = spawn(shell.file, shell.args, { cwd: root, detached: platform !== "win32", stdio: ["ignore", "pipe", "pipe"], env: process.env, windowsHide: true });
|
|
127
164
|
let timedOut = false;
|
|
128
165
|
let aborted = false;
|
|
129
|
-
const timer = setTimeout(() => { timedOut = true; killGroup(child); }, timeoutS * 1000);
|
|
130
|
-
const onAbort = () => { aborted = true; killGroup(child); };
|
|
166
|
+
const timer = setTimeout(() => { timedOut = true; killGroup(child, platform); }, timeoutS * 1000);
|
|
167
|
+
const onAbort = () => { aborted = true; killGroup(child, platform); };
|
|
131
168
|
if (signal) {
|
|
132
169
|
if (signal.aborted) onAbort();
|
|
133
170
|
else signal.addEventListener("abort", onAbort, { once: true });
|
package/package.json
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub-runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "agent-hub 的电脑运行器:配对后让你的 bot 在这台电脑上读写授权目录里的文件、跑命令(跑命令要你在网页上批准)",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
6
|
+
"bin": {
|
|
7
|
+
"agent-hub-runner": "bin/agent-hub-runner.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"runner.mjs",
|
|
11
|
+
"ops.mjs",
|
|
12
|
+
"edit.mjs",
|
|
13
|
+
"changes.mjs",
|
|
14
|
+
"autostart.mjs",
|
|
15
|
+
"bin/agent-hub-runner.mjs",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/cs1145994973/agent-hub-runner.git"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"provenance": true
|
|
31
|
+
}
|
|
10
32
|
}
|
package/runner.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import fs from "node:fs/promises";
|
|
|
23
23
|
import os from "node:os";
|
|
24
24
|
import path from "node:path";
|
|
25
25
|
import readline from "node:readline/promises";
|
|
26
|
-
import { execFile } from "node:child_process";
|
|
26
|
+
import { execFile, spawn, spawnSync } from "node:child_process";
|
|
27
27
|
import { fileURLToPath } from "node:url";
|
|
28
28
|
import { runOp } from "./ops.mjs";
|
|
29
29
|
import { buildChangeRecord, changesDirFor, CHANGE_ID_RE, createChangeStore } from "./changes.mjs";
|
|
@@ -98,10 +98,19 @@ function checkNode() {
|
|
|
98
98
|
|
|
99
99
|
export const PICK_PROMPT = "选择要授权给 bot 的文件夹";
|
|
100
100
|
export const PICK_SCRIPT = `POSIX path of (choose folder with prompt "${PICK_PROMPT}")`;
|
|
101
|
+
/** Windows 上的弹窗脚本:系统自带的 .NET 文件夹选择框,不需要任何额外安装。 */
|
|
102
|
+
export const PICK_SCRIPT_WIN = [
|
|
103
|
+
"[Console]::OutputEncoding=[Text.Encoding]::UTF8",
|
|
104
|
+
"Add-Type -AssemblyName System.Windows.Forms",
|
|
105
|
+
"$d = New-Object System.Windows.Forms.FolderBrowserDialog",
|
|
106
|
+
`$d.Description = '${PICK_PROMPT}'`,
|
|
107
|
+
"$d.ShowNewFolderButton = $true",
|
|
108
|
+
"if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($d.SelectedPath) }",
|
|
109
|
+
].join("\n");
|
|
101
110
|
/** R640:这个 bot 在对照表里没有目录时,调用一律回这句。 */
|
|
102
111
|
// @意图#R666(2026-10-29): 「右栏」是 R663 以前的位置,改成输入框下面「我的电脑」那一行(和服务器 devices.ts 的 NO_DIR 一字不差)
|
|
103
112
|
export const NO_DIR = "还没给这个 bot 选目录,请在这个 bot 的私聊里点输入框下面「我的电脑」那一行,再点「选择目录」";
|
|
104
|
-
export const PICK_ONLY_MAC = "只支持 Mac:选目录的窗口只在 Mac 上弹";
|
|
113
|
+
export const PICK_ONLY_MAC = "只支持 Mac 和 Windows:选目录的窗口只在 Mac / Windows 上弹";
|
|
105
114
|
/** R640:pair 带了 --dir 时的提示(不报错,照常配对,目录不用)。 */
|
|
106
115
|
export const PAIR_DIR_IGNORED = "目录请连上后在每个 bot 的私聊里选(--dir 已不用,这次没用它)";
|
|
107
116
|
|
|
@@ -138,6 +147,43 @@ export function pickDirMac({ signal } = {}) {
|
|
|
138
147
|
|
|
139
148
|
/** 检查授权目录:存在、换成真实路径、是目录、不是磁盘根目录。返回真实路径。 */
|
|
140
149
|
// @意图#R640(2026-10-27): 每个 bot 选的目录都走这套检查
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Windows 上弹系统「选文件夹」窗口(.NET FolderBrowserDialog,系统自带,不用装任何东西)。
|
|
153
|
+
* 选了返回路径;点取消返回 null;signal 中止时 taskkill 关掉窗口并抛 AbortError;弹不出来(没有桌面会话)抛错。
|
|
154
|
+
*/
|
|
155
|
+
// @意图#R716(2026-11-02): Windows 客户是小白,不能要求装 Git Bash,也不能靠手填路径;弹窗用系统自带的 .NET 窗口
|
|
156
|
+
export function pickDirWindows({ signal, spawn: spawnFn = spawn } = {}) {
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
const b64 = Buffer.from(PICK_SCRIPT_WIN + "\n", "utf16le").toString("base64");
|
|
159
|
+
// 带 -STA:FolderBrowserDialog 要求单线程公寓;不带 -NonInteractive:要的就是交互窗口
|
|
160
|
+
const child = spawnFn("powershell.exe", ["-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-OutputFormat", "Text", "-EncodedCommand", b64], {
|
|
161
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
162
|
+
windowsHide: true,
|
|
163
|
+
});
|
|
164
|
+
let out = "";
|
|
165
|
+
let err = "";
|
|
166
|
+
let settled = false;
|
|
167
|
+
const settle = (fn, v) => { if (!settled) { settled = true; signal?.removeEventListener("abort", onAbort); fn(v); } };
|
|
168
|
+
const killIt = () => {
|
|
169
|
+
if (child.pid) { try { spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }); } catch { /* 已退出 */ } }
|
|
170
|
+
try { child.kill("SIGKILL"); } catch { /* ignore */ }
|
|
171
|
+
};
|
|
172
|
+
const onAbort = () => { killIt(); settle(reject, Object.assign(new Error("已中止"), { name: "AbortError" })); };
|
|
173
|
+
if (signal) { if (signal.aborted) return onAbort(); signal.addEventListener("abort", onAbort, { once: true }); }
|
|
174
|
+
child.stdout?.on("data", (d) => { out += d.toString("utf8"); });
|
|
175
|
+
child.stderr?.on("data", (d) => { err += d.toString("utf8"); });
|
|
176
|
+
child.on("error", (e) => settle(reject, new Error(`弹不出选文件夹的窗口(${e?.message ?? e})`)));
|
|
177
|
+
child.on("close", () => {
|
|
178
|
+
const picked = out.trim();
|
|
179
|
+
if (picked) return settle(resolve, picked);
|
|
180
|
+
const why = err.trim().split("\n")[0].trim();
|
|
181
|
+
if (why) return settle(reject, new Error(`弹不出选文件夹的窗口(${why})`));
|
|
182
|
+
settle(resolve, null); // 点了取消:对话框没点确定,没有输出
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
141
187
|
// @意图#R639(2026-10-27): 配对时的 --dir 和网页上选的目录走同一套检查
|
|
142
188
|
export async function checkDir(dir) {
|
|
143
189
|
const real = await fs.realpath(path.resolve(dir)).catch(() => { throw new Error(`授权目录不存在:${dir}`); });
|
|
@@ -257,7 +303,7 @@ const wsUrlOf = (server) => `${server.replace(/^http/, "ws")}/devices/ws`;
|
|
|
257
303
|
// @意图#R639(2026-10-27已废弃): 目录不再是连接时固定的常量;没目录时 call 一律拒绝;pick_dir 选完检查、写回配置(600)、回 dir_result(R640 改成按 bot 分)
|
|
258
304
|
// @意图#R690(2026-11-01): write / edit 带 change_id(服务器记灰字时生成的 auto_id)就在写改成功后存一份改动记录(存失败只记日志);
|
|
259
305
|
// 新消息 change_get {id, change_id} → change_result {id, ok:true, found, record},不看这个 bot 有没有目录(换了目录也能看旧记录)
|
|
260
|
-
export function createSession(cfg, { log = say,
|
|
306
|
+
export function createSession(cfg, { log = say, platform = process.platform, pickDir = platform === "win32" ? pickDirWindows : pickDirMac, file = configPath(), changes } = {}) {
|
|
261
307
|
let dirs = botDirsOf(cfg); // bot_id → 授权目录
|
|
262
308
|
const jobs = new Map(); // 调用 id → AbortController
|
|
263
309
|
const picks = new Map(); // 弹窗 id → AbortController
|
|
@@ -300,7 +346,7 @@ export function createSession(cfg, { log = say, pickDir = pickDirMac, platform =
|
|
|
300
346
|
const { id } = msg;
|
|
301
347
|
const bot = typeof msg.bot_id === "string" ? msg.bot_id : "";
|
|
302
348
|
if (!bot) { send({ type: "dir_result", id, ok: false, error: "缺 bot_id" }); return; }
|
|
303
|
-
if (platform !== "darwin") { send({ type: "dir_result", id, ok: false, bot_id: bot, error: PICK_ONLY_MAC }); return; }
|
|
349
|
+
if (platform !== "darwin" && platform !== "win32") { send({ type: "dir_result", id, ok: false, bot_id: bot, error: PICK_ONLY_MAC }); return; }
|
|
304
350
|
const ac = new AbortController();
|
|
305
351
|
picks.set(id, ac);
|
|
306
352
|
log(`网页上给 bot ${bot} 点了选择目录:请在弹出的窗口里选文件夹`);
|
|
@@ -353,7 +399,7 @@ export function createSession(cfg, { log = say, pickDir = pickDirMac, platform =
|
|
|
353
399
|
// @意图#R640(2026-10-27): hello 报 bot→目录 对照表(bot_dirs),不再报单一 dir
|
|
354
400
|
// @意图#R639(2026-10-27): 没目录也能连上;目录由 createSession 管(网页上选完马上生效),重连时 hello 带当前目录
|
|
355
401
|
// @意图#R636(2026-10-27): 前台运行、心跳、执行工具调用;断线时杀掉正在跑的命令,重连后不重放
|
|
356
|
-
export function startRunner(cfg, { log = say, retryMs = 2000,
|
|
402
|
+
export function startRunner(cfg, { log = say, retryMs = 2000, platform = process.platform, pickDir = platform === "win32" ? pickDirWindows : pickDirMac, file = configPath(), background = process.env.AGENT_RUNNER_LAUNCHD === "1" } = {}) {
|
|
357
403
|
checkNode();
|
|
358
404
|
let fails = 0;
|
|
359
405
|
const session = createSession(cfg, { log, pickDir, platform, file });
|
|
@@ -512,10 +558,11 @@ const usage = (prog) => `用法:
|
|
|
512
558
|
${prog} start 连上服务器(终端窗口别关;Ctrl-C 断开)
|
|
513
559
|
${prog} stop 停掉在跑的运行器
|
|
514
560
|
${prog} status 看配置、各 bot 的目录、在不在跑、开机自启
|
|
561
|
+
${prog} set-dir <bot_id> <路径> 直接指定这个 bot 的授权目录(和网页上点「选择目录」走同一套检查;弹窗弹不出来时用)
|
|
515
562
|
${prog} autostart on|off|status Mac 开机自启(on 也用来升级到新版本)
|
|
516
563
|
${prog} uninstall [--purge] 关掉自启、删掉本地副本和日志(--purge 连配置一起删)
|
|
517
564
|
${prog} --version
|
|
518
|
-
目录请连上后在网页上每个 bot 的私聊里点输入框下面「我的电脑」那一行选,Mac 上会弹窗让你选。`;
|
|
565
|
+
目录请连上后在网页上每个 bot 的私聊里点输入框下面「我的电脑」那一行选,Mac / Windows 上会弹窗让你选。`;
|
|
519
566
|
|
|
520
567
|
// @意图#R666(2026-10-29): pair 默认配完直接 start(--no-start 只配对);配完按 decideAutostart 问 / 提示 / 开启开机自启,开了就不在前台跑;
|
|
521
568
|
// 新命令 stop、autostart on|off|status、uninstall、--version;status 多显示在不在跑、版本、开机自启;用法里的命令按运行方式写(npx 时写 npx 写法)
|
|
@@ -588,6 +635,26 @@ export async function main(argv, prog = progName(), deps = {}) {
|
|
|
588
635
|
console.log(`已卸载:开机自启关了、本地副本和日志删了。${purge ? `配置也删了(${file});记得在网页「我的电脑」里点「断开」作废令牌。` : `配置留着(${file});连配置一起删:${prog} uninstall --purge`}`);
|
|
589
636
|
return;
|
|
590
637
|
}
|
|
638
|
+
if (a.cmd === "set-dir") {
|
|
639
|
+
// @意图#R716(2026-11-02): 不靠弹窗也能设目录(无桌面会话 / 弹不出来时应急),走和网页选目录同一套检查
|
|
640
|
+
const file = configPath();
|
|
641
|
+
const bot = a.positional[0];
|
|
642
|
+
const dir = a.positional[1];
|
|
643
|
+
if (!bot || !dir) { console.log(`用法:${prog} set-dir <bot_id> <路径>\n bot_id 在网页这个 bot 的私聊里、输入框下面「我的电脑」那一行能看到`); process.exitCode = 1; return; }
|
|
644
|
+
let cfg;
|
|
645
|
+
try { cfg = loadConfig(file); } catch (e) { console.log(e?.message ?? String(e)); process.exitCode = 1; return; }
|
|
646
|
+
if (!cfg?.token) { console.log(`还没配对:${file} 里没有令牌。先在网页上配对:${prog} pair <配对码>@<服务器地址>`); process.exitCode = 1; return; }
|
|
647
|
+
let real;
|
|
648
|
+
try { real = await checkDir(dir); } catch (e) { console.log(e?.message ?? String(e)); process.exitCode = 1; return; }
|
|
649
|
+
cfg.bot_dirs = { ...botDirsOf(cfg), [bot]: real };
|
|
650
|
+
delete cfg.dir; // 老格式的单一 dir 不再写回
|
|
651
|
+
saveConfig(cfg, file);
|
|
652
|
+
const l = readLock(file);
|
|
653
|
+
const running = l && pidAlive(l.pid);
|
|
654
|
+
console.log(`已设:${bot} → ${real}`);
|
|
655
|
+
console.log(running ? `运行器正在跑(进程 ${l.pid}),重启后生效:${prog} stop && ${prog} start` : `配置文件:${file}`);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
591
658
|
if (a.cmd === "status") {
|
|
592
659
|
const file = configPath();
|
|
593
660
|
const cfg = loadConfig(file);
|