@sleep2agi/commhub-server 0.9.0-preview.35 → 0.9.0-preview.37

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.35",
3
+ "version": "0.9.0-preview.37",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  ValidationError,
4
- validateName, validateRuntime, validateModel, validateFlagValue,
4
+ validateName, validateRuntime, validateModel, validateFlagValue, RUNTIMES,
5
5
  validateEnvRefs, validateChannelsP1, serializeEnvLocal, buildAnetArgs,
6
6
  MAX_ENV_KEYS_PER_NODE,
7
7
  } from "./create-node-validate.js";
@@ -195,3 +195,72 @@ describe("buildAnetArgs (§4.2.2 F2 — fully validated argv)", () => {
195
195
  } as any)).toThrow(ValidationError);
196
196
  });
197
197
  });
198
+
199
+ // ─── runtime_invalid 要说清「允许哪些」和「还有什么路」 ────────────────
200
+ //
201
+ // 2026-08-28 实测:从 Dashboard 建一个 codex-app-server 节点,用户拿到的是
202
+ // {"ok":false,"error":"runtime_invalid","value":"codex-app-server"}
203
+ // 而目标机器的 daemon 日志里一行都没有 —— 请求被 hub 拦在最前面,
204
+ // 用户无从知道①哪些允许②是不是打错了③有没有别的办法。
205
+ // 🔴 2026-08-28:这一节原本拿 `codex-app-server` / `opencode-cli` 当"非法 runtime"的例子。
206
+ // #1298 把 hub 的 RUNTIMES 从 3 个放开到 7 个之后,那两个变成了合法值,这两条测试因此红了 ——
207
+ // **测试正确地抓到了行为变更**。修法是换例子,不是削弱断言:
208
+ // 意图仍然是「非法 runtime 要给可操作的报错」,只是例子换成永远不会合法的字符串。
209
+ describe("validateRuntime — 报错要可操作", () => {
210
+ function thrown(v: unknown): any {
211
+ try { validateRuntime(v); } catch (e: any) { return e; }
212
+ throw new Error("expected validateRuntime to throw");
213
+ }
214
+
215
+ test("非法 runtime 的报错带上允许集合", () => {
216
+ const e = thrown("definitely-not-a-runtime");
217
+ expect(e.code).toBe("runtime_invalid");
218
+ expect(Array.isArray(e.detail?.allowed)).toBe(true);
219
+ // 🔴 断言它等于 RUNTIMES 本身,而不是等于一份手抄的清单 ——
220
+ // 手抄的那份会漂,漂了之后报错会理直气壮地告诉用户一组错的名字。
221
+ expect(e.detail.allowed).toEqual([...RUNTIMES]);
222
+ });
223
+
224
+ test("报错带上一条出路提示", () => {
225
+ const e = thrown("another-bogus-runtime");
226
+ expect(typeof e.detail?.hint).toBe("string");
227
+ expect(e.detail.hint.length).toBeGreaterThan(10);
228
+ // 提示必须点名那条真实存在的路,否则它只是安慰话
229
+ expect(e.detail.hint).toContain("anet node create");
230
+ });
231
+
232
+ test("仍然回报用户传进来的那个值(便于识别是不是打错了)", () => {
233
+ const e = thrown("codex-app-servr"); // 故意少一个 e
234
+ expect(e.detail.value).toBe("codex-app-servr");
235
+ });
236
+
237
+ test("🔴 合法 runtime 一个都不能被这次改动误伤", () => {
238
+ for (const r of RUNTIMES) {
239
+ expect(() => validateRuntime(r)).not.toThrow();
240
+ }
241
+ // 正控:非字符串必须仍然被拒 —— 否则上面那条循环全过也说明不了什么
242
+ expect(() => validateRuntime(123 as any)).toThrow();
243
+ expect(() => validateRuntime(undefined as any)).toThrow();
244
+ });
245
+ });
246
+
247
+ // #1298 —— daemon 远程创建放开到 7 个 runtime(Vincent 2026-08-28 定,
248
+ // 理由是兜底排错:节点不动了,人要能进去跟 Codex / Claude Code 对话)。
249
+ describe("#1298 RUNTIMES —— 七个都放行,且与 CLI 侧一致", () => {
250
+ test("三个共存 runtime 不再被拒", () => {
251
+ for (const r of ["codex-app-server", "grok-build-cli", "opencode-cli"]) {
252
+ expect(() => validateRuntime(r)).not.toThrow();
253
+ }
254
+ });
255
+ test("原有四个仍然放行(放开不该顺手改坏别的)", () => {
256
+ for (const r of ["claude-agent-sdk", "claude-code-cli", "codex-sdk", "grok-build-acp"]) {
257
+ expect(() => validateRuntime(r)).not.toThrow();
258
+ }
259
+ });
260
+ test("🔴 恰好七个 —— 多一个少一个都要有人解释", () => {
261
+ expect(RUNTIMES.length).toBe(7);
262
+ // 正控:不在名单里的仍然被拒,证明上面不是"什么都放行"
263
+ expect(() => validateRuntime("definitely-not-a-runtime")).toThrow();
264
+ expect(() => validateRuntime("")).toThrow();
265
+ });
266
+ });
@@ -17,7 +17,33 @@ export class ValidationError extends Error {
17
17
  }
