@sleep2agi/commhub-server 0.9.0-preview.43 → 0.9.0-preview.45
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 +1 -1
- package/src/admin-networks-http.test.ts +5 -1
- package/src/api-host-supervisors-can-create-nodes.test.ts +84 -0
- package/src/api-host-supervisors-fallback.test.ts +5 -1
- package/src/auth-validate.test.ts +5 -1
- package/src/create-node-blocked-gate.test.ts +125 -0
- package/src/dashboard-slash-routing-http.test.ts +5 -1
- package/src/db.ts +31 -0
- package/src/external-schedule-edits-http.test.ts +5 -1
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +17 -4
- package/src/health-redaction.test.ts +5 -1
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/network-name-http.test.ts +5 -1
- package/src/node-attrs.test.ts +5 -1
- package/src/node-id-alias.test.ts +41 -0
- package/src/observer-avatar-http.test.ts +5 -1
- package/src/report-status-host-schema.test.ts +135 -0
- package/src/rest-explicit-columns-http.test.ts +5 -1
- package/src/rules-file-transport.test.ts +235 -0
- package/src/scheduled-tasks-http.test.ts +5 -1
- package/src/server.ts +34 -5
- package/src/side-thread-http-integration.test.ts +5 -1
- package/src/skillhub-http.test.ts +5 -1
- package/src/task-auth-origin-http.test.ts +5 -1
- package/src/task-consumption.test.ts +1 -1
- package/src/task-diagnostic-http.test.ts +5 -1
- package/src/task-lifecycle-watcher.test.ts +5 -1
- package/src/task-network-resolution-http.test.ts +5 -1
- package/src/tasks-pagination-http.test.ts +5 -1
- package/src/tools.ts +391 -11
- package/src/uploads-http.test.ts +5 -1
|
@@ -23,6 +23,10 @@ function sqliteUtcTimestamp(date: Date): string {
|
|
|
23
23
|
return date.toISOString().slice(0, 19).replace("T", " ");
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
27
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
28
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
29
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
26
30
|
beforeAll(async () => {
|
|
27
31
|
const { register } = await import("./auth.js");
|
|
28
32
|
const auth = register(`watcher_owner_${Date.now()}`, "WatcherOwner-Strong-1!", undefined, "watcher");
|
|
@@ -39,7 +43,7 @@ beforeAll(async () => {
|
|
|
39
43
|
const mod = await import("./server.js");
|
|
40
44
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
41
45
|
base = `http://127.0.0.1:${server.port}`;
|
|
42
|
-
});
|
|
46
|
+
}, 30_000);
|
|
43
47
|
|
|
44
48
|
afterAll(() => {
|
|
45
49
|
try { server?.stop(true); } catch {}
|
|
@@ -46,6 +46,10 @@ function seedTarget(principal: Principal): void {
|
|
|
46
46
|
);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
50
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
51
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
52
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
49
53
|
beforeAll(async () => {
|
|
50
54
|
process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
|
|
51
55
|
adminSingle = registerPrincipal("admin_single", true);
|
|
@@ -70,7 +74,7 @@ beforeAll(async () => {
|
|
|
70
74
|
const mod: any = await import("./server.js");
|
|
71
75
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
72
76
|
base = `http://127.0.0.1:${server.port}`;
|
|
73
|
-
});
|
|
77
|
+
}, 30_000);
|
|
74
78
|
|
|
75
79
|
afterAll(() => {
|
|
76
80
|
try { server?.stop?.(true); } catch {}
|
|
@@ -28,6 +28,10 @@ function seedTask(
|
|
|
28
28
|
);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
32
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
33
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
34
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
31
35
|
beforeAll(async () => {
|
|
32
36
|
({ db } = await import("./db.js"));
|
|
33
37
|
({ register } = await import("./auth.js"));
|
|
@@ -54,7 +58,7 @@ beforeAll(async () => {
|
|
|
54
58
|
const mod = await import("./server.js");
|
|
55
59
|
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
56
60
|
base = `http://127.0.0.1:${server.port}`;
|
|
57
|
-
});
|
|
61
|
+
}, 30_000);
|
|
58
62
|
|
|
59
63
|
afterAll(() => {
|
|
60
64
|
try { server?.stop(true); } catch {}
|
package/src/tools.ts
CHANGED
|
@@ -61,6 +61,7 @@ import {
|
|
|
61
61
|
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
62
62
|
import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
|
|
63
63
|
import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
|
|
64
|
+
import { parseHubTimestamp } from "./hub-timestamp";
|
|
64
65
|
|
|
65
66
|
function ts(): string {
|
|
66
67
|
return new Date().toTimeString().slice(0, 8);
|
|
@@ -517,6 +518,28 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
517
518
|
model: z.string().max(200).optional().describe("AI model name"),
|
|
518
519
|
node_name: z.string().max(200).optional().describe("Stable node display name (may differ from alias)"),
|
|
519
520
|
network_id: z.string().max(200).optional().describe("Network this agent belongs to"),
|
|
521
|
+
// ╭─ 🔴 往 report_status 里加字段之前先读这一段(#1545 实测,zod 4.3.6)─────────╮
|
|
522
|
+
// │ **这份 schema 的子对象一半严一半松,而后果完全不同。**
|
|
523
|
+
// │
|
|
524
|
+
// │ 非 strict:host / process_telemetry / config_snapshot / daemon_capabilities
|
|
525
|
+
// │ `.strict()`:external_schedules(含其 schedules[] 元素)
|
|
526
|
+
// │ side_thread_capability(含其 exactBoundary)
|
|
527
|
+
// │
|
|
528
|
+
// │ 往**非 strict** 的对象里加一个 hub 还不认识的键 ⇒ zod **静默丢弃**。
|
|
529
|
+
// │ 节点不会掉线,但字段人间蒸发 —— 现场表现为「daemon 明明发了、hub 上没有」,
|
|
530
|
+
// │ 排查起来和 daemon 侧 bug 一模一样。
|
|
531
|
+
// │ 往**strict** 的对象里加同一个键 ⇒ `unrecognized_keys`,**整份 report_status 被拒**。
|
|
532
|
+
// │ 而 agent-node 的 register() 是 `await` 且无人 catch ⇒ **节点进程当场死掉**
|
|
533
|
+
// │ (#1225 就是这么全网躺倒的,见下面 host.ip 那段)。
|
|
534
|
+
// │
|
|
535
|
+
// │ 所以「新字段能不能 daemon 先发」**没有统一答案,取决于你往哪个子对象里加**:
|
|
536
|
+
// │ 非 strict → 可以先发(会丢,不会死);strict → hub 必须先合,否则升级即失联。
|
|
537
|
+
// │
|
|
538
|
+
// │ 🔴 别把这条读成「strict 更危险、都改成非 strict」。两者各有其位:
|
|
539
|
+
// │ external_schedules / side_thread_capability 是**有界快照**,strict 挡的是
|
|
540
|
+
// │ 「节点往里塞任意键」;daemon_capabilities 是**能力自述**,天然要向前兼容
|
|
541
|
+
// │ (旧 hub 见到新能力应当忽略而不是拒绝整份上报)。**加字段前先看清你在哪一边。**
|
|
542
|
+
// ╰──────────────────────────────────────────────────────────────────────────────╯
|
|
520
543
|
host: z.object({
|
|
521
544
|
// 🔴 `.nullable()` 不是防御性冗余,是**发送方声明的类型**:agent-node 的
|
|
522
545
|
// HostTelemetry 是 `ip: string | null`,`firstNonInternalIPv4()` 在没有
|
|
@@ -599,6 +622,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
599
622
|
// never saw them, max_concurrent_children stayed default + the
|
|
600
623
|
// allowlists stayed unenforced. PR3 nit ① per 通信龙).
|
|
601
624
|
// Soft caps avoid abuse via attacker daemon.
|
|
625
|
+
// 🔴 这个对象**故意不是 `.strict()`** —— 能力自述必须向前兼容:
|
|
626
|
+
// 旧 hub 见到新 daemon 报的新能力,应当忽略那一格,而不是拒掉整份 report
|
|
627
|
+
// 把节点踢下线。代价是新键在 hub 升级前会被**静默丢弃**(所以新字段仍然
|
|
628
|
+
// 应当 hub 先合)。同一份 schema 里 side_thread_capability / external_schedules
|
|
629
|
+
// 是 `.strict()` 的,后果不同 —— 见 `host:` 上方那段方框注释。
|
|
602
630
|
daemon_capabilities: z.object({
|
|
603
631
|
runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
|
|
604
632
|
allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
|
|
@@ -636,6 +664,40 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
636
664
|
"anet_bin_permission",
|
|
637
665
|
"anet_bin_unknown",
|
|
638
666
|
]).optional(),
|
|
667
|
+
// #1545 —— 上面那一格**是什么时候测出来的**。
|
|
668
|
+
//
|
|
669
|
+
// 🔴 版本号一律带包名 + 完整版本:仓里三个包各自独立编号,裸的 `preview.NN`
|
|
670
|
+
// 同时是三个不同的时间点。此处曾写成「preview.67」——
|
|
671
|
+
// 那是 agent-network 的号,agent-node 根本没有 .67(npm 404,最高 .56)。
|
|
672
|
+
// 🔴 为什么必须有:**agent-node ≤ 2.5.0-preview.54** 的 `daemonCreateCapability()`
|
|
673
|
+
// 用一个进程级缓存(`_createCapCache`)只算一次。开机时 pin 坏 ⇒ 之后
|
|
674
|
+
// 永远上报 blocked(哪怕运维已经写好 path.conf);开机时好 ⇒ 之后把二进制
|
|
675
|
+
// chmod 掉也**永远上报 ready**。后者是朝「没问题」方向说谎。
|
|
676
|
+
// 而 hub 这边,一个 3 秒前测的 blocked 和一个三周前测的 blocked,
|
|
677
|
+
// 在 `last_seen_at` 上长得一模一样 —— 心跳是新的,那一格不是。
|
|
678
|
+
//
|
|
679
|
+
// 🔴 为什么是「多久以前」(时长)而不是绝对时间戳:这个值来自**节点自己的钟**。
|
|
680
|
+
// 时钟偏移下,绝对时间戳会算出一个既可能"永远新鲜"也可能"来自 1970"的年龄,
|
|
681
|
+
// 而且错的方向不可预测。时长对偏移免疫 —— hub 用自己的钟在收到时换算。
|
|
682
|
+
//
|
|
683
|
+
// 🔴 为什么不加 `.min()/.max()/.int()`:**这几个约束都会让整条 report_status 被拒**
|
|
684
|
+
// (zod 对象里任何一个已知字段验证失败 = 整份被拒,不是丢掉这一格),
|
|
685
|
+
// 而这一格是纯诊断信息,不值得拿一台节点的在线状态去换。
|
|
686
|
+
// 收得宽,**在读取处消毒**(见 /api/host-supervisors)。
|
|
687
|
+
// 这条不是我新立的:同一个 schema 上方 `anet_bin_pin_unresolved` 那条注释
|
|
688
|
+
// 记的就是同一次教训 —— 删一个 enum 值会让所有 .40 daemon 失联。
|
|
689
|
+
//
|
|
690
|
+
// 🔴 `.catch(null)` 不是装饰:光写 `z.number()` **仍然会拒 NaN**
|
|
691
|
+
// (实测:`z.number().nullable().optional()` 对 NaN 抛),而那意味着
|
|
692
|
+
// 一次算错的时长能让整台节点在 hub 上失联 —— 正是 #1225 里 host.ip
|
|
693
|
+
// 那个形状。`.catch(null)` 让这一格**在任何输入下都不可能拒掉整份 report**:
|
|
694
|
+
// 合法数字透传,其余一律变 null(读取侧当作「没报」)。
|
|
695
|
+
// 类型仍然是 number 而不是 unknown —— 别给一个 attacker daemon
|
|
696
|
+
// 留下"往快照里塞任意大对象"的口子(同 schema 上方 soft cap 的立场)。
|
|
697
|
+
//
|
|
698
|
+
// 不上报这一格 ≠ 0。旧 daemon 压根不发,读的人必须能把
|
|
699
|
+
// 「刚测的」「很久以前测的」「不知道」分成三件事说。
|
|
700
|
+
create_capability_observed_ms_ago: z.number().nullable().catch(null).optional(),
|
|
639
701
|
}).optional(),
|
|
640
702
|
}).optional().describe("RFC-024 — masked node config snapshot"),
|
|
641
703
|
},
|
|
@@ -2426,9 +2488,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2426
2488
|
],
|
|
2427
2489
|
);
|
|
2428
2490
|
});
|
|
2429
|
-
// #1459 — pushUserEvent 在没有订阅者时静默 return
|
|
2430
|
-
// inbox
|
|
2431
|
-
//
|
|
2491
|
+
// #1459 / #1563 — pushUserEvent 在没有订阅者时静默 return。
|
|
2492
|
+
// 🔴 这段注释原先写「本工具既不写 inbox、也没有 user 级回读路径 ⇒ dashboard
|
|
2493
|
+
// 关着时消息永久没了」。**那半句已经不成立** —— 上面 20 行就在
|
|
2494
|
+
// `INSERT INTO user_inbox`,且 `/api/messages?scope=user` 能回读
|
|
2495
|
+
// (desktop-message-seam.test.ts 有真实 send → 真实 GET 的端到端断言)。
|
|
2496
|
+
// 留着它会把 debug 未读数的人直接带向错误的一层。
|
|
2432
2497
|
//
|
|
2433
2498
|
// 这里先把**丢失变可见**:`ok` 仍表示「请求被接受、审计已落库」,
|
|
2434
2499
|
// 新增 `delivered` 表示「此刻真的有人收到」。判据取自订阅者注册表,
|
|
@@ -2937,6 +3002,240 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2937
3002
|
},
|
|
2938
3003
|
);
|
|
2939
3004
|
|
|
3005
|
+
// ── app#225 — 节点规则文件(AGENTS.md / CLAUDE.md)远程读写 ──
|
|
3006
|
+
//
|
|
3007
|
+
// 形状照抄 RFC-024 config-apply:客户端调 read_node_rules_file /
|
|
3008
|
+
// write_node_rules_file 落一行 node_rules_requests + 门铃 {type:"rules_file"};
|
|
3009
|
+
// 节点收到门铃后 get_rules_file_request 拉(和 get_config_update 同一道
|
|
3010
|
+
// F-A 闸:网络 token + alias),做完 ack_rules_file_request 回报;客户端
|
|
3011
|
+
// 轮询 get_rules_file_result。
|
|
3012
|
+
//
|
|
3013
|
+
// 🔴 安全(#225 验收第 5 条):整条链路**没有路径参数**。文件名由节点按
|
|
3014
|
+
// 自己的 RUNTIME 决定(claude → CLAUDE.md,其余 → AGENTS.md),目录固定是
|
|
3015
|
+
// 节点进程的 cwd(agent-node/src/runtime/rules-file.ts)。hub 只传 op +
|
|
3016
|
+
// content,客户端连文件名都指定不了,所以不存在「借路径参数写任意文件」。
|
|
3017
|
+
// 读和写都要 canWrite:规则文件是节点行为的一部分,读它等于读节点配置。
|
|
3018
|
+
const RULES_FILE_MAX_BYTES = 256 * 1024;
|
|
3019
|
+
const RULES_REQUEST_STALE_MS = 60_000;
|
|
3020
|
+
|
|
3021
|
+
const enqueueRulesFileRequest = (
|
|
3022
|
+
op: "read" | "write",
|
|
3023
|
+
a: { node_id?: string; child_node_id?: string; network_id?: string; content?: string },
|
|
3024
|
+
) => {
|
|
3025
|
+
const idArg = resolveNodeIdArg({ node_id: a.node_id, child_node_id: a.child_node_id });
|
|
3026
|
+
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
3027
|
+
const nodeId = idArg.node_id;
|
|
3028
|
+
const effectiveNetId = getNetworkId(a.network_id);
|
|
3029
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, `${op} rules file`);
|
|
3030
|
+
|
|
3031
|
+
const { row: node, sec1Ok } = resolveTargetNode(nodeId, effectiveNetId);
|
|
3032
|
+
if (!node) {
|
|
3033
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: nodeId }) }] };
|
|
3034
|
+
}
|
|
3035
|
+
if (!sec1Ok) {
|
|
3036
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node", message: "node belongs to another network" }) }] };
|
|
3037
|
+
}
|
|
3038
|
+
if (op === "write") {
|
|
3039
|
+
if (typeof a.content !== "string") {
|
|
3040
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_content", reason: "content must be a string" }) }] };
|
|
3041
|
+
}
|
|
3042
|
+
const bytes = Buffer.byteLength(a.content, "utf8");
|
|
3043
|
+
if (bytes > RULES_FILE_MAX_BYTES) {
|
|
3044
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "content_too_large", bytes, max_bytes: RULES_FILE_MAX_BYTES }) }] };
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
// 单飞 + 陈旧回收,同 update_node_config 的 F-B:节点掉线时旧行永远
|
|
3049
|
+
// 非终态,不回收就把这个节点锁死。
|
|
3050
|
+
const inFlight = db.get<{ request_id: string; created_at: number; pulled_at: number | null }>(
|
|
3051
|
+
"SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') ORDER BY created_at DESC LIMIT 1",
|
|
3052
|
+
nodeId,
|
|
3053
|
+
);
|
|
3054
|
+
if (inFlight) {
|
|
3055
|
+
const age = Date.now() - (inFlight.pulled_at ?? inFlight.created_at);
|
|
3056
|
+
if (age <= RULES_REQUEST_STALE_MS) {
|
|
3057
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_in_flight", existing_request_id: inFlight.request_id, age_ms: age }) }] };
|
|
3058
|
+
}
|
|
3059
|
+
db.run(
|
|
3060
|
+
"UPDATE node_rules_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3",
|
|
3061
|
+
[Date.now(), `superseded after ${age}ms (> ${RULES_REQUEST_STALE_MS}ms) — node did not answer`, inFlight.request_id],
|
|
3062
|
+
);
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
const requestId = `rf_${uuidv4()}`;
|
|
3066
|
+
const networkId = node.network_id || "default";
|
|
3067
|
+
db.run(
|
|
3068
|
+
`INSERT INTO node_rules_requests (request_id, node_id, network_id, op, content, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)`,
|
|
3069
|
+
[requestId, nodeId, networkId, op, op === "write" ? a.content! : null, Date.now(), callerTokenId || "unknown"],
|
|
3070
|
+
);
|
|
3071
|
+
pushEvent(node.alias, { type: "rules_file", request_id: requestId }, networkId);
|
|
3072
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, op }) }] };
|
|
3073
|
+
};
|
|
3074
|
+
|
|
3075
|
+
server.tool(
|
|
3076
|
+
"read_node_rules_file",
|
|
3077
|
+
"Ask a node to send back its rules file (CLAUDE.md for claude nodes, AGENTS.md otherwise) from its working directory. No path argument by design. Poll get_rules_file_result with the returned request_id. app#225.",
|
|
3078
|
+
{ ...NODE_ID_ALIAS_FIELDS, network_id: z.string().max(200).optional() },
|
|
3079
|
+
async ({ node_id, child_node_id, network_id }) => enqueueRulesFileRequest("read", { node_id, child_node_id, network_id }),
|
|
3080
|
+
);
|
|
3081
|
+
|
|
3082
|
+
server.tool(
|
|
3083
|
+
"write_node_rules_file",
|
|
3084
|
+
"Ask a node to overwrite its rules file (CLAUDE.md for claude nodes, AGENTS.md otherwise) in its working directory with `content`. No path argument by design. Poll get_rules_file_result with the returned request_id. app#225.",
|
|
3085
|
+
{
|
|
3086
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3087
|
+
content: z.string().max(RULES_FILE_MAX_BYTES).describe("Full new file content (UTF-8). The node writes it atomically."),
|
|
3088
|
+
network_id: z.string().max(200).optional(),
|
|
3089
|
+
},
|
|
3090
|
+
async ({ node_id, child_node_id, content, network_id }) => enqueueRulesFileRequest("write", { node_id, child_node_id, content, network_id }),
|
|
3091
|
+
);
|
|
3092
|
+
|
|
3093
|
+
server.tool(
|
|
3094
|
+
"get_rules_file_request",
|
|
3095
|
+
"Node pulls its oldest pending rules-file request (called from agent-node when the SSE rules_file doorbell arrives). app#225.",
|
|
3096
|
+
{},
|
|
3097
|
+
async () => {
|
|
3098
|
+
// 同 get_config_update 的 F-A 闸:必须是网络 token + alias。
|
|
3099
|
+
if (!callerTokenIsNetwork || !enforceNetworkId) {
|
|
3100
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
|
|
3101
|
+
}
|
|
3102
|
+
if (!callerAlias) {
|
|
3103
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
|
|
3104
|
+
}
|
|
3105
|
+
const node = db.get<any>(
|
|
3106
|
+
"SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
3107
|
+
callerAlias,
|
|
3108
|
+
enforceNetworkId,
|
|
3109
|
+
);
|
|
3110
|
+
if (!node) {
|
|
3111
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request: null }) }] };
|
|
3112
|
+
}
|
|
3113
|
+
const req = db.get<any>(
|
|
3114
|
+
"SELECT request_id, op, content FROM node_rules_requests WHERE node_id = ?1 AND status = 'pending' ORDER BY created_at ASC LIMIT 1",
|
|
3115
|
+
node.node_id,
|
|
3116
|
+
);
|
|
3117
|
+
if (!req) {
|
|
3118
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request: null }) }] };
|
|
3119
|
+
}
|
|
3120
|
+
db.run(
|
|
3121
|
+
"UPDATE node_rules_requests SET status = 'in_progress', pulled_at = ?1 WHERE request_id = ?2 AND status = 'pending'",
|
|
3122
|
+
[Date.now(), req.request_id],
|
|
3123
|
+
);
|
|
3124
|
+
return {
|
|
3125
|
+
content: [{
|
|
3126
|
+
type: "text" as const,
|
|
3127
|
+
text: JSON.stringify({
|
|
3128
|
+
ok: true,
|
|
3129
|
+
request: {
|
|
3130
|
+
request_id: req.request_id,
|
|
3131
|
+
op: req.op,
|
|
3132
|
+
...(req.op === "write" ? { content: req.content ?? "" } : {}),
|
|
3133
|
+
},
|
|
3134
|
+
}),
|
|
3135
|
+
}],
|
|
3136
|
+
};
|
|
3137
|
+
},
|
|
3138
|
+
);
|
|
3139
|
+
|
|
3140
|
+
server.tool(
|
|
3141
|
+
"ack_rules_file_request",
|
|
3142
|
+
"Node reports the outcome of a rules-file request — done (with content for reads) or failed (with error). app#225.",
|
|
3143
|
+
{
|
|
3144
|
+
request_id: z.string().min(1).max(200),
|
|
3145
|
+
status: z.enum(["done", "failed"]),
|
|
3146
|
+
file_name: z.string().max(64).optional(),
|
|
3147
|
+
exists: z.boolean().optional(),
|
|
3148
|
+
content: z.string().max(RULES_FILE_MAX_BYTES).optional(),
|
|
3149
|
+
error: z.string().max(2000).optional(),
|
|
3150
|
+
},
|
|
3151
|
+
async ({ request_id: requestId, status, file_name: fileName, exists, content, error: ackError }) => {
|
|
3152
|
+
if (!callerTokenIsNetwork || !enforceNetworkId) {
|
|
3153
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
|
|
3154
|
+
}
|
|
3155
|
+
if (!callerAlias) {
|
|
3156
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
|
|
3157
|
+
}
|
|
3158
|
+
const node = db.get<any>(
|
|
3159
|
+
"SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
3160
|
+
callerAlias,
|
|
3161
|
+
enforceNetworkId,
|
|
3162
|
+
);
|
|
3163
|
+
if (!node) {
|
|
3164
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "alias_unknown" }) }] };
|
|
3165
|
+
}
|
|
3166
|
+
const req = db.get<any>(
|
|
3167
|
+
"SELECT request_id, node_id, status FROM node_rules_requests WHERE request_id = ?1",
|
|
3168
|
+
requestId,
|
|
3169
|
+
);
|
|
3170
|
+
// 跨租户闸:只能 ack 自己的请求;别人的当不存在处理。
|
|
3171
|
+
if (!req || req.node_id !== node.node_id) {
|
|
3172
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "unknown_or_foreign_request" }) }] };
|
|
3173
|
+
}
|
|
3174
|
+
if (req.status === "done" || req.status === "failed" || req.status === "timeout") {
|
|
3175
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "already_terminal", current_status: req.status }) }] };
|
|
3176
|
+
}
|
|
3177
|
+
db.run(
|
|
3178
|
+
`UPDATE node_rules_requests SET status = ?1, acked_at = ?2, file_name = ?3, file_exists = ?4, result_content = ?5, error = ?6 WHERE request_id = ?7`,
|
|
3179
|
+
[
|
|
3180
|
+
status,
|
|
3181
|
+
Date.now(),
|
|
3182
|
+
fileName ?? null,
|
|
3183
|
+
typeof exists === "boolean" ? (exists ? 1 : 0) : null,
|
|
3184
|
+
status === "done" ? (content ?? null) : null,
|
|
3185
|
+
status === "failed" ? (ackError || "node reported failure without a reason") : null,
|
|
3186
|
+
requestId,
|
|
3187
|
+
],
|
|
3188
|
+
);
|
|
3189
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, status }) }] };
|
|
3190
|
+
},
|
|
3191
|
+
);
|
|
3192
|
+
|
|
3193
|
+
server.tool(
|
|
3194
|
+
"get_rules_file_result",
|
|
3195
|
+
"Poll the outcome of a read_node_rules_file / write_node_rules_file request. Returns status pending|in_progress|done|failed|timeout, the file name the node used, and (for reads) the content. app#225.",
|
|
3196
|
+
{ request_id: z.string().min(1).max(200), network_id: z.string().max(200).optional() },
|
|
3197
|
+
async ({ request_id: requestId, network_id: clientNetId }) => {
|
|
3198
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
3199
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "read rules file result");
|
|
3200
|
+
const row = db.get<any>(
|
|
3201
|
+
"SELECT request_id, node_id, network_id, op, status, file_name, file_exists, result_content, error, created_at, pulled_at, acked_at FROM node_rules_requests WHERE request_id = ?1",
|
|
3202
|
+
requestId,
|
|
3203
|
+
);
|
|
3204
|
+
// SEC-1:结果行的网络必须等于调用方作用域,否则当不存在。
|
|
3205
|
+
if (!row || row.network_id !== (effectiveNetId || "default")) {
|
|
3206
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found", request_id: requestId }) }] };
|
|
3207
|
+
}
|
|
3208
|
+
let status: string = row.status;
|
|
3209
|
+
const ageMs = Date.now() - (row.pulled_at ?? row.created_at);
|
|
3210
|
+
if ((status === "pending" || status === "in_progress") && ageMs > RULES_REQUEST_STALE_MS) {
|
|
3211
|
+
status = "timeout";
|
|
3212
|
+
db.run(
|
|
3213
|
+
"UPDATE node_rules_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3 AND status IN ('pending', 'in_progress')",
|
|
3214
|
+
[Date.now(), `node did not answer within ${RULES_REQUEST_STALE_MS}ms (offline, or running an agent-node without app#225 support)`, requestId],
|
|
3215
|
+
);
|
|
3216
|
+
row.error = `node did not answer within ${RULES_REQUEST_STALE_MS}ms (offline, or running an agent-node without app#225 support)`;
|
|
3217
|
+
}
|
|
3218
|
+
return {
|
|
3219
|
+
content: [{
|
|
3220
|
+
type: "text" as const,
|
|
3221
|
+
text: JSON.stringify({
|
|
3222
|
+
ok: true,
|
|
3223
|
+
request_id: row.request_id,
|
|
3224
|
+
node_id: row.node_id,
|
|
3225
|
+
op: row.op,
|
|
3226
|
+
status,
|
|
3227
|
+
file_name: row.file_name ?? null,
|
|
3228
|
+
exists: row.file_exists === null || row.file_exists === undefined ? null : row.file_exists === 1,
|
|
3229
|
+
...(status === "done" && row.op === "read" ? { content: row.result_content ?? "" } : {}),
|
|
3230
|
+
error: row.error ?? null,
|
|
3231
|
+
age_ms: ageMs,
|
|
3232
|
+
}),
|
|
3233
|
+
}],
|
|
3234
|
+
};
|
|
3235
|
+
},
|
|
3236
|
+
);
|
|
3237
|
+
|
|
3238
|
+
|
|
2940
3239
|
server.tool(
|
|
2941
3240
|
"restart_node",
|
|
2942
3241
|
"Trigger a node restart without changing config. RFC-024 Vincent 2026-06-28 increment. Network-scoped (SEC-1); member+ role suffices (lifecycle ops are not privilege elevation).",
|
|
@@ -3233,10 +3532,63 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3233
3532
|
&& caps.create_nodes_blocked_reason.length > 0)
|
|
3234
3533
|
? caps.create_nodes_blocked_reason
|
|
3235
3534
|
: "anet_bin_unknown";
|
|
3535
|
+
// #1545 —— 光说 `blocked_reason` 不够:**那个判断是什么时候做出来的**
|
|
3536
|
+
// 决定了下一步完全不同的两件事。
|
|
3537
|
+
// 刚测的 blocked ⇒ 去那台机器按 reason 修
|
|
3538
|
+
// 三周前测的 blocked ⇒ 那台 daemon 是**开机算一次就永久缓存**的旧版本
|
|
3539
|
+
// (agent-node ≤ 2.5.0-preview.54),它可能早就好了 ——
|
|
3540
|
+
// 先重启/升级它,再谈修 pin
|
|
3541
|
+
// 而在拒绝这一刻,用户正好在决定"去修哪台机器"。
|
|
3542
|
+
//
|
|
3543
|
+
// 🔴 年龄未知时给的是**显式 null + 一个说明为什么的枚举**,不是省略这两个键。
|
|
3544
|
+
// 省略读起来像"没有这个问题",而调用方(常常是个 agent)会默认它是刚测的
|
|
3545
|
+
// —— 那是朝「更可信」方向撒谎,比不给更糟。
|
|
3546
|
+
const rawAge = caps.create_capability_observed_ms_ago;
|
|
3547
|
+
const reportedAgeOk = typeof rawAge === "number" && Number.isFinite(rawAge)
|
|
3548
|
+
&& rawAge >= 0 && rawAge <= 365 * 24 * 60 * 60 * 1000;
|
|
3549
|
+
// 🔴 daemon 报的是「测完到**这份 report 发出**之间隔了多久」,不是「到现在」。
|
|
3550
|
+
// 两者差一个心跳间隔(3 分钟),而对一个已经失联的 daemon 差多少都可能。
|
|
3551
|
+
// 直接把它当成"多久以前测的"渲染,会**朝更新鲜的方向**低报 ——
|
|
3552
|
+
// 正是这一格要防的那个方向。所以在这里补上心跳那一段:
|
|
3553
|
+
// 绝对年龄 = (now − last_seen_at) + daemon 报的时长
|
|
3554
|
+
// hub 的钟出绝对时间,daemon 只提供时长(它自己的钟偏移污染不了这个数)。
|
|
3555
|
+
//
|
|
3556
|
+
// 这是冷路径(只有被拒时才走),多一次小查询不影响心跳路径。
|
|
3557
|
+
let heartbeatAgeMs: number | null = null;
|
|
3558
|
+
try {
|
|
3559
|
+
const sess = db.get<{ last_seen_at: string | null }>(
|
|
3560
|
+
"SELECT last_seen_at FROM sessions WHERE node_id = ?1 ORDER BY last_seen_at DESC LIMIT 1",
|
|
3561
|
+
daemon_node_id,
|
|
3562
|
+
);
|
|
3563
|
+
// #1650 — 这一列是 TEXT(`datetime('now')`),UTC 但不带时区标记。
|
|
3564
|
+
// Date.parse 会按**本机时区**解析,误差 = 主机偏移(生产 hub 在 UTC+8
|
|
3565
|
+
// 时这个诊断数字整整偏 8 小时)。走 parseHubTimestamp 显式按 UTC 解。
|
|
3566
|
+
const t = parseHubTimestamp(sess?.last_seen_at);
|
|
3567
|
+
if (t !== null) heartbeatAgeMs = Math.max(0, Date.now() - t);
|
|
3568
|
+
} catch { /* 取不到就是取不到 —— 下面如实说 unknown,不猜 0 */ }
|
|
3569
|
+
|
|
3570
|
+
// 🔴 三种取值,不能塌成两种:
|
|
3571
|
+
// known 两半都有,可以给绝对年龄
|
|
3572
|
+
// unknown_legacy_daemon 那台 daemon 根本没报这一格(旧版本)
|
|
3573
|
+
// unknown_no_heartbeat_time 它报了,但 hub 这边没有可用的心跳时间
|
|
3574
|
+
// ⇒ **补不出绝对年龄**,而不是"年龄是 0"
|
|
3575
|
+
const ageKnown = reportedAgeOk && heartbeatAgeMs !== null;
|
|
3236
3576
|
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
3237
3577
|
ok: false,
|
|
3238
3578
|
error: "daemon_cannot_create_nodes",
|
|
3239
3579
|
blocked_reason: reason,
|
|
3580
|
+
// 到**现在**为止,这个判断做出来有多久了(毫秒)。null = 补不出来。
|
|
3581
|
+
capability_observed_ms_ago: ageKnown ? heartbeatAgeMs! + (rawAge as number) : null,
|
|
3582
|
+
capability_age: ageKnown
|
|
3583
|
+
? "known"
|
|
3584
|
+
: (reportedAgeOk ? "unknown_no_heartbeat_time" : "unknown_legacy_daemon"),
|
|
3585
|
+
// 🔴 这里**不复制那张 code → 修法命令 的表**。它只有一个作者,在
|
|
3586
|
+
// `@sleep2agi/agent-network` 的 daemon-capability-display —— 抄一份到 hub,
|
|
3587
|
+
// 两份就会各自漂移,而「hub 教的修法」和「CLI 教的修法」不一致时,
|
|
3588
|
+
// 没有任何东西会红。这里只把人指到那一份。
|
|
3589
|
+
// (同 `blocked_reason` 是 z.enum 而不是自由文本的立场:hub 出**代码**,
|
|
3590
|
+
// 渲染留给客户端。)
|
|
3591
|
+
remediation_hint: "run `anet daemon list` where that daemon is configured — it prints the exact, paste-able fix command for this blocked_reason",
|
|
3240
3592
|
daemon_node_id,
|
|
3241
3593
|
}) }] };
|
|
3242
3594
|
}
|
|
@@ -3679,6 +4031,38 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3679
4031
|
return row?.daemon_node_id ?? null;
|
|
3680
4032
|
};
|
|
3681
4033
|
|
|
4034
|
+
/**
|
|
4035
|
+
* 为什么这里要分两种「解析不到 daemon」(#196 / app 仓)。
|
|
4036
|
+
*
|
|
4037
|
+
* `resolveDaemonForChild` 对两件完全不同的事都返回 `null`:
|
|
4038
|
+
* ① id 不以 `node_` 开头 —— 这个节点**根本不是 daemon 创建的**,
|
|
4039
|
+
* 是有人在某台机器上直接 `anet node start` 起来的。hub 上没有任何
|
|
4040
|
+
* daemon 可以代它执行停止,**这条路径对它在概念上就不成立**。
|
|
4041
|
+
* ② id 以 `node_` 开头,但 `node_create_requests` 里查不到那一行 ——
|
|
4042
|
+
* 它可能确实是 daemon 建的,只是记录缺失,这时「显式传 daemon_node_id」
|
|
4043
|
+
* 是一条真的走得通的路。
|
|
4044
|
+
*
|
|
4045
|
+
* 🔴 原先两种情况打印同一句 `pass daemon_node_id explicitly`。对 ① 来说
|
|
4046
|
+
* **这句建议的前提是假的** —— 没有 daemon 可传,用户按它做只会走进死路。
|
|
4047
|
+
* 2026-08-28 对生产 hub 实测:218 个节点里 **207 个**是 ① 这种。
|
|
4048
|
+
*
|
|
4049
|
+
* 信息在源头就存在(那个 `startsWith("node_")` 判断),只是被丢在了返回值里。
|
|
4050
|
+
*/
|
|
4051
|
+
const explainUnresolvableDaemon = (child_node_id: string) =>
|
|
4052
|
+
child_node_id.startsWith("node_")
|
|
4053
|
+
? {
|
|
4054
|
+
ok: false, error: "daemon_not_resolvable",
|
|
4055
|
+
message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
|
|
4056
|
+
}
|
|
4057
|
+
: {
|
|
4058
|
+
ok: false, error: "not_daemon_managed",
|
|
4059
|
+
message:
|
|
4060
|
+
`node ${child_node_id} was not created by a daemon (it was started by hand with ` +
|
|
4061
|
+
`\`anet node start\` on some machine), so no daemon on the Hub can stop it. ` +
|
|
4062
|
+
`Run \`anet node stop <alias>\` on that machine instead. ` +
|
|
4063
|
+
`Daemon-created children have ids beginning with \`node_\`; this one does not.`,
|
|
4064
|
+
};
|
|
4065
|
+
|
|
3682
4066
|
const dispatchStopOrDelete = (args: DispatchArgs, clientNetId?: string | null) => {
|
|
3683
4067
|
// §4.1 — SEC-1 trust-root join: resolve target node row WITHIN the
|
|
3684
4068
|
// caller's scope. resolveReadScope returns 'denied' if the caller
|
|
@@ -3968,10 +4352,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3968
4352
|
const child_node_id = idArg.node_id;
|
|
3969
4353
|
const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
|
|
3970
4354
|
if (!resolved) {
|
|
3971
|
-
return { content: [{ type: "text" as const,
|
|
3972
|
-
|
|
3973
|
-
message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
|
|
3974
|
-
}) }] };
|
|
4355
|
+
return { content: [{ type: "text" as const,
|
|
4356
|
+
text: JSON.stringify(explainUnresolvableDaemon(child_node_id)) }] };
|
|
3975
4357
|
}
|
|
3976
4358
|
const r = dispatchStopOrDelete(
|
|
3977
4359
|
{ action: "stop", child_node_id, daemon_node_id: resolved, force: force ?? false,
|
|
@@ -4000,10 +4382,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4000
4382
|
const child_node_id = idArg.node_id;
|
|
4001
4383
|
const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
|
|
4002
4384
|
if (!resolved) {
|
|
4003
|
-
return { content: [{ type: "text" as const,
|
|
4004
|
-
|
|
4005
|
-
message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
|
|
4006
|
-
}) }] };
|
|
4385
|
+
return { content: [{ type: "text" as const,
|
|
4386
|
+
text: JSON.stringify(explainUnresolvableDaemon(child_node_id)) }] };
|
|
4007
4387
|
}
|
|
4008
4388
|
const r = dispatchStopOrDelete(
|
|
4009
4389
|
{ action: "delete", child_node_id, daemon_node_id: resolved, force: force ?? false,
|
package/src/uploads-http.test.ts
CHANGED
|
@@ -24,6 +24,10 @@ let server: any;
|
|
|
24
24
|
let userToken = "";
|
|
25
25
|
let userNetworkId = "";
|
|
26
26
|
|
|
27
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
28
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
29
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
30
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
27
31
|
beforeAll(async () => {
|
|
28
32
|
// Bootstrap a real admin + token on the temp DB so the server can
|
|
29
33
|
// resolve it via requireAuth (we don't want to short-circuit auth —
|
|
@@ -58,7 +62,7 @@ beforeAll(async () => {
|
|
|
58
62
|
BASE = `http://127.0.0.1:${server.port}`;
|
|
59
63
|
// Tiny settle so the listener is ready.
|
|
60
64
|
await new Promise((r) => setTimeout(r, 100));
|
|
61
|
-
});
|
|
65
|
+
}, 30_000);
|
|
62
66
|
|
|
63
67
|
afterAll(() => {
|
|
64
68
|
try { server?.stop?.(true); } catch {}
|