agent-hub-runner 0.3.1 → 0.3.2

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
@@ -82,6 +82,7 @@ npx -y agent-hub-runner@latest browser status # 看浏览器装没装
82
82
  - 「这个账号已经连着一台电脑了」:一个账号同时只能连一台,先在网页抽屉「连着的电脑」里点「断开」。
83
83
  - 开机自启报「Bootstrap failed: 5: Input/output error」:这个后台任务以前被 macOS 记成了「已禁用」。0.2.1 起 `autostart on` 会自己解除并重试,直接再运行一次 `npx -y agent-hub-runner@latest autostart on` 就行;还不行就按它给的那条 `launchctl enable …` 命令运行后再试,或重启电脑再试。
84
84
  - 升级后网页还说「运行器版本太旧」:旧版本的运行器还在后台跑着(升级前开的终端,或者旧的开机自启),新版一看有人连着就退出了。0.2.2 起新版会自己把旧版本停掉、接上服务器;从 0.2.1 或更早升上来的,运行一次 `npx -y agent-hub-runner@latest autostart on` 就行,它会先停掉正在跑的那个,等新版本真连上了才说「已设开机自启」。
85
+ - 网页上点「升级」(0.3.2 起):抽屉里有新版本时点一下,运行器自己去 npm 拉最新版、校验后换上并重启,不用开终端。0.3.1 及更早不认这个按钮,仍用上面那条命令升一次。
85
86
 
86
87
  ## 断开、卸载
87
88
 