18
18
 
19
19
  // §4.2.2 — structural enums.
20
- export const RUNTIMES = ["claude-agent-sdk", "codex-sdk", "grok-build-acp"] as const;
20
+ // #1298 —— daemon 远程创建允许的 runtime。2026-08-28 从 3 个放开到 7 个。
21
+ //
22
+ // 放开之前挡着的问题是「daemon 建出来的是**无人值守**的后台进程,而三个共存 runtime
23
+ // 的本质是人和 agent 共用一个 TUI 会话 —— 建出来给谁用?」
24
+ //
25
+ // Vincent 2026-08-28 定,理由是**兜底排错**:
26
+ // 「某个节点突然不动了,人可以进去看。在 daemon 还没那么靠谱的情况下,
27
+ // 这非常重要 —— 至少可以跟 Codex 和 Claude Code 对话。」
28
+ // 🔴 关键在于 **attach 是「建好之后」的动作,不是「建的时候」的前提**。
29
+ //
30
+ // 支撑读数(2026-08-28 实测舰队):`通信牛` 跑 codex-app-server(共存 runtime),
31
+ // **764 次成功完成任务**,当天仍在跑 —— 比两个普通 runtime 的节点(61 次 / 3 次)
32
+ // 都多一个量级。⇒ 任务链路不需要人坐在屏幕前。
33
+ //
34
+ // 🔴 这七个名字与 `agent-network/src/normalize-runtime.ts` 的 `RuntimeName` 一致。
35
+ // 那边是 CLI 侧的全集;两边分叉过一次(daemon 侧的 VALID_RUNTIMES 早就是 7 个,
36
+ // 只有这里还是 3 个,而记录此事的 #1298 正文写的是「三道闸都关着」——
37
+ // **一份快照式的盘点,只在写下的那一刻是真的**)。
38
+ export const RUNTIMES = [
39
+ "claude-agent-sdk",
40
+ "claude-code-cli",
41
+ "codex-sdk",
42
+ "codex-app-server",
43
+ "grok-build-acp",
44
+ "grok-build-cli",
45
+ "opencode-cli",
46
+ ] as const;
21
47
  export type Runtime = typeof RUNTIMES[number];
22
48
 
23
49
  export const FLAG_KEYS = ["permissionMode", "dangerouslySkipPermissions", "maxTurns", "budget", "timeout"] as const;
