agent-hub-runner 0.4.0 → 0.5.0

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
@@ -16,8 +16,8 @@ agent-hub 的电脑运行器。装在你自己的电脑上,配对之后,你
16
16
  ## 需要
17
17
 
18
18
  - **Node 22 或更新**(终端里运行 `node -v` 看版本)。还没装:去 [nodejs.org](https://nodejs.org) 下载 LTS 版(Mac 选 .pkg 安装包),或者 `brew install node`。
19
- - **macOS**:给 bot 选目录时会在 Mac 上弹出系统的「选文件夹」窗口;开机自启也只支持 Mac。别的系统能连上,但选不了目录。
20
- - bot 跑命令时用 `bash`(macOS、Linux 自带)。
19
+ - **macOS / Windows**:给 bot 选自定义目录时会弹系统选文件夹窗口;macOS 和 Windows 都支持开机自启。
20
+ - bot 跑命令时 macOS / Linux 用 `bash`,Windows 用 PowerShell。
21
21
 
22
22
  ## 第一次连接
23
23
 
@@ -30,7 +30,15 @@ agent-hub 的电脑运行器。装在你自己的电脑上,配对之后,你
30
30
 
31
31
  配对码 5 分钟有效、只能用一次;过期了在抽屉里点「换一个码」。配对成功后直接连上。
32
32
  3. Mac 上会问「开机自动连上?(Y/n)」:回车就是「是」,以后开机自己连上,终端可以关;选「否」的话终端窗口别关。
33
- 4. 抽屉里第 2 步会自动打勾。第 3 步点「选择目录…」,在 Mac 弹出的窗口里选这个 bot 能用的文件夹。每个 bot 各选各的。
33
+ 4. 抽屉里第 2 步会自动打勾。之后到网页右侧「bot设置 → 执行环境」给这个 bot 选电脑;默认使用这台电脑的共享资料库,需要时可点「选择自定义目录」在本机弹窗里选文件夹。
34
+
35
+ ## 多台电脑、共享资料库和整台电脑访问(0.5.0 起)
36
+
37
+ - 同一个账号可以连多台电脑;每台运行器有自己的配置和令牌。
38
+ - 每台电脑有一个**固定的共享资料库**:`~/.agent-runner/library`(Windows 是当前用户目录下的 `.agent-runner\library`)。程序自动创建,不能改成别的路径;升级、重启、自更新都不会清空。它是应用数据,不是临时缓存。
39
+ - bot 绑定这台电脑后默认就使用共享资料库;单个 bot 可以在网页 bot 设置里改成别的目录。
40
+ - 绑定后 bot 可以读写整台电脑,**不再限制在某个授权文件夹**;实际仍受运行器所在系统账号本身的权限限制。read 不弹批准,write/edit 自动允许并留记录,跑命令仍按「帮我批准命令行」决定是否要你点。只绑定你信任的电脑。
41
+ - 后台任务创建时冻结当时的电脑和目录;中途在网页切绑不影响旧任务,新消息和新任务用新绑定。
34
42
 
35
43
  `@` 后面的地址默认是 https;`localhost`、`127.x` 默认 http;也可以写全 `码@http://…`。老写法 `pair <配对码> --server <地址>` 照样能用。只配对不连:加 `--no-start`。
36
44
 
@@ -63,14 +71,15 @@ npx -y agent-hub-runner@latest browser status # 看浏览器装没装
63
71
 
64
72
  ## 配置文件
65
73
 
66
- - 默认在 `~/.agent-runner/config.json`(权限 600),里面有服务器地址、设备 id、长期令牌、电脑名和每个 bot 的目录。别发给别人。
74
+ - 默认在 `~/.agent-runner/config.json`(权限 600),里面有服务器地址、设备 id、长期令牌、电脑名和每个 bot 的工作目录。别发给别人。
75
+ - 共享资料库固定在同一应用目录下的 `library/`,不写进配置,也没有修改命令。
67
76
  - 想放别处:设环境变量 `AGENT_RUNNER_CONFIG=/某个路径/config.json`。
68
77
  - 文件坏了:删掉它,回网页拿新配对码重新配对。
69
78
 
70
79
 
71
80
  ## 项目上下文快照(0.3.0;`.claude/skills` 起 0.3.1)
72
81
 
73
- 服务器在每轮主对话开始时可能调用只读操作 `context_snapshot`(没有路径参数):在**当前 bot 的授权文件夹根**里读取一份 AGENTS/CLAUDE 规则,并列出 `.pi/skills`、`.agents/skills`、`.claude/skills` 下技能的名称与说明(不上传技能全文)。目录外路径和越界符号链接一律拒绝。基础快照需要运行器 **0.3.0+**;发现 `.claude/skills` 需要 **0.3.1+**。
82
+ 服务器在每轮主对话开始时可能调用只读操作 `context_snapshot`(没有路径参数):在**当前 bot 的默认工作目录**里读取一份 AGENTS/CLAUDE 规则,并列出 `.pi/skills`、`.agents/skills`、`.claude/skills` 下技能的名称与说明(不上传技能全文)。R758 起目录不再是权限边界,但项目规则仍只从默认工作目录开始找。基础快照需要运行器 **0.3.0+**;发现 `.claude/skills` 需要 **0.3.1+**。
74
83
 
75
84
  ## 常见问题
76
85
 
@@ -79,11 +88,13 @@ npx -y agent-hub-runner@latest browser status # 看浏览器装没装
79
88
  - 「权限不够」/ `EACCES`:别用 sudo;运行 `sudo chown -R "$(whoami)" ~/.npm ~/.agent-runner` 修好再试。
80
89
  - 「找不到服务器」「连不上服务器」「超时」:检查网络;命令要整条原样复制。
81
90
  - 「配对码过期了 / 已经用过了 / 已经作废了」:网页上点「换一个码」再运行新命令。
82
- - 「这个账号已经连着一台电脑了」:一个账号同时只能连一台,先在网页抽屉「连着的电脑」里点「断开」。
91
+ - 「这个账号已经连着一台电脑了」:这是旧版提示;0.5.0 起一个账号可以连多台,旧版可以升级后再配。
92
+ - 想再加一台电脑:网页「我的电脑」抽屉里点「添加电脑」,拿新码到另一台电脑运行。
83
93
  - 开机自启报「Bootstrap failed: 5: Input/output error」:这个后台任务以前被 macOS 记成了「已禁用」。0.2.1 起 `autostart on` 会自己解除并重试,直接再运行一次 `npx -y agent-hub-runner@latest autostart on` 就行;还不行就按它给的那条 `launchctl enable …` 命令运行后再试,或重启电脑再试。
84
94
  - 升级后网页还说「运行器版本太旧」:旧版本的运行器还在后台跑着(升级前开的终端,或者旧的开机自启),新版一看有人连着就退出了。0.2.2 起新版会自己把旧版本停掉、接上服务器;从 0.2.1 或更早升上来的,运行一次 `npx -y agent-hub-runner@latest autostart on` 就行,它会先停掉正在跑的那个,等新版本真连上了才说「已设开机自启」。
85
95
  - 网页上点「升级」(0.3.2 起):抽屉里有新版本时点一下,运行器自己去 npm 拉最新版、校验后换上并重启,不用开终端。0.3.1 及更早不认这个按钮,仍用上面那条命令升一次。
86
96
  - 联网搜索(0.4.0 起):bot 设置里打开「允许联网搜索」后,bot 可以上网查资料。默认用 DuckDuckGo,不用注册、不用 key。想更稳可以在「我的电脑」抽屉里换成 Tavily 或 Brave(去官网注册,把 key 粘进来),或者填一个 SearXNG 地址。key 只存在你电脑上,服务器不留。0.3.x 及更早没有这项,抽屉里会给升级命令。
97
+ - 共享资料库和整台电脑访问需要 **0.5.0**;旧运行器仍能连接,但不知道默认资料库,也不会按新规则放开目录。
87
98
 
88
99
  ## 断开、卸载
89
100
 
package/ops.mjs CHANGED
@@ -1,5 +1,5 @@
1
- // @意图#R636(2026-10-27): 运行器在你电脑上真正执行 read / write / edit / bash。路径只在这里判断(S6 结论:服务器不解析用户电脑的路径):
2
- // 先按授权目录换算成真实路径(解开符号链接,包括指向不存在目标的符号链接),不在授权目录里的一律拒绝;bash 的工作目录就是授权目录。
1
+ // @意图#R636(2026-10-27): 运行器在你电脑上真正执行 read / write / edit / bash。路径只在这里判断(S6 结论:服务器不解析用户电脑的路径)。
2
+ // @意图#R758(2026-11-08): 绑定后整台电脑都可读写,工作目录只是默认 cwd(相对路径起点);仍然解开符号链接,不越过 OS 账号权限。
3
3
  // 第一轮没有沙箱(king 定的范围):bash 里的命令本身能碰到目录外的东西,只能靠你看清批准卡片再点。
4
4
  import { spawn, spawnSync } from "node:child_process";
5
5
  import fs from "node:fs/promises";
@@ -44,14 +44,10 @@ export async function realOf(abs, depth = 0) {
44
44
 
45
45
  export const inside = (real, root) => real === root || real.startsWith(root.endsWith(path.sep) ? root : root + path.sep);
46
46
 
47
- /** 换算并检查:在授权目录里返回真实路径,不在就抛「目录外路径已拒绝」。 */
48
- // @意图#R636(2026-10-27): 授权目录外的路径一律拒绝
47
+ /** 换算成真实路径;R758 起不再限制在 root 内,root 只用来解析相对路径。 */
49
48
  export async function resolveInside(p, root) {
50
49
  if (typeof p !== "string" || !p.trim()) throw new Error("路径是空的");
51
- let real;
52
- try { real = await realOf(expandPath(p, root)); } catch (e) { throw new Error(`无法解析路径:${p}(${e?.code ?? e?.message ?? e})`); }
53
- if (!inside(real, root)) throw new Error(`目录外路径已拒绝:${p}(授权目录是 ${root})`);
54
- return real;
50
+ try { return await realOf(expandPath(p, root)); } catch (e) { throw new Error(`无法解析路径:${p}(${e?.code ?? e?.message ?? e})`); }
55
51
  }
56
52
 
57
53
  function imageMime(buf) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub-runner",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "agent-hub 的电脑运行器:配对后让你的 bot 在这台电脑上读写授权目录里的文件、跑命令(跑命令要你在网页上批准),还能用它自己的独立浏览器",
5
5
  "type": "module",
6
6
  "bin": {
package/runner.mjs CHANGED
@@ -31,7 +31,7 @@ import { browserStatus, createBrowserManager, installBrowser } from "./browser.m
31
31
  import { searchWeb, cleanSearchConfig, searchConfigStatus } from "./search.mjs"; // R751
32
32
  import { autostartOff, autostartOffWindows, autostartOn, autostartOnWindows, autostartStatus, autostartStatusWindows, COPY_FILES, installCopy, integrityOk, logFileFor, ONLY_MAC, otherAutostarts, runnerHome, truncateLogIfBig } from "./autostart.mjs";
33
33
 
34
- export const PROTOCOL_VERSION = 1;
34
+ export const PROTOCOL_VERSION = 2;
35
35
  /** npm 包名,也是装好后的命令名 */
36
36
  export const PKG_BIN = "agent-hub-runner";
37
37
  /** R666:抽屉里给的、npx 运行时提示里写的命令前缀(@latest:每次都用最新版,也不会被电脑上旧的全局安装盖掉) */
@@ -50,6 +50,8 @@ export const CLOSE_REPLACED = 4409;
50
50
 
51
51
  // @意图#R666(2026-10-29): 默认目录可用 AGENT_RUNNER_HOME 换(测试、开机自启的副本用);不设时和原来一样是 ~/.agent-runner/config.json
52
52
  export const configPath = () => process.env.AGENT_RUNNER_CONFIG || path.join(runnerHome(), "config.json");
53
+ /** R758:这台电脑固定的共享资料库(程序自己管理,不提供修改入口);测试可用 AGENT_RUNNER_HOME 隔离。 */
54
+ export const libraryDir = (env = process.env) => path.join(runnerHome(env), "library");
53
55
 
54
56
  const stamp = () => new Date().toLocaleTimeString("zh-CN", { hour12: false });
55
57
  const say = (s) => console.log(`[${stamp()}] ${s}`);
@@ -188,9 +190,8 @@ export function pickDirWindows({ signal, spawn: spawnFn = spawn } = {}) {
188
190
 
189
191
  // @意图#R639(2026-10-27): 配对时的 --dir 和网页上选的目录走同一套检查
190
192
  export async function checkDir(dir) {
191
- const real = await fs.realpath(path.resolve(dir)).catch(() => { throw new Error(`授权目录不存在:${dir}`); });
192
- if (!statSync(real).isDirectory()) throw new Error(`授权目录不是目录:${dir}`);
193
- if (real === path.parse(real).root) throw new Error("不能把整个磁盘根目录授权出去");
193
+ const real = await fs.realpath(path.resolve(dir)).catch(() => { throw new Error(`工作目录不存在:${dir}`); });
194
+ if (!statSync(real).isDirectory()) throw new Error(`工作目录不是目录:${dir}`);
194
195
  return real;
195
196
  }
196
197
 
@@ -309,7 +310,8 @@ const wsUrlOf = (server) => `${server.replace(/^http/, "ws")}/devices/ws`;
309
310
  // 浏览器不看这个 bot 有没有选目录(资料目录在 ~/.agent-runner/browser-profiles/<bot_id>/,和授权目录无关);断线不关浏览器(重连后接着用)
310
311
  export function createSession(cfg, { log = say, platform = process.platform, pickDir = platform === "win32" ? pickDirWindows : pickDirMac, file = configPath(), changes, browser, search } = {}) {
311
312
  const searchImpl = search ?? searchWeb; // R751:测试注入,默认真搜
312
- let dirs = botDirsOf(cfg); // bot_id → 授权目录
313
+ const sharedLibrary = libraryDir();
314
+ let dirs = botDirsOf(cfg); // bot_id → 工作目录
313
315
  let browsers = browser; // R729:每个 bot 一只浏览器,用到时才建
314
316
  const browserMgr = () => (browsers ??= createBrowserManager({ log }));
315
317
  const jobs = new Map(); // 调用 id → AbortController
@@ -332,8 +334,11 @@ export function createSession(cfg, { log = say, platform = process.platform, pic
332
334
  async function handleCall(send, msg) {
333
335
  const { id, op, args } = msg;
334
336
  const bot = typeof msg.bot_id === "string" ? msg.bot_id : "";
335
- const dir = bot ? dirs[bot] : undefined; // 这次调用用开始时这个 bot 的目录;中途换目录不影响它
337
+ // R758:任务/这一轮冻结的目录优先;没有就按 bot 当前目录。中途换目录不影响已经发出去的调用。
338
+ const frozen = typeof msg.dir === "string" && msg.dir.trim() ? msg.dir.trim() : undefined;
339
+ let dir = bot ? (frozen ?? dirs[bot]) : undefined;
336
340
  if (!dir) { send({ type: "result", id, ok: false, error: NO_DIR }); return; }
341
+ try { dir = await checkDir(dir); } catch (e) { send({ type: "result", id, ok: false, error: e?.message ?? String(e) }); return; }
337
342
  const ac = new AbortController();
338
343
  jobs.set(id, ac);
339
344
  log(`[${bot}] ${op === "bash" ? `bash:${args?.command}` : `${op}:${args?.path}`}`);
@@ -403,6 +408,24 @@ export function createSession(cfg, { log = say, platform = process.platform, pic
403
408
  }
404
409
  }
405
410
 
411
+ // R758:服务器同步 bot 当前绑定。dir 空 = 用固定共享资料库;自定义路径来自本机选目录或服务器已保存的值。
412
+ async function handleSetBinding(send, msg) {
413
+ const { id } = msg;
414
+ const bot = typeof msg.bot_id === "string" ? msg.bot_id : "";
415
+ if (!bot) { send({ type: "result", id, ok: false, error: "缺 bot_id" }); return; }
416
+ try {
417
+ const wanted = typeof msg.dir === "string" && msg.dir.trim() ? msg.dir.trim() : sharedLibrary;
418
+ if (wanted === sharedLibrary) mkdirSync(sharedLibrary, { recursive: true, mode: 0o700 });
419
+ const real = await checkDir(wanted);
420
+ dirs = { ...dirs, [bot]: real };
421
+ cfg.bot_dirs = { ...dirs };
422
+ saveConfig(cfg, file);
423
+ send({ type: "result", id, ok: true, bot_id: bot, dir: real });
424
+ } catch (e) {
425
+ send({ type: "result", id, ok: false, error: e?.message ?? String(e) });
426
+ }
427
+ }
428
+
406
429
  async function handlePick(send, msg) {
407
430
  const { id } = msg;
408
431
  const bot = typeof msg.bot_id === "string" ? msg.bot_id : "";
@@ -436,6 +459,7 @@ export function createSession(cfg, { log = say, platform = process.platform, pic
436
459
  cleanChanges() { return changeStore().clean(true).catch(() => 0); },
437
460
  onMessage(msg, send) {
438
461
  if (msg.type === "call") return handleCall(send, msg);
462
+ if (msg.type === "set_binding") return handleSetBinding(send, msg);
439
463
  if (msg.type === "pick_dir") return handlePick(send, msg);
440
464
  if (msg.type === "change_get") return handleChangeGet(send, msg);
441
465
  if (msg.type === "browser") return handleBrowser(send, msg); // R729
@@ -491,7 +515,7 @@ export function startRunner(cfg, { log = say, retryMs = 2000, platform = process
491
515
  const sock = new WebSocket(wsUrlOf(cfg.server));
492
516
  ws = sock;
493
517
  sock.addEventListener("open", () => {
494
- send(sock, { type: "hello", version: PROTOCOL_VERSION, device_id: cfg.device_id, token: cfg.token, bot_dirs: session.botDirs,
518
+ send(sock, { type: "hello", version: PROTOCOL_VERSION, device_id: cfg.device_id, token: cfg.token, bot_dirs: session.botDirs, library_dir: libraryDir(),
495
519
  name: cfg.name ?? os.hostname(), os: `${process.platform} ${os.release()}`, runner_version: RUNNER_VERSION, autostart: background, browser: browserInfo(), search: searchConfigStatus(cfg.search) });
496
520
  beat = setInterval(() => send(sock, { type: "ping", browser: browserInfo() }), HEARTBEAT_MS);
497
521
  });