package/autostart.mjs CHANGED
@@ -116,6 +116,17 @@ export function installCopy({ srcDir, version, env = process.env, platform = pro
116
116
  return path.join(cur, "runner.mjs");
117
117
  }
118
118
 
119
+ /**
120
+ * R750:校验 npm 包的 integrity。npm 给的是 "sha512-<base64>",对不上返回 false,调用方不得替换正在跑的版本。
121
+ * 只认 sha512。别的算法(sha1、没有 integrity)一律 false,不将就。
122
+ */
123
+ export function integrityOk(buf, integrity) {
124
+ const m = /^sha512-(.+)$/.exec(String(integrity ?? "").trim());
125
+ if (!m) return false;
126
+ const got = createHash("sha512").update(buf).digest("base64");
127
+ return got === m[1];
128
+ }
129
+
119
130
  /** 默认的 launchctl:返回 { code, stdout, stderr },不抛错。AGENT_RUNNER_LAUNCHCTL 可换成别的程序(测试用假的,不真加载) */
120
131
  export function runLaunchctl(args, env = process.env) {
121
132
  return new Promise((resolve) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub-runner",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "agent-hub 的电脑运行器:配对后让你的 bot 在这台电脑上读写授权目录里的文件、跑命令(跑命令要你在网页上批准),还能用它自己的独立浏览器",
5
5
  "type": "module",
6
6
  "bin": {
package/runner.mjs CHANGED
@@ -28,7 +28,7 @@ import { fileURLToPath } from "node:url";
28
28
  import { runOp } from "./ops.mjs";
29
29
  import { buildChangeRecord, changesDirFor, CHANGE_ID_RE, createChangeStore } from "./changes.mjs";
30
30
  import { browserStatus, createBrowserManager, installBrowser } from "./browser.mjs";
31
- import { autostartOff, autostartOffWindows, autostartOn, autostartOnWindows, autostartStatus, autostartStatusWindows, logFileFor, ONLY_MAC, otherAutostarts, runnerHome, truncateLogIfBig } from "./autostart.mjs";
31
+ import { autostartOff, autostartOffWindows, autostartOn, autostartOnWindows, autostartStatus, autostartStatusWindows, COPY_FILES, installCopy, integrityOk, logFileFor, ONLY_MAC, otherAutostarts, runnerHome, truncateLogIfBig } from "./autostart.mjs";
32
32
 
33
33
  export const PROTOCOL_VERSION = 1;
34
34
  /** npm 包名,也是装好后的命令名 */
@@ -438,7 +438,7 @@ export function createSession(cfg, { log = say, platform = process.platform, pic
438
438
  // @意图#R640(2026-10-27): hello 报 bot→目录 对照表(bot_dirs),不再报单一 dir
439
439
  // @意图#R639(2026-10-27): 没目录也能连上;目录由 createSession 管(网页上选完马上生效),重连时 hello 带当前目录
440
440
  // @意图#R636(2026-10-27): 前台运行、心跳、执行工具调用;断线时杀掉正在跑的命令,重连后不重放
441
- 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" } = {}) {
441
+ 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", selfUpdate: selfUpdateFn } = {}) {
442
442
  checkNode();
443
443
  let fails = 0;
444
444
  const session = createSession(cfg, { log, pickDir, platform, file });
@@ -446,12 +446,16 @@ export function startRunner(cfg, { log = say, retryMs = 2000, platform = process
446
446
  let ws = null;
447
447
  let stopped = false;
448
448
  let beat = null;
449
+ let retry = null;
449
450
  let resolveDone;
450
451
  const done = new Promise((r) => { resolveDone = r; });
451
452
  const finish = (why) => { if (!stopped) { stopped = true; } resolveDone(why); };
452
453
  const send = (sock, msg) => { if (sock.readyState === WebSocket.OPEN) sock.send(JSON.stringify(msg)); };
453
454
  const browserInfo = () => { try { const b = browserStatus(); return { installed: b.installed, version: b.version }; } catch { return { installed: false, version: null }; } };
454
455
 
456
+ // @意图#R750(2026-11-07): 网页点「升级」发来 self_update(不带参数)。自己查最新版、校验、换上、拉起新版,回一条结果后正常退出,
457
+ // 让新版接上。失败了回原因,旧版继续跑。同时只跑一个,重复发来的忽略。
458
+ let updating = false;
455
459
  function connect() {
456
460
  if (stopped) return;
457
461
  const sock = new WebSocket(wsUrlOf(cfg.server));
@@ -471,6 +475,7 @@ export function startRunner(cfg, { log = say, retryMs = 2000, platform = process
471
475
  log(n ? `已连上服务器 ${cfg.server},${n} 个 bot 选了目录。${tail}`
472
476
  : `已连上服务器 ${cfg.server},还没有 bot 选目录:请在网页上每个 bot 的私聊里点输入框下面「我的电脑」那一行,再点「选择目录」。${tail}`);
473
477
  } else if (msg.type === "revoked") { session.killAll(); log("网页上已断开这台电脑,令牌已作废。要再连,请在网页上重新拿配对码配对。"); }
478
+ else if (msg.type === "self_update") { void runSelfUpdate(sock); }
474
479
  else void session.onMessage(msg, (m) => send(sock, m));
475
480
  });
476
481
  sock.addEventListener("close", (ev) => {
@@ -494,6 +499,7 @@ export function startRunner(cfg, { log = say, retryMs = 2000, platform = process
494
499
  session.killAll();
495
500
  void session.closeBrowsers(); // R729
496
501
  clearInterval(beat);
502
+ clearTimeout(retry);
497
503
  try { ws?.close(1000, "runner stopped"); } catch { /* ignore */ }
498
504
  resolveDone("stopped");
499
505
  };
@@ -622,6 +628,90 @@ export async function waitNewHolder(file, { version = RUNNER_VERSION, ms = 10000
622
628
  }
623
629
  }
624
630
 
631
+ /**
632
+ * R750:自己升级。版本号自己去 npm 查,不接收调用方给的版本或命令。
633
+ * 下载的包按 integrity 校验 sha512,对不上就不替换。
634
+ * fetchLatest / download / extract / spawnNext 都可以换成假的(测试用),默认走真的 npm。
635
+ * 返回 { ok:true, version } 或 { ok:false, error }。成功时新进程已经拉起,调用方负责退出自己。
636
+ */
637
+ // @意图#R750(2026-11-07): 网页点「升级」,运行器自己拉最新版换上,用户不用开终端。指令不带参数,目标版本自己查,
638
+ // 校验不过就不动旧版。没开开机自启的也拉一个后台新进程接上,不用先 Ctrl-C。
639
+ export const NPM_PACK_URL = (version) => `https://registry.npmjs.org/agent-hub-runner/-/agent-hub-runner-${version}.tgz`;
640
+ export async function fetchLatestPack(fetchImpl = globalThis.fetch, timeoutMs = 8000) {
641
+ const r = await fetchImpl("https://registry.npmjs.org/agent-hub-runner/latest", { headers: { accept: "application/json" }, signal: AbortSignal.timeout(timeoutMs) });
642
+ if (!r.ok) return null;
643
+ const b = await r.json().catch(() => null);
644
+ const version = typeof b?.version === "string" ? b.version : "";
645
+ const integrity = typeof b?.dist?.integrity === "string" ? b.dist.integrity : "";
646
+ if (!/^\d+\.\d+\.\d+/.test(version) || !integrity.startsWith("sha512-")) return null;
647
+ return { version, integrity, tarball: typeof b?.dist?.tarball === "string" ? b.dist.tarball : NPM_PACK_URL(version) };
648
+ }
649
+ /** 下载 tgz。地址只接受 registry.npmjs.org,别的一律拒绝,不跟随调用方给的地址。 */
650
+ export async function downloadPack(latest, fetchImpl = globalThis.fetch, timeoutMs = 20000) {
651
+ const url = new URL(latest.tarball);
652
+ if (url.protocol !== "https:" || url.hostname !== "registry.npmjs.org") throw new Error("只从 registry.npmjs.org 下载");
653
+ const r = await fetchImpl(url, { signal: AbortSignal.timeout(timeoutMs) });
654
+ if (!r.ok) throw new Error(`下载失败 ${r.status}`);
655
+ return Buffer.from(await r.arrayBuffer());
656
+ }
657
+ /** 解压 tgz 到临时目录,返回 package/ 子目录(npm 包的根)。只取运行器自己那几份文件,不执行包里的任何东西。 */
658
+ export async function extractPack(buf) {
659
+ const { mkdtempSync } = await import("node:fs");
660
+ const dir = mkdtempSync(path.join(os.tmpdir(), "agent-runner-upd-"));
661
+ const tar = spawn("tar", ["-xzf", "-", "-C", dir], { stdio: ["pipe", "ignore", "ignore"] });
662
+ const done = new Promise((resolve, reject) => { tar.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`解压失败 ${code}`))); tar.on("error", reject); });
663
+ tar.stdin.end(buf);
664
+ await done;
665
+ const pkg = path.join(dir, "package");
666
+ const { readdirSync } = await import("node:fs");
667
+ for (const name of readdirSync(pkg)) if (!COPY_FILES.includes(name)) rmSync(path.join(pkg, name), { recursive: true, force: true });
668
+ return pkg;
669
+ }
670
+ /**
671
+ * 拉起新版后台进程。新版从刚装好的副本起,不从下载的临时目录起。
672
+ * 开机自启的交给 launchd / 计划任务重新加载(新版按 R732 顶掉旧的);没开的自己起一个后台进程,等它拿到锁。
673
+ */
674
+ export async function spawnUpdated({ version, file, env = process.env, platform = process.platform, launchctl, schtasks, spawnFn = spawn, wait = waitNewHolder }) {
675
+ const script = path.join(runnerHome(env), "bin", version, "runner.mjs");
676
+ if (platform === "win32" && env.AGENT_RUNNER_LAUNCHD === "1") {
677
+ await autostartOnWindows({ configFile: file, version, srcDir: path.dirname(script), execPath: process.execPath, env, schtasks });
678
+ } else if (platform === "darwin" && env.AGENT_RUNNER_LAUNCHD === "1") {
679
+ await autostartOn({ configFile: file, version, srcDir: path.dirname(script), execPath: process.execPath, env, launchctl, platform });
680
+ } else {
681
+ const child = spawnFn(process.execPath, [script, "start"], { detached: true, stdio: "ignore", env: { ...env, AGENT_RUNNER_LAUNCHD: "" } });
682
+ child.unref();
683
+ }
684
+ const got = await wait(file, { version });
685
+ if (!got) throw new Error("新版没有接上");
686
+ return got;
687
+ }
688
+ /**
689
+ * R750:处理一条 self_update。不看消息内容(版本号、命令都忽略),只跑 selfUpdate。
690
+ * 回一条 self_update_result。成功返回 true,调用方退出让新版接上;失败返回 false,旧版继续跑。
691
+ */
692
+ export async function handleSelfUpdate({ file, platform, selfUpdate: run = selfUpdate, log = () => {} }, send) {
693
+ const r = await run({ file, env: process.env, platform }).catch((e) => ({ ok: false, error: e?.message ?? String(e) }));
694
+ send({ type: "self_update_result", ok: r.ok === true, ...(r.ok ? { version: r.version } : { error: r.error ?? "升级失败" }) });
695
+ if (r.ok) { log(`已换到 ${r.version},这个进程退出,新版接上`); return true; }
696
+ log(`升级没成:${r.error}`);
697
+ return false;
698
+ }
699
+ export async function selfUpdate({ file = configPath(), env = process.env, platform = process.platform, fetchLatest = () => fetchLatestPack(), download = downloadPack, extract = extractPack, spawnNext = spawnUpdated, currentVersion = RUNNER_VERSION } = {}) {
700
+ const latest = await fetchLatest().catch(() => null);
701
+ if (!latest) return { ok: false, error: "查不到最新版,没有更换" };
702
+ if (!newerVersion(latest.version, currentVersion)) return { ok: false, error: `已经是最新版(${currentVersion})` };
703
+ const buf = await download(latest).catch(() => null);
704
+ if (!buf) return { ok: false, error: "下载失败,没有更换" };
705
+ if (!integrityOk(buf, latest.integrity)) return { ok: false, error: "下载的包校验对不上,没有更换" };
706
+ let srcDir;
707
+ try { srcDir = await extract(buf, latest.version); } catch (e) { return { ok: false, error: `解压失败,没有更换:${e?.message ?? e}` }; }
708
+ installCopy({ srcDir, version: latest.version, env, platform });
709
+ try {
710
+ await spawnNext({ version: latest.version, file, env });
711
+ } catch (e) { return { ok: false, error: `新版没接上,没有换掉正在跑的:${e?.message ?? e}` }; }
712
+ return { ok: true, version: latest.version };
713
+ }
714
+
625
715
  const here = () => path.dirname(fileURLToPath(import.meta.url));
626
716
  // @意图#R666(2026-10-29): 开启自启:前台有一个在跑就先停掉它(不然后台那个起来就因为锁退出了),再交给 autostart.mjs(复制自己、写 plist、加载)
627
717
  // @意图#R725(2026-11-03): Windows 走计划任务(autostartOnWindows),Mac 走 launchd。提示里的「这台 Mac」按平台写。