@@ -42,7 +68,23 @@ export function validateName(s: unknown): asserts s is string {
42
68
 
43
69
  export function validateRuntime(s: unknown): asserts s is Runtime {
44
70
  if (typeof s !== "string" || !(RUNTIMES as readonly string[]).includes(s)) {
45
- throw new ValidationError("runtime_invalid", { value: typeof s === "string" ? s.slice(0, 80) : typeof s });
71
+ // 🔴 原来只回一个 `runtime_invalid` + 用户传进来的那个值 —— **不说允许哪些,
72
+ // 也不说还有没有别的路**。2026-08-28 实测:从 Dashboard 建一个
73
+ // `codex-app-server` 节点,用户拿到的就是
74
+ // {"ok":false,"error":"runtime_invalid","value":"codex-app-server"}
75
+ // 而目标机器的 daemon 日志里一行都没有(请求根本没到)。
76
+ // 用户无从知道:①哪些是允许的 ②这个名字是不是打错了 ③有没有别的办法。
77
+ //
78
+ // `allowed` 直接由 RUNTIMES 展开,不是手写的第二份清单 ——
79
+ // 🔴 手写一份会漂移,而漂移之后报错会**理直气壮地告诉用户一组错的名字**。
80
+ throw new ValidationError("runtime_invalid", {
81
+ value: typeof s === "string" ? s.slice(0, 80) : typeof s,
82
+ allowed: [...RUNTIMES],
83
+ // 这句提示的前提是查过的:`agent-network/src/normalize-runtime.ts` 的
84
+ // `RuntimeName` / `SUPPORTED_RUNTIME_NAMES` 是 7 个,包含此处不放行的那几个,
85
+ // 所以「在目标机器上直接建」确实是一条真实存在的路,不是安慰话。
86
+ hint: "daemon 远程创建目前只放行以上 runtime;其余 runtime 可以在目标机器上直接 `anet node create --runtime <name>` 创建。",
87
+ });
46
88
  }
47
89
  }
48
90
 
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ childNodeIdForCreateRequest,
4
+ buildControllableMap,
5
+ isLifecycleControllable,
6
+ } from "./node-lifecycle-controllable.js";
7
+
8
+ // app#196 —— 桌面端对 218 个节点里的 207 个(95%)显示了「停止/重启/删除」,
9
+ // 而那三个工具的 schema 是 regex(/^node_[a-z0-9_-]+$/),那 207 个是 n_ 开头,
10
+ // 点下去只会看到一段 zod 正则原文。
11
+ describe("childNodeIdForCreateRequest —— 推导规则", () => {
12
+ test("cr_ 前缀 → node_ 前缀,其余部分逐字保留", () => {
13
+ // 🔴 这一对是 2026-08-28 生产 hub 日志里真实的 create-node finalize 行,
14
+ // 不是我按注释造的。
15
+ expect(childNodeIdForCreateRequest("cr_59723b24-8372-4270-9b3c-1552e592a09f"))
16
+ .toBe("node_59723b24-8372-4270-9b3c-1552e592a09f");
17
+ });
18
+ test("不是 cr_ 开头的一律 null —— 不猜、不截断", () => {
19
+ expect(childNodeIdForCreateRequest("node_abc")).toBeNull();
20
+ expect(childNodeIdForCreateRequest("n_abc")).toBeNull();
21
+ expect(childNodeIdForCreateRequest("abc")).toBeNull();
22
+ expect(childNodeIdForCreateRequest("cr_")).toBeNull(); // 只有前缀,没有主体
23
+ });
24
+ test("非字符串输入不抛,返回 null", () => {
25
+ for (const v of [undefined, null, 123, {}, []]) {
26
+ expect(childNodeIdForCreateRequest(v as any)).toBeNull();
27
+ }
28
+ });
29
+ });
30
+
31
+ describe("isLifecycleControllable —— 判据不是 id 前缀", () => {
32
+ const map = buildControllableMap([
33
+ { request_id: "cr_aaa", daemon_node_id: "node_daemon_x" },
34
+ { request_id: "cr_bbb", daemon_node_id: "node_daemon_y" },
35
+ { request_id: "not-a-request", daemon_node_id: "node_daemon_z" }, // 应被丢弃
36
+ ]);
37
+
38
+ test("有创建记录的 → 可控", () => {
39
+ expect(isLifecycleControllable("node_aaa", null, map)).toBe(true);
40
+ expect(map.get("node_aaa")).toBe("node_daemon_x");
41
+ });
42
+
43
+ test("🔴 光有 node_ 前缀但没有创建记录 → 不可控", () => {
44
+ // 这条是本模块存在的理由:前缀是实现细节,不是判据。
45
+ expect(isLifecycleControllable("node_never_created", null, map)).toBe(false);
46
+ });
47
+
48
+ test("手工起的 n_ 节点 → 不可控(那 207 个)", () => {
49
+ expect(isLifecycleControllable("n_22b777bf", null, map)).toBe(false);
50
+ });
51
+
52
+ test("🔴 daemon 自己可控 —— 它在 node_create_requests 里没有记录", () => {
53
+ // 漏掉这条会把 3 个 daemon 从可控名单里误删。
54
+ expect(isLifecycleControllable("node_daemon_39bd1eeae3ee", "host_supervisor", map)).toBe(true);
55
+ // 正控:同一个 id 但 role 不是 host_supervisor ⇒ 仍不可控,
56
+ // 证明上面那条过是因为 role,不是因为 id 恰好带 node_ 前缀。
57
+ expect(isLifecycleControllable("node_daemon_39bd1eeae3ee", null, map)).toBe(false);
58
+ });
59
+
60
+ test("坏的 request_id 不会污染映射", () => {
61
+ expect(map.has("node_not-a-request")).toBe(false);
62
+ expect(map.size).toBe(2);
63
+ });
64
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * app#196 —— 哪些节点能走 stop_node / restart_node / delete_node。
3
+ *
4
+ * 抽成独立模块而不是写在 /api/nodes 的路由里,因为路由里的内联逻辑测不到 ——
5
+ * 现有的 api-nodes-shape.test.ts 是直跑 handler 的 SQL,拿不到映射步骤。
6
+ * 🔴 「不可测」本身就是一个设计信号,不是测试的问题。
7
+ */
8
+
9
+ /** 子节点 id 由创建请求 id 确定性推导。
10
+ *
11
+ * 🔴 这条规则我用真实一对验过,不是只读注释:
12
+ * cr_59723b24-8372-4270-9b3c-1552e592a09f → node_59723b24-8372-4270-9b3c-…
13
+ * (2026-08-28 生产 hub 日志的 `create-node finalize` 行)
14
+ *
15
+ * 非 `cr_` 开头的一律返回 null —— 不猜,不截断。 */
16
+ export function childNodeIdForCreateRequest(requestId: unknown): string | null {
17
+ if (typeof requestId !== "string") return null;
18
+ if (!requestId.startsWith("cr_")) return null;
19
+ const rest = requestId.slice(3);
20
+ if (!rest) return null;
21
+ return `node_${rest}`;
22
+ }
23
+
24
+ /** 由创建记录建出「child node_id → daemon node_id」的映射。 */
25
+ export function buildControllableMap(
26
+ createRequests: Array<{ request_id?: unknown; daemon_node_id?: unknown }>,
27
+ ): Map<string, string> {
28
+ const m = new Map<string, string>();
29
+ for (const cr of createRequests) {
30
+ const child = childNodeIdForCreateRequest(cr?.request_id);
31
+ if (!child) continue;
32
+ m.set(child, typeof cr?.daemon_node_id === "string" ? cr.daemon_node_id : "");
33
+ }
34
+ return m;
35
+ }
36
+
37
+ /** 一个节点能不能走生命周期操作。
38
+ *
39
+ * 🔴 判据**不是 id 前缀**。前缀是实现细节;判据是 node_create_requests 里真有创建记录。
40
+ *
41
+ * 例外:daemon 自己(role=host_supervisor)也能被 restart/stop,而它在
42
+ * node_create_requests 里**没有**记录(它不是被别的 daemon 创建的)—— 单独放行。
43
+ * 漏掉这一条会把可控的 daemon 判成不可控。 */
44
+ export function isLifecycleControllable(
45
+ nodeId: unknown,
46
+ role: unknown,
47
+ controllable: Map<string, string>,
48
+ ): boolean {
49
+ if (role === "host_supervisor") return true;
50
+ return typeof nodeId === "string" && controllable.has(nodeId);
51
+ }
package/src/server.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { parseDbTimestampMs } from "./db-timestamp.js";
3
+ import { buildControllableMap, isLifecycleControllable } from "./node-lifecycle-controllable.js";
3
4
  import { resolvePort } from "./resolve-port.js";
4
5
  import { normalizeSessionRuntime } from "./runtime-label.js";
5
6
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
@@ -3143,6 +3144,33 @@ return Bun.serve({
3143
3144
  // discovery (replacing the prior `node_daemon_` prefix heuristic).
3144
3145
  // RFC-026 P1 P2 §9 will supersede this REST proxy with the
3145
3146
  // `list_host_supervisors` MCP tool.
3147
+ // app#196 —— 哪些节点能走 stop_node / restart_node / delete_node。
3148
+ //
3149
+ // 背景:那三个工具的 schema 是 `regex(/^node_[a-z0-9_-]+$/)`,而节点 id 有两种:
3150
+ // node_xxxx daemon 建出来的子节点(+ daemon 自己)
3151
+ // n_xxxx 人手工 `anet node start` 起的
3152
+ // 2026-08-28 对生产 hub 实测:218 个节点里 **207 个是 n_**,
3153
+ // 在桌面端点「停止」只会看到一段 zod 正则原文。
3154
+ //
3155
+ // 🔴 这不是「正则写窄了」——那三个工具的做法是**让那台机器上的 daemon 去停它
3156
+ // 自己建的子进程**。手工起的节点没有 daemon 可问,放宽正则只会把失败推到更深处。
3157
+ //
3158
+ // 所以这里给出一个显式标志,**让前端不必靠 id 前缀去猜**。判据不是前缀,
3159
+ // 是 node_create_requests 里真的有对应的创建记录 ——
3160
+ // 子节点 id 由 request_id 确定性推导:`node_${request_id.replace(/^cr_/,"")}`
3161
+ // (2026-08-28 用真实一对验过:cr_59723b24-… → node_59723b24-…)。
3162
+ const controllable = (() => {
3163
+ try {
3164
+ const crParams: any[] = [];
3165
+ let crSql = "SELECT request_id, daemon_node_id FROM node_create_requests WHERE 1=1";
3166
+ crSql = addNetworkScope(crSql, crParams, restScope);
3167
+ return buildControllableMap(db.all<Record<string, any>>(crSql, ...crParams));
3168
+ } catch {
3169
+ // 表不存在/查询失败 ⇒ 全部按不可控处理,不让这一格拖垮整个列表
3170
+ return new Map<string, string>();
3171
+ }
3172
+ })();
3173
+
3146
3174
  const rows = rawRows.map(r => {
3147
3175
  let role: string | null = null;
3148
3176
  const snap = r.config_snapshot;
@@ -3155,11 +3183,17 @@ return Bun.serve({
3155
3183
  // it can map() without a guard, and normalise legacy/absent values
3156
3184
  // to [] rather than null. attrs_revision is normalised to a number
3157
3185
  // so pre-migration rows (NULL) read as 0.
3186
+ // app#196 —— 显式告诉前端这个节点能不能走生命周期操作,以及该找哪个 daemon。
3187
+ // daemon 自己(role=host_supervisor)也能被 restart/stop,它的 id 同样是 node_ 前缀
3188
+ // 且在 node_create_requests 里没有记录,所以单独放行。
3189
+ const lifecycle_controllable = isLifecycleControllable(r.node_id, role, controllable);
3158
3190
  return {
3159
3191
  ...rest,
3160
3192
  tags: parseStoredTags(r.tags),
3161
3193
  attrs_revision: Number(r.attrs_revision ?? 0),
3162
3194
  role,
3195
+ lifecycle_controllable,
3196
+ lifecycle_daemon_node_id: controllable.get(r.node_id) ?? null,
3163
3197
  };
3164
3198
  });
3165
3199
  return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
@@ -718,3 +718,60 @@ describe("RFC-027 PR1.2a — restart_node resets lifecycle_state to 'active'", (
718
718
  expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active");
719
719
  });
720
720
  });
721
+
722
+ // ── #1286 stale-deleting 重派出口 ──────────────────────────────────
723
+ //
724
+ // 修复前 daemon 丢 ack 会让行永远停在 deleting:重发被挡、force 也一样。
725
+ // 2026-08-28 实测两行卡死(acc-…-3373 / probe-tl2),API 层面无解。
726
+ // 出口三条件缺一不放行:force=true + 最近 delete 请求非终态 + 晾超 5 分钟。
727
+ describe("delete_node — stale-deleting redispatch exit (#1286)", () => {
728
+ function seedDeletingChild() {
729
+ setupAlphaNetwork();
730
+ db.run(`UPDATE nodes SET lifecycle_state = 'deleting' WHERE node_id = ?1`, [CHILD_A_ID]);
731
+ }
732
+ function seedStopRequest(status: string, ageMs: number) {
733
+ db.run(
734
+ `INSERT INTO node_stop_requests (request_id, network_id, daemon_node_id, child_node_id,
735
+ child_alias, action, delete_config, created_by_token, status, created_at)
736
+ VALUES (?1, ?2, ?3, ?4, ?5, 'delete', 1, 'tok_test', ?6, ?7)`,
737
+ [`sr_stale_${Date.now()}_${Math.floor(ageMs)}`, NET_A, DAEMON_A_ID, CHILD_A_ID,
738
+ CHILD_A_ALIAS, status, Date.now() - ageMs],
739
+ );
740
+ }
741
+ const base = { child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, confirm_alias: CHILD_A_ALIAS };
742
+
743
+ test("无 force:仍拒,且带 hint 指出口(不是让人反复撞墙)", async () => {
744
+ seedDeletingChild(); seedStopRequest("pending", 10 * 60_000);
745
+ const r = await call(buildHandlers(USER_A_ID).delete_node, base);
746
+ expect(r.ok).toBe(false);
747
+ expect(r.error).toBe("node_already_deleting");
748
+ expect(typeof r.hint).toBe("string");
749
+ });
750
+
751
+ test("🔴 force 但请求还新鲜(1 分钟):仍拒 —— force 不能变成竞态放大器", async () => {
752
+ seedDeletingChild(); seedStopRequest("pending", 60_000);
753
+ const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
754
+ expect(r.ok).toBe(false);
755
+ expect(r.error).toBe("node_already_deleting");
756
+ });
757
+
758
+ test("🔴 force 但上一条已终态(stopped):仍拒 —— daemon 刚 ack 过,行马上会转移", async () => {
759
+ seedDeletingChild(); seedStopRequest("stopped", 10 * 60_000);
760
+ const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
761
+ expect(r.ok).toBe(false);
762
+ expect(r.error).toBe("node_already_deleting");
763
+ });
764
+
765
+ test("force + pending 晾超 5 分钟:放行,生成新 request_id", async () => {
766
+ seedDeletingChild(); seedStopRequest("pending", 10 * 60_000);
767
+ const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
768
+ expect(r.ok).toBe(true);
769
+ expect((r.request_id as string).startsWith("sr_")).toBe(true);
770
+ });
771
+
772
+ test("force + dispatched 晾超 5 分钟:同样放行", async () => {
773
+ seedDeletingChild(); seedStopRequest("dispatched", 10 * 60_000);
774
+ const r = await call(buildHandlers(USER_A_ID).delete_node, { ...base, force: true });
775
+ expect(r.ok).toBe(true);
776
+ });
777
+ });
package/src/tools.ts CHANGED
@@ -551,6 +551,39 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
551
551
  runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
552
552
  allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
553
553
  max_concurrent_children: z.number().int().min(1).max(1000).optional(),
554
+ // #1353 —— daemon **当下**能不能创建节点。与 runtimes_supported 不是一回事:
555
+ // 后者是声明(我支持哪些 runtime),这个是实际能力。一个 daemon 可以声明支持
556
+ // 三种 runtime,同时因为 ANET_BIN pin 解析不出来而一种也创建不了。
557
+ //
558
+ // 🔴 为什么 hub 需要看见它:pin 只存在于 /etc/anet-daemon/path.conf 或
559
+ // 显式开启的环境变量里,**重启不带环境就会丢**。丢了之后 daemon 照常注册、
560
+ // 在线、收 doorbell,hub 返回 ok:true + request_id —— 而节点永远不出现。
561
+ // 在此之前 hub 完全看不出区别,Dashboard 的「选服务器」还会把它列为可选。
562
+ can_create_nodes: z.boolean().optional(),
563
+ // 只收**代码**,不收原始报错文本 —— 上游 unsafePathHelp() 的消息里带完整机器路径,
564
+ // 而这个字段会走到 Dashboard。enum 而不是 string:一个自由文本字段等于给
565
+ // 「把路径塞进来」留了口子。
566
+ // #1353 —— 四类失败,修法完全不同:
567
+ // identity 重装 anet / unset ANET_BIN_ABS
568
+ // source 写 /etc/anet-daemon/path.conf(**要 sudo**)
569
+ // shape 换成 realpath
570
+ // permission **一行 chmod go-w**
571
+ // 混成一条会让人修错方向 —— 2026-08-28 我一天里撞到其中两类,看到的是同一条错误。
572
+ //
573
+ // 🔴 `anet_bin_pin_unresolved` **必须留着**:已经在跑的 agent-node@2.5.0-preview.40
574
+ // 发的就是这个值。zod 对象里任何一个字段验证失败会让**整个 report_status 被拒**,
575
+ // 不只是丢掉这一格 —— 那会让所有 .40 daemon 在 hub 上变成失联。
576
+ // 删一个 enum 值的代价,远大于多留一个。
577
+ // 🔴 仍然是 enum 不是 string:自由文本字段就是给机器路径留的口子,
578
+ // 而上游 e.message 里带的正是完整路径。
579
+ create_nodes_blocked_reason: z.enum([
580
+ "anet_bin_pin_unresolved", // 兼容:.40 及更早
581
+ "anet_bin_identity",
582
+ "anet_bin_source",
583
+ "anet_bin_shape",
584
+ "anet_bin_permission",
585
+ "anet_bin_unknown",
586
+ ]).optional(),
554
587
  }).optional(),
555
588
  }).optional().describe("RFC-024 — masked node config snapshot"),
556
589
  },
@@ -3528,7 +3561,39 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3528
3561
  return { ok: false, error: state === "stopping" ? "node_already_stopping" : "node_not_active", current_state: state };
3529
3562
  }
3530
3563
  if (args.action === "delete" && state === "deleting") {
3531
- return { ok: false, error: "node_already_deleting", current_state: state };
3564
+ // #1286 —— stale-deleting 重派出口。
3565
+ //
3566
+ // 在 .40 埋点修复之前,daemon 丢失 ack 会让行**永远**停在 deleting:
3567
+ // 重发 delete 被这里挡住,force=true 也一样 —— 状态机没有出口,
3568
+ // 2026-08-28 实测两行卡死(acc-…-3373 / probe-tl2),API 层面无解。
3569
+ //
3570
+ // 出口的三个条件,缺一不放行:
3571
+ // ① 调用方显式 force=true(不改默认行为 —— 正常并发的重复 delete 仍被拒)
3572
+ // ② 该 child 最近一条 delete 请求不是终态(pending/dispatched)
3573
+ // —— 终态说明 daemon 刚 ack 过,行马上会转移,不该重派
3574
+ // ③ 那条请求已经晾了超过 STALE_DELETING_MS —— 🔴 不是"刚派发就能 force":
3575
+ // 给正常的 doorbell→ack 往返留时间,否则 force 变成竞态放大器。
3576
+ const STALE_DELETING_MS = 5 * 60_000;
3577
+ const last = db.get<{ request_id: string; status: string; created_at: number }>(
3578
+ `SELECT request_id, status, created_at FROM node_stop_requests
3579
+ WHERE child_node_id = ?1 AND action = 'delete'
3580
+ ORDER BY created_at DESC LIMIT 1`, node.node_id,
3581
+ );
3582
+ const lastIsStale = !!last
3583
+ && (last.status === "pending" || last.status === "dispatched")
3584
+ && (Date.now() - last.created_at) > STALE_DELETING_MS;
3585
+ if (!(args.force === true && (lastIsStale || !last))) {
3586
+ return {
3587
+ ok: false, error: "node_already_deleting", current_state: state,
3588
+ // 🔴 告诉调用方出口在哪,而不是让他反复撞同一面墙。
3589
+ ...(last ? { last_request_id: last.request_id, last_request_status: last.status } : {}),
3590
+ hint: last && (last.status === "pending" || last.status === "dispatched")
3591
+ ? `再等一下;若超过 ${STALE_DELETING_MS / 60000} 分钟仍未收敛,带 force=true 重试可重新派发`
3592
+ : "上一条请求已终态,行应即将转移;稍后重查",
3593
+ };
3594
+ }
3595
+ // 放行:走正常派发路径,生成新 request_id。旧请求行保留(审计线索),
3596
+ // daemon 侧对未知 request 的 get_stop_request 会拿到 request_not_found,无副作用。
3532
3597
  }
3533
3598
  if (args.action === "delete" && state === "stopping") {
3534
3599
  // Can't start delete while a stop is mid-flight — race-prone.
@@ -3737,6 +3802,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3737
3802
  error: z.string().max(1000).optional(),
3738
3803
  },
3739
3804
  async ({ request_id, status, exit_signal, backup_path, error: ackError }) => {
3805
+ // #1358 —— 🔴 在此之前,这个工具的**每一条出口都是静默的**:到达不记、
3806
+ // 三条拒绝不记、成功收尾也不记。而隔壁 create-node 的收尾**是记的**
3807
+ // (`✓ create-node finalize`)。这种不对称最坑人:读日志的人看到 create 有、
3808
+ // delete 没有,第一反应是「delete 没发生」—— 但两者的日志覆盖面本来就不同,
3809
+ // 那条推论没有依据。排查 #1286 时我自己就差点这样读。
3810
+ console.log(`[commhub] ← ack_stop_request request=${request_id} status=${status}${exit_signal ? ` exit_signal=${exit_signal}` : ""}`);
3740
3811
  const callerDaemon = resolveCallerDaemonTokenBound();
3741
3812
  if (!callerDaemon.ok) {
3742
3813
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
@@ -3748,11 +3819,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3748
3819
  }>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, action,
3749
3820
  in_flight_at_dispatch, force
3750
3821
  FROM node_stop_requests WHERE request_id = ?1`, request_id);
3751
- if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
3822
+ if (!row) {
3823
+ console.warn(`[commhub] ✕ ack_stop_request rejected: request_not_found request=${request_id}`);
3824
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
3825
+ }
3752
3826
  if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
3827
+ console.warn(`[commhub] ✕ ack_stop_request rejected: not_your_request request=${request_id} row_daemon=${row.daemon_node_id} caller=${callerDaemon.daemonNodeId}`);
3753
3828
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
3754
3829
  }
3755
3830
  if (row.network_id !== callerDaemon.networkId) {
3831
+ console.warn(`[commhub] ✕ ack_stop_request rejected: cross_network_request request=${request_id} row_net=${row.network_id} caller_net=${callerDaemon.networkId}`);
3756
3832
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
3757
3833
  }
3758
3834
  const now = Date.now();
@@ -3807,9 +3883,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3807
3883
  // sweeper / next reconciliation will pick it up. Don't transition.
3808
3884
  });
3809
3885
  } catch (e: any) {
3886
+ console.warn(`[commhub] ✕ ack_stop_request finalize_tx_failed request=${request_id} action=${row.action} child=${row.child_alias}: ${e?.message || e}`);
3810
3887
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "finalize_tx_failed", message: e?.message || String(e) }) }] };
3811
3888
  }
3812
3889
 
3890
+ // 🔴 关键的是最后那半句「据此做了什么」。#1286 里 hub 的代码路径经核对是
3891
+ // 正确的,缺的正是「它到底走没走那条分支」—— 只说「收到了 ack」回答不了。
3892
+ const did = status === "stopped" && row.action === "delete"
3893
+ ? "revoked ntok + DELETE FROM nodes"
3894
+ : status === "stopped" && row.action === "stop"
3895
+ ? "lifecycle_state=stopped"
3896
+ : status === "stop_failed"
3897
+ ? "lifecycle_state=stop_failed"
3898
+ : "no lifecycle transition (noop_not_my_child)";
3899
+ console.log(`[commhub] ✓ ${row.action}-node finalize: request=${request_id} child=${row.child_alias} (${row.child_node_id}) status=${status} → ${did}`);
3813
3900
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
3814
3901
  },
3815
3902
  );