@sleep2agi/commhub-server 0.9.0-preview.43 → 0.9.0-preview.44
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.
|
|
3
|
+
"version": "0.9.0-preview.44",
|
|
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",
|
|
@@ -264,3 +264,87 @@ describe("#1353 Fix ② — /api/host-supervisors surfaces can_create_nodes + bl
|
|
|
264
264
|
expect(rows.find((r: any) => r.alias === "malformed")).toBeUndefined();
|
|
265
265
|
});
|
|
266
266
|
});
|
|
267
|
+
|
|
268
|
+
/* #1545 —— `can_create_nodes` 说的是「能不能」,这一组说的是「**那是什么时候测的**」。
|
|
269
|
+
*
|
|
270
|
+
* 为什么非要分开:agent-node ≤ 2.5.0-preview.54 用一个进程级缓存只算一次
|
|
271
|
+
* (`cli.ts` 的 `_createCapCache`)。开机时二进制好、之后被 chmod 掉 ⇒ 它会
|
|
272
|
+
* **永远上报 ready**。hub 这边,那条记录和一个 3 秒前刚测出来的 ready
|
|
273
|
+
* 在 `last_seen_at` 上一模一样 —— 心跳是新的,那一格不是。
|
|
274
|
+
*
|
|
275
|
+
* 🔴 缺席**不得**当成 0。把「没报」渲染成「刚测的」,正好朝「没问题」方向说谎,
|
|
276
|
+
* 而这正是 #1545 里比沉默更糟的那一半。
|
|
277
|
+
*/
|
|
278
|
+
describe("#1545 /api/host-supervisors —— can_create_nodes 的年龄", () => {
|
|
279
|
+
test("daemon 报了年龄 → 原样带出", async () => {
|
|
280
|
+
clearDaemons();
|
|
281
|
+
seedDaemon("age-fresh", {
|
|
282
|
+
role: "host_supervisor",
|
|
283
|
+
daemon_capabilities: {
|
|
284
|
+
can_create_nodes: false,
|
|
285
|
+
create_nodes_blocked_reason: "anet_bin_source",
|
|
286
|
+
create_capability_observed_ms_ago: 12,
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
const d = findDaemon(await listDaemons(), "age-fresh");
|
|
290
|
+
expect(d.create_capability_observed_ms_ago).toBe(12);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("🔴 daemon 没报年龄 → 这一格必须**缺席**,不能是 0/null", async () => {
|
|
294
|
+
clearDaemons();
|
|
295
|
+
seedDaemon("age-absent", {
|
|
296
|
+
role: "host_supervisor",
|
|
297
|
+
daemon_capabilities: { can_create_nodes: true },
|
|
298
|
+
});
|
|
299
|
+
const d = findDaemon(await listDaemons(), "age-absent");
|
|
300
|
+
// 能力本身照常带出 —— 加年龄这一格不许把旧 daemon 的既有信息弄丢。
|
|
301
|
+
expect(d.can_create_nodes).toBe(true);
|
|
302
|
+
expect("create_capability_observed_ms_ago" in d).toBe(false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
/* 🔴 消毒**不是 clamp**:clamp 会把一个坏值变成一个看起来正常的年龄,
|
|
306
|
+
* 而这一格存在的全部意义就是让人分辨新鲜和陈旧。坏值一律当作「没报」。 */
|
|
307
|
+
test.each([
|
|
308
|
+
["负数", -1],
|
|
309
|
+
["超过一年", 400 * 24 * 60 * 60 * 1000],
|
|
310
|
+
["null(schema 的 .catch 兜底值)", null],
|
|
311
|
+
["字符串(直接写库,绕过 schema)", "12"],
|
|
312
|
+
])("坏值当作没报,而不是 clamp 成边界:%s", async (label, value) => {
|
|
313
|
+
clearDaemons();
|
|
314
|
+
const alias = `age-bad-${String(label).slice(0, 4)}`;
|
|
315
|
+
seedDaemon(alias, {
|
|
316
|
+
role: "host_supervisor",
|
|
317
|
+
daemon_capabilities: {
|
|
318
|
+
can_create_nodes: true,
|
|
319
|
+
create_capability_observed_ms_ago: value,
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
const d = findDaemon(await listDaemons(), alias);
|
|
323
|
+
expect(d.can_create_nodes).toBe(true); // 能力仍在
|
|
324
|
+
expect("create_capability_observed_ms_ago" in d).toBe(false); // 年龄不在
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
/* 边界校准:上面用 400 天证明「太旧被丢」,这里用**刚好一年**证明门不是恒丢。
|
|
328
|
+
* 只验被丢那一侧的话,一个恒丢的实现能全绿通过。 */
|
|
329
|
+
test("边界:恰好一年被接受 ⇒ 上面那条不是因为恒丢才绿", async () => {
|
|
330
|
+
clearDaemons();
|
|
331
|
+
const oneYear = 365 * 24 * 60 * 60 * 1000;
|
|
332
|
+
seedDaemon("age-boundary", {
|
|
333
|
+
role: "host_supervisor",
|
|
334
|
+
daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: oneYear },
|
|
335
|
+
});
|
|
336
|
+
const d = findDaemon(await listDaemons(), "age-boundary");
|
|
337
|
+
expect(d.create_capability_observed_ms_ago).toBe(oneYear);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("年龄只在 can_create_nodes 存在时出现(旧 daemon 两格都没有)", async () => {
|
|
341
|
+
clearDaemons();
|
|
342
|
+
seedDaemon("age-legacy", {
|
|
343
|
+
role: "host_supervisor",
|
|
344
|
+
daemon_capabilities: { runtimes_supported: ["claude-agent-sdk"] },
|
|
345
|
+
});
|
|
346
|
+
const d = findDaemon(await listDaemons(), "age-legacy");
|
|
347
|
+
expect("can_create_nodes" in d).toBe(false);
|
|
348
|
+
expect("create_capability_observed_ms_ago" in d).toBe(false);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
@@ -31,6 +31,7 @@ function cleanup() {
|
|
|
31
31
|
try { db.run("DELETE FROM node_create_requests WHERE network_id = ?1", [NET]); } catch {}
|
|
32
32
|
try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [NET]); } catch {}
|
|
33
33
|
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET]); } catch {}
|
|
34
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET]); } catch {}
|
|
34
35
|
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET]); } catch {}
|
|
35
36
|
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
|
|
36
37
|
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
@@ -39,6 +40,18 @@ function cleanup() {
|
|
|
39
40
|
beforeEach(cleanup);
|
|
40
41
|
afterAll(cleanup);
|
|
41
42
|
|
|
43
|
+
/** #1545 —— 可选:写一条 sessions 心跳行。
|
|
44
|
+
* 默认**不写**,这样既有那 9 条测试的行为逐字不变;
|
|
45
|
+
* 只有要断言绝对年龄的用例才显式给 `heartbeatAgoMs`。 */
|
|
46
|
+
function seedHeartbeat(agoMs: number) {
|
|
47
|
+
const iso = new Date(Date.now() - agoMs).toISOString();
|
|
48
|
+
db.run(
|
|
49
|
+
`INSERT INTO sessions (alias, node_id, network_id, status, last_seen_at, updated_at)
|
|
50
|
+
VALUES (?1, ?2, ?3, 'idle', ?4, ?4)`,
|
|
51
|
+
[DAEMON_ALIAS, DAEMON_ID, NET, iso],
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
42
55
|
function seed(snapshot: object | string | null) {
|
|
43
56
|
db.run(
|
|
44
57
|
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
@@ -229,3 +242,115 @@ describe("#1353 Fix ① — hub gates create_node on daemon-reported can_create_
|
|
|
229
242
|
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
230
243
|
});
|
|
231
244
|
});
|
|
245
|
+
|
|
246
|
+
/* #1545 —— 拒绝的时候还要说**那个判断是什么时候做出来的**。
|
|
247
|
+
*
|
|
248
|
+
* 为什么这一格落在拒绝路径上最要紧:用户被挡住的这一刻,正好在决定"去修哪台机器"。
|
|
249
|
+
* 刚测的 blocked ⇒ 去那台机器按 reason 修
|
|
250
|
+
* 三周前测的 blocked ⇒ 那台 daemon 是**开机算一次就永久缓存**的旧版本
|
|
251
|
+
* (agent-node ≤ 2.5.0-preview.54),它可能早就好了 —— 先重启/升级它
|
|
252
|
+
* 只给 `blocked_reason` 的话,这两种情况给用户的字**一模一样**。 */
|
|
253
|
+
describe("#1545 拒绝文案带上「这个判断是多久以前做的」", () => {
|
|
254
|
+
const blockedSnap = (extra: object) => ({
|
|
255
|
+
role: "host_supervisor",
|
|
256
|
+
daemon_capabilities: {
|
|
257
|
+
can_create_nodes: false,
|
|
258
|
+
create_nodes_blocked_reason: "anet_bin_permission",
|
|
259
|
+
...extra,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
/* 🔴 报出去的是**到现在为止**的绝对年龄,不是 daemon 报的那个原始时长。
|
|
264
|
+
* daemon 报的是「测完到**这份 report 发出**」;直接透传会漏掉心跳那一段,
|
|
265
|
+
* **朝更新鲜的方向低报** —— 正是这一格要防的方向。
|
|
266
|
+
* 绝对年龄 = (now − last_seen_at) + daemon 报的时长 */
|
|
267
|
+
test("daemon 报了年龄 + 有心跳时间 → 补上心跳那一段,标记 known", async () => {
|
|
268
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: 12_345 }));
|
|
269
|
+
seedHeartbeat(60_000); // 上次心跳在 60s 前
|
|
270
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
271
|
+
expect(r.ok).toBe(false);
|
|
272
|
+
expect(r.blocked_reason).toBe("anet_bin_permission");
|
|
273
|
+
expect(r.capability_age).toBe("known");
|
|
274
|
+
// 允许 ±5s 的执行抖动;关键是它**明显大于** 12,345,即心跳那一段确实被加上了
|
|
275
|
+
const got = r.capability_observed_ms_ago as number;
|
|
276
|
+
expect(got).toBeGreaterThanOrEqual(12_345 + 60_000 - 5_000);
|
|
277
|
+
expect(got).toBeLessThanOrEqual(12_345 + 60_000 + 5_000);
|
|
278
|
+
// 🔴 反向锚:如果实现是"原样透传",这个数会恰好等于 12,345
|
|
279
|
+
expect(got).not.toBe(12_345);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
/* 🔴 第三种 unknown,不能和「旧 daemon 没报」塌成同一个:
|
|
283
|
+
* 它报了,但 hub 这边没有可用的心跳时间 ⇒ **补不出绝对年龄**,而不是"年龄是 0"。 */
|
|
284
|
+
test("报了年龄但 hub 没有心跳时间 → unknown_no_heartbeat_time(不是 legacy,也不是 0)", async () => {
|
|
285
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: 12_345 }));
|
|
286
|
+
// 故意不 seedHeartbeat
|
|
287
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
288
|
+
expect(r.capability_age).toBe("unknown_no_heartbeat_time");
|
|
289
|
+
expect(r.capability_observed_ms_ago).toBeNull();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
/* 🔴 本组的重点:年龄未知时给的是**显式 null + 说明为什么的枚举**,
|
|
293
|
+
* 而不是省略这两个键。省略读起来像"没有这个问题",而调用方(常常是个 agent)
|
|
294
|
+
* 会默认它是刚测的 —— 那是朝「更可信」方向撒谎,比不给更糟。 */
|
|
295
|
+
test("🔴 daemon 没报年龄 → 两个键都在:null + unknown_legacy_daemon(不是省略)", async () => {
|
|
296
|
+
seed(blockedSnap({}));
|
|
297
|
+
seedHeartbeat(1_000); // 心跳齐全 ⇒ 唯一缺的就是 daemon 没报那一格
|
|
298
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
299
|
+
expect(r.ok).toBe(false);
|
|
300
|
+
expect("capability_observed_ms_ago" in r).toBe(true);
|
|
301
|
+
expect(r.capability_observed_ms_ago).toBeNull();
|
|
302
|
+
expect(r.capability_age).toBe("unknown_legacy_daemon");
|
|
303
|
+
// 🔴 绝不能渲染成"刚测的"
|
|
304
|
+
expect(r.capability_observed_ms_ago).not.toBe(0);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test.each([
|
|
308
|
+
["负数", -1],
|
|
309
|
+
["超过一年", 400 * 24 * 60 * 60 * 1000],
|
|
310
|
+
["非数字", "12"],
|
|
311
|
+
["null", null],
|
|
312
|
+
])("坏值当作没报(不 clamp 成一个看起来正常的年龄):%s", async (_l, v) => {
|
|
313
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: v }));
|
|
314
|
+
// 🔴 必须给心跳:否则这几条会因为"没有心跳时间"而变成
|
|
315
|
+
// unknown_no_heartbeat_time,**坏值那一维根本没被测到**
|
|
316
|
+
// (夹具只该在被测的那一维上不合法)。
|
|
317
|
+
seedHeartbeat(1_000);
|
|
318
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
319
|
+
expect(r.capability_age).toBe("unknown_legacy_daemon");
|
|
320
|
+
expect(r.capability_observed_ms_ago).toBeNull();
|
|
321
|
+
// 分母自证:拒绝本身仍然发生了,不是因为整条路径没走到才"没有年龄"
|
|
322
|
+
expect(r.error).toBe("daemon_cannot_create_nodes");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("边界:恰好一年被接受 ⇒ 上面那些不是因为恒判 unknown 才绿", async () => {
|
|
326
|
+
const oneYear = 365 * 24 * 60 * 60 * 1000;
|
|
327
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: oneYear }));
|
|
328
|
+
seedHeartbeat(1_000);
|
|
329
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
330
|
+
expect(r.capability_age).toBe("known");
|
|
331
|
+
expect(r.capability_observed_ms_ago as number).toBeGreaterThanOrEqual(oneYear);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
/* 🔴 hub 出**代码**,不出渲染好的修法命令 —— 那张 code → 命令 的表只有一个作者
|
|
335
|
+
* (@sleep2agi/agent-network 的 daemon-capability-display)。抄一份到 hub,
|
|
336
|
+
* 两份就会各自漂移,而「hub 教的修法」和「CLI 教的修法」不一致时没有任何东西会红。 */
|
|
337
|
+
test("🔴 hub 不返回具体修法命令,只把人指到那唯一一份", async () => {
|
|
338
|
+
seed(blockedSnap({ create_capability_observed_ms_ago: 0 }));
|
|
339
|
+
seedHeartbeat(1_000);
|
|
340
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
341
|
+
const blob = JSON.stringify(r);
|
|
342
|
+
for (const leaked of ["chmod go-w", "npm i -g", "readlink -f", "/etc/anet-daemon"]) {
|
|
343
|
+
expect(blob).not.toContain(leaked);
|
|
344
|
+
}
|
|
345
|
+
expect(String(r.remediation_hint)).toContain("anet daemon list");
|
|
346
|
+
// 正控:这四条不是恒真 —— 它们确实是那份表里的真实内容
|
|
347
|
+
expect("该二进制 group/other 可写。一行修:chmod go-w \"$(command -v anet)\"").toContain("chmod go-w");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test("健康 daemon 不受影响:正常派发,不带这两个键", async () => {
|
|
351
|
+
seed({ role: "host_supervisor", daemon_capabilities: { can_create_nodes: true } });
|
|
352
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
353
|
+
expect(r.ok).not.toBe(false);
|
|
354
|
+
expect("capability_age" in r).toBe(false);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
@@ -74,3 +74,138 @@ describe("report_status host telemetry schema", () => {
|
|
|
74
74
|
})).not.toThrow();
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
|
+
|
|
78
|
+
/* #1545 —— `create_capability_observed_ms_ago` 的**验证宽度**本身就是判据。
|
|
79
|
+
*
|
|
80
|
+
* 这一格是纯诊断信息(那个 can_create_nodes 是多久以前测出来的)。zod 对象里
|
|
81
|
+
* 任何一个**已知字段**验证失败,整份 report_status 都会被拒 —— 不是丢掉这一格。
|
|
82
|
+
* 所以给它加 `.int()/.min()/.max()` 等于:一台节点发了个奇怪的诊断数字,
|
|
83
|
+
* 就在 hub 上整个失联。**#1225/#1498 已经用 host.ip 演过一遍这件事了。**
|
|
84
|
+
*
|
|
85
|
+
* 消毒在读取处(`/api/host-supervisors`),不在这里。 */
|
|
86
|
+
describe("#1545 create_capability_observed_ms_ago —— schema 必须收得宽", () => {
|
|
87
|
+
const caps = () => schemas.report_status.config_snapshot;
|
|
88
|
+
|
|
89
|
+
test("前提:抓到的确实是 config_snapshot 形状,且它接受 daemon_capabilities", () => {
|
|
90
|
+
expect(caps()).toBeTruthy();
|
|
91
|
+
const parsed = caps().parse({
|
|
92
|
+
flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
|
|
93
|
+
daemon_capabilities: { can_create_nodes: true },
|
|
94
|
+
});
|
|
95
|
+
expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("正常值:透传", () => {
|
|
99
|
+
const parsed = caps().parse({
|
|
100
|
+
flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
|
|
101
|
+
daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: 0 },
|
|
102
|
+
});
|
|
103
|
+
expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("null 必须被接受(显式「我没测过」和「我不发这一格」都要能表达)", () => {
|
|
107
|
+
const parsed = caps().parse({
|
|
108
|
+
flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
|
|
109
|
+
daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: null },
|
|
110
|
+
});
|
|
111
|
+
expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/* 🔴 这四个值**一个都不许让整份 report 被拒**。它们在读取侧会被当成「没报」,
|
|
115
|
+
* 但那是读取侧的事 —— 这一层的职责只有一个:别把节点踢下线。 */
|
|
116
|
+
test.each([
|
|
117
|
+
["负数", -1],
|
|
118
|
+
["非整数", 1234.5678],
|
|
119
|
+
["超过一年", 400 * 24 * 60 * 60 * 1000],
|
|
120
|
+
["NaN", Number.NaN],
|
|
121
|
+
])("怪值不得拒掉整份 report_status:%s", (_label, value) => {
|
|
122
|
+
expect(() => caps().parse({
|
|
123
|
+
flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
|
|
124
|
+
daemon_capabilities: { can_create_nodes: false,
|
|
125
|
+
create_nodes_blocked_reason: "anet_bin_source",
|
|
126
|
+
create_capability_observed_ms_ago: value },
|
|
127
|
+
})).not.toThrow();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/* 🔴 反向锚:上面那组「不抛」如果只是因为**这个 key 压根没被 schema 看见**,
|
|
131
|
+
* 它们会一样绿。所以这里证明 schema 确实在看它:喂一个非数字,值必须**被改写成
|
|
132
|
+
* null**(而不是原样穿过去)。原样穿过去 = key 被忽略,那组测试就什么也没测。 */
|
|
133
|
+
test("反向锚:非数字被改写成 null(不是原样穿过)⇒ 证明 schema 确实在看这个 key", () => {
|
|
134
|
+
const parsed = caps().parse({
|
|
135
|
+
flags: {}, config_update_capable: false, peer_reply_inbox_capable: true,
|
|
136
|
+
daemon_capabilities: { can_create_nodes: true, create_capability_observed_ms_ago: "0" },
|
|
137
|
+
});
|
|
138
|
+
expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).toBeNull();
|
|
139
|
+
expect(parsed.daemon_capabilities.create_capability_observed_ms_ago).not.toBe("0");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/* 🔴 #1545 —— 把 `host:` 上方那段方框注释里的表**钉住**。
|
|
144
|
+
*
|
|
145
|
+
* 那段注释说的是:这份 schema 的子对象一半 strict 一半不 strict,而「往里加一个
|
|
146
|
+
* hub 还不认识的键」的后果完全相反 —— 非 strict 静默丢弃(节点活着、字段蒸发),
|
|
147
|
+
* strict 拒掉整份 report(节点当场失联,#1225 的形状)。
|
|
148
|
+
*
|
|
149
|
+
* 注释会烂。这组测试的作用**不是禁止改 strict**(两种都各有其位),而是:谁改了,
|
|
150
|
+
* 谁就会在这里撞红,从而**被迫连注释一起改**。所以断言写成「和注释里那张表一致」,
|
|
151
|
+
* 而不是「必须是某个值」。 */
|
|
152
|
+
describe("#1545 report_status 子对象的 strict 分布(注释里那张表的可执行副本)", () => {
|
|
153
|
+
const rs = () => schemas.report_status;
|
|
154
|
+
const UNKNOWN = { __hub_does_not_know_this_key__: 1 };
|
|
155
|
+
|
|
156
|
+
function acceptsUnknownKey(schema: any, base: object): "dropped" | "rejected" {
|
|
157
|
+
try {
|
|
158
|
+
const parsed = schema.parse({ ...base, ...UNKNOWN });
|
|
159
|
+
return "__hub_does_not_know_this_key__" in parsed ? "rejected" /* 不可能:留着说明它根本没被处理 */ : "dropped";
|
|
160
|
+
} catch { return "rejected"; }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const SIDE_THREAD = {
|
|
164
|
+
supported: true, runtime: "codex", runtimeVersion: "1.0",
|
|
165
|
+
topology: "native-exact-fork", evidenceRevision: "r1",
|
|
166
|
+
};
|
|
167
|
+
const SNAP_BASE = { flags: {}, config_update_capable: false, peer_reply_inbox_capable: true };
|
|
168
|
+
|
|
169
|
+
test.each([
|
|
170
|
+
["host", () => rs().host, {}, "dropped"],
|
|
171
|
+
["process_telemetry", () => rs().process_telemetry, {}, "dropped"],
|
|
172
|
+
["config_snapshot", () => rs().config_snapshot, SNAP_BASE, "dropped"],
|
|
173
|
+
["external_schedules", () => rs().external_schedules, { schedules: [] }, "rejected"],
|
|
174
|
+
])("%s → 未知键被 %s", (_name, get, base, expected) => {
|
|
175
|
+
expect(acceptsUnknownKey(get(), base)).toBe(expected as string);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("daemon_capabilities(嵌在 config_snapshot 里)→ 未知键被丢弃,不拒整份", () => {
|
|
179
|
+
const parsed: any = rs().config_snapshot.parse({
|
|
180
|
+
...SNAP_BASE,
|
|
181
|
+
daemon_capabilities: { can_create_nodes: true, ...UNKNOWN },
|
|
182
|
+
});
|
|
183
|
+
expect(parsed.daemon_capabilities.can_create_nodes).toBe(true);
|
|
184
|
+
expect("__hub_does_not_know_this_key__" in parsed.daemon_capabilities).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("side_thread_capability(同一个 config_snapshot 里)→ 未知键拒掉**整份**", () => {
|
|
188
|
+
expect(() => rs().config_snapshot.parse({
|
|
189
|
+
...SNAP_BASE,
|
|
190
|
+
side_thread_capability: { ...SIDE_THREAD, ...UNKNOWN },
|
|
191
|
+
})).toThrow();
|
|
192
|
+
// 反向锚:去掉那个未知键必须过 —— 否则上面那条可能是被别的必填字段拒的,
|
|
193
|
+
// 和 strict 无关。
|
|
194
|
+
expect(() => rs().config_snapshot.parse({
|
|
195
|
+
...SNAP_BASE, side_thread_capability: SIDE_THREAD,
|
|
196
|
+
})).not.toThrow();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/* 分母自证:上面这张表**两侧都有**。如果哪天全变成 dropped(或全 rejected),
|
|
200
|
+
* 那段注释描述的「不对称」就不存在了,而这条会先红。 */
|
|
201
|
+
test("这张表确实是不对称的(两种后果各至少一个)", () => {
|
|
202
|
+
const results = [
|
|
203
|
+
acceptsUnknownKey(rs().host, {}),
|
|
204
|
+
acceptsUnknownKey(rs().process_telemetry, {}),
|
|
205
|
+
acceptsUnknownKey(rs().config_snapshot, SNAP_BASE),
|
|
206
|
+
acceptsUnknownKey(rs().external_schedules, { schedules: [] }),
|
|
207
|
+
];
|
|
208
|
+
expect(results.filter(r => r === "dropped").length).toBeGreaterThan(0);
|
|
209
|
+
expect(results.filter(r => r === "rejected").length).toBeGreaterThan(0);
|
|
210
|
+
});
|
|
211
|
+
});
|
package/src/server.ts
CHANGED
|
@@ -3152,6 +3152,12 @@ return Bun.serve({
|
|
|
3152
3152
|
// upgraded past preview.55.
|
|
3153
3153
|
let snapCanCreate: boolean | undefined;
|
|
3154
3154
|
let snapBlockedReason: string | undefined;
|
|
3155
|
+
// #1545 —— 见 report_status schema 里 create_capability_observed_ms_ago 的注释:
|
|
3156
|
+
// 上报侧**故意收得宽**(无 int/min/max,免得一个诊断字段能让整份 report 被拒),
|
|
3157
|
+
// 消毒放在这里。非有限数、负数、超过 1 年的一律当作「没报」——
|
|
3158
|
+
// 🔴 不是 clamp 成边界值:clamp 会把一个坏值变成一个**看起来正常的年龄**,
|
|
3159
|
+
// 而这一格存在的全部意义就是让人分辨新鲜和陈旧。
|
|
3160
|
+
let snapObservedMsAgo: number | undefined;
|
|
3155
3161
|
if (r.config_snapshot) {
|
|
3156
3162
|
try {
|
|
3157
3163
|
const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
|
|
@@ -3164,12 +3170,18 @@ return Bun.serve({
|
|
|
3164
3170
|
&& caps.create_nodes_blocked_reason.length > 0) {
|
|
3165
3171
|
snapBlockedReason = caps.create_nodes_blocked_reason;
|
|
3166
3172
|
}
|
|
3173
|
+
const rawAge = caps?.create_capability_observed_ms_ago;
|
|
3174
|
+
if (typeof rawAge === "number" && Number.isFinite(rawAge)
|
|
3175
|
+
&& rawAge >= 0 && rawAge <= 365 * 24 * 60 * 60 * 1000) {
|
|
3176
|
+
snapObservedMsAgo = rawAge;
|
|
3177
|
+
}
|
|
3167
3178
|
} catch { /* malformed */ }
|
|
3168
3179
|
}
|
|
3169
|
-
return { row: r, role: snapRole, canCreate: snapCanCreate, blockedReason: snapBlockedReason
|
|
3180
|
+
return { row: r, role: snapRole, canCreate: snapCanCreate, blockedReason: snapBlockedReason,
|
|
3181
|
+
observedMsAgo: snapObservedMsAgo };
|
|
3170
3182
|
})
|
|
3171
3183
|
.filter(({ role }) => role === "host_supervisor")
|
|
3172
|
-
.map(({ row: r, canCreate, blockedReason }) => {
|
|
3184
|
+
.map(({ row: r, canCreate, blockedReason, observedMsAgo }) => {
|
|
3173
3185
|
let online = false;
|
|
3174
3186
|
let lastSeenAt: string | null = null;
|
|
3175
3187
|
if (r.session_last_seen) {
|
|
@@ -3221,6 +3233,18 @@ return Bun.serve({
|
|
|
3221
3233
|
if (canCreate === false && blockedReason) {
|
|
3222
3234
|
out.create_nodes_blocked_reason = blockedReason;
|
|
3223
3235
|
}
|
|
3236
|
+
// #1545 —— 只在 daemon 真的报了这一格时才出现。
|
|
3237
|
+
// 🔴 缺席**不能**当成 0:那等于替一个从不重算的旧 daemon 宣称
|
|
3238
|
+
// 「这是刚测的」,正好朝「没问题」方向说谎。缺席就让它缺席,
|
|
3239
|
+
// 读的人据此说「年龄未知」(与 can_create_nodes 本身
|
|
3240
|
+
// undefined-vs-false 的处理同一条规矩,见上面 #1353 Fix ②)。
|
|
3241
|
+
//
|
|
3242
|
+
// 语义:该能力值是在**这份 report 发出前 N 毫秒**测得的。
|
|
3243
|
+
// 绝对年龄 = (now - last_seen_at) + create_capability_observed_ms_ago。
|
|
3244
|
+
// 用节点自己的钟只量了「多久以前」,绝对时间全部由 hub 的钟出。
|
|
3245
|
+
if (typeof observedMsAgo === "number") {
|
|
3246
|
+
out.create_capability_observed_ms_ago = observedMsAgo;
|
|
3247
|
+
}
|
|
3224
3248
|
}
|
|
3225
3249
|
return out;
|
|
3226
3250
|
});
|
package/src/tools.ts
CHANGED
|
@@ -517,6 +517,28 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
517
517
|
model: z.string().max(200).optional().describe("AI model name"),
|
|
518
518
|
node_name: z.string().max(200).optional().describe("Stable node display name (may differ from alias)"),
|
|
519
519
|
network_id: z.string().max(200).optional().describe("Network this agent belongs to"),
|
|
520
|
+
// ╭─ 🔴 往 report_status 里加字段之前先读这一段(#1545 实测,zod 4.3.6)─────────╮
|
|
521
|
+
// │ **这份 schema 的子对象一半严一半松,而后果完全不同。**
|
|
522
|
+
// │
|
|
523
|
+
// │ 非 strict:host / process_telemetry / config_snapshot / daemon_capabilities
|
|
524
|
+
// │ `.strict()`:external_schedules(含其 schedules[] 元素)
|
|
525
|
+
// │ side_thread_capability(含其 exactBoundary)
|
|
526
|
+
// │
|
|
527
|
+
// │ 往**非 strict** 的对象里加一个 hub 还不认识的键 ⇒ zod **静默丢弃**。
|
|
528
|
+
// │ 节点不会掉线,但字段人间蒸发 —— 现场表现为「daemon 明明发了、hub 上没有」,
|
|
529
|
+
// │ 排查起来和 daemon 侧 bug 一模一样。
|
|
530
|
+
// │ 往**strict** 的对象里加同一个键 ⇒ `unrecognized_keys`,**整份 report_status 被拒**。
|
|
531
|
+
// │ 而 agent-node 的 register() 是 `await` 且无人 catch ⇒ **节点进程当场死掉**
|
|
532
|
+
// │ (#1225 就是这么全网躺倒的,见下面 host.ip 那段)。
|
|
533
|
+
// │
|
|
534
|
+
// │ 所以「新字段能不能 daemon 先发」**没有统一答案,取决于你往哪个子对象里加**:
|
|
535
|
+
// │ 非 strict → 可以先发(会丢,不会死);strict → hub 必须先合,否则升级即失联。
|
|
536
|
+
// │
|
|
537
|
+
// │ 🔴 别把这条读成「strict 更危险、都改成非 strict」。两者各有其位:
|
|
538
|
+
// │ external_schedules / side_thread_capability 是**有界快照**,strict 挡的是
|
|
539
|
+
// │ 「节点往里塞任意键」;daemon_capabilities 是**能力自述**,天然要向前兼容
|
|
540
|
+
// │ (旧 hub 见到新能力应当忽略而不是拒绝整份上报)。**加字段前先看清你在哪一边。**
|
|
541
|
+
// ╰──────────────────────────────────────────────────────────────────────────────╯
|
|
520
542
|
host: z.object({
|
|
521
543
|
// 🔴 `.nullable()` 不是防御性冗余,是**发送方声明的类型**:agent-node 的
|
|
522
544
|
// HostTelemetry 是 `ip: string | null`,`firstNonInternalIPv4()` 在没有
|
|
@@ -599,6 +621,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
599
621
|
// never saw them, max_concurrent_children stayed default + the
|
|
600
622
|
// allowlists stayed unenforced. PR3 nit ① per 通信龙).
|
|
601
623
|
// Soft caps avoid abuse via attacker daemon.
|
|
624
|
+
// 🔴 这个对象**故意不是 `.strict()`** —— 能力自述必须向前兼容:
|
|
625
|
+
// 旧 hub 见到新 daemon 报的新能力,应当忽略那一格,而不是拒掉整份 report
|
|
626
|
+
// 把节点踢下线。代价是新键在 hub 升级前会被**静默丢弃**(所以新字段仍然
|
|
627
|
+
// 应当 hub 先合)。同一份 schema 里 side_thread_capability / external_schedules
|
|
628
|
+
// 是 `.strict()` 的,后果不同 —— 见 `host:` 上方那段方框注释。
|
|
602
629
|
daemon_capabilities: z.object({
|
|
603
630
|
runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
|
|
604
631
|
allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
|
|
@@ -636,6 +663,40 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
636
663
|
"anet_bin_permission",
|
|
637
664
|
"anet_bin_unknown",
|
|
638
665
|
]).optional(),
|
|
666
|
+
// #1545 —— 上面那一格**是什么时候测出来的**。
|
|
667
|
+
//
|
|
668
|
+
// 🔴 版本号一律带包名 + 完整版本:仓里三个包各自独立编号,裸的 `preview.NN`
|
|
669
|
+
// 同时是三个不同的时间点。此处曾写成「preview.67」——
|
|
670
|
+
// 那是 agent-network 的号,agent-node 根本没有 .67(npm 404,最高 .56)。
|
|
671
|
+
// 🔴 为什么必须有:**agent-node ≤ 2.5.0-preview.54** 的 `daemonCreateCapability()`
|
|
672
|
+
// 用一个进程级缓存(`_createCapCache`)只算一次。开机时 pin 坏 ⇒ 之后
|
|
673
|
+
// 永远上报 blocked(哪怕运维已经写好 path.conf);开机时好 ⇒ 之后把二进制
|
|
674
|
+
// chmod 掉也**永远上报 ready**。后者是朝「没问题」方向说谎。
|
|
675
|
+
// 而 hub 这边,一个 3 秒前测的 blocked 和一个三周前测的 blocked,
|
|
676
|
+
// 在 `last_seen_at` 上长得一模一样 —— 心跳是新的,那一格不是。
|
|
677
|
+
//
|
|
678
|
+
// 🔴 为什么是「多久以前」(时长)而不是绝对时间戳:这个值来自**节点自己的钟**。
|
|
679
|
+
// 时钟偏移下,绝对时间戳会算出一个既可能"永远新鲜"也可能"来自 1970"的年龄,
|
|
680
|
+
// 而且错的方向不可预测。时长对偏移免疫 —— hub 用自己的钟在收到时换算。
|
|
681
|
+
//
|
|
682
|
+
// 🔴 为什么不加 `.min()/.max()/.int()`:**这几个约束都会让整条 report_status 被拒**
|
|
683
|
+
// (zod 对象里任何一个已知字段验证失败 = 整份被拒,不是丢掉这一格),
|
|
684
|
+
// 而这一格是纯诊断信息,不值得拿一台节点的在线状态去换。
|
|
685
|
+
// 收得宽,**在读取处消毒**(见 /api/host-supervisors)。
|
|
686
|
+
// 这条不是我新立的:同一个 schema 上方 `anet_bin_pin_unresolved` 那条注释
|
|
687
|
+
// 记的就是同一次教训 —— 删一个 enum 值会让所有 .40 daemon 失联。
|
|
688
|
+
//
|
|
689
|
+
// 🔴 `.catch(null)` 不是装饰:光写 `z.number()` **仍然会拒 NaN**
|
|
690
|
+
// (实测:`z.number().nullable().optional()` 对 NaN 抛),而那意味着
|
|
691
|
+
// 一次算错的时长能让整台节点在 hub 上失联 —— 正是 #1225 里 host.ip
|
|
692
|
+
// 那个形状。`.catch(null)` 让这一格**在任何输入下都不可能拒掉整份 report**:
|
|
693
|
+
// 合法数字透传,其余一律变 null(读取侧当作「没报」)。
|
|
694
|
+
// 类型仍然是 number 而不是 unknown —— 别给一个 attacker daemon
|
|
695
|
+
// 留下"往快照里塞任意大对象"的口子(同 schema 上方 soft cap 的立场)。
|
|
696
|
+
//
|
|
697
|
+
// 不上报这一格 ≠ 0。旧 daemon 压根不发,读的人必须能把
|
|
698
|
+
// 「刚测的」「很久以前测的」「不知道」分成三件事说。
|
|
699
|
+
create_capability_observed_ms_ago: z.number().nullable().catch(null).optional(),
|
|
639
700
|
}).optional(),
|
|
640
701
|
}).optional().describe("RFC-024 — masked node config snapshot"),
|
|
641
702
|
},
|
|
@@ -3233,10 +3294,60 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3233
3294
|
&& caps.create_nodes_blocked_reason.length > 0)
|
|
3234
3295
|
? caps.create_nodes_blocked_reason
|
|
3235
3296
|
: "anet_bin_unknown";
|
|
3297
|
+
// #1545 —— 光说 `blocked_reason` 不够:**那个判断是什么时候做出来的**
|
|
3298
|
+
// 决定了下一步完全不同的两件事。
|
|
3299
|
+
// 刚测的 blocked ⇒ 去那台机器按 reason 修
|
|
3300
|
+
// 三周前测的 blocked ⇒ 那台 daemon 是**开机算一次就永久缓存**的旧版本
|
|
3301
|
+
// (agent-node ≤ 2.5.0-preview.54),它可能早就好了 ——
|
|
3302
|
+
// 先重启/升级它,再谈修 pin
|
|
3303
|
+
// 而在拒绝这一刻,用户正好在决定"去修哪台机器"。
|
|
3304
|
+
//
|
|
3305
|
+
// 🔴 年龄未知时给的是**显式 null + 一个说明为什么的枚举**,不是省略这两个键。
|
|
3306
|
+
// 省略读起来像"没有这个问题",而调用方(常常是个 agent)会默认它是刚测的
|
|
3307
|
+
// —— 那是朝「更可信」方向撒谎,比不给更糟。
|
|
3308
|
+
const rawAge = caps.create_capability_observed_ms_ago;
|
|
3309
|
+
const reportedAgeOk = typeof rawAge === "number" && Number.isFinite(rawAge)
|
|
3310
|
+
&& rawAge >= 0 && rawAge <= 365 * 24 * 60 * 60 * 1000;
|
|
3311
|
+
// 🔴 daemon 报的是「测完到**这份 report 发出**之间隔了多久」,不是「到现在」。
|
|
3312
|
+
// 两者差一个心跳间隔(3 分钟),而对一个已经失联的 daemon 差多少都可能。
|
|
3313
|
+
// 直接把它当成"多久以前测的"渲染,会**朝更新鲜的方向**低报 ——
|
|
3314
|
+
// 正是这一格要防的那个方向。所以在这里补上心跳那一段:
|
|
3315
|
+
// 绝对年龄 = (now − last_seen_at) + daemon 报的时长
|
|
3316
|
+
// hub 的钟出绝对时间,daemon 只提供时长(它自己的钟偏移污染不了这个数)。
|
|
3317
|
+
//
|
|
3318
|
+
// 这是冷路径(只有被拒时才走),多一次小查询不影响心跳路径。
|
|
3319
|
+
let heartbeatAgeMs: number | null = null;
|
|
3320
|
+
try {
|
|
3321
|
+
const sess = db.get<{ last_seen_at: string | null }>(
|
|
3322
|
+
"SELECT last_seen_at FROM sessions WHERE node_id = ?1 ORDER BY last_seen_at DESC LIMIT 1",
|
|
3323
|
+
daemon_node_id,
|
|
3324
|
+
);
|
|
3325
|
+
const t = sess?.last_seen_at ? Date.parse(sess.last_seen_at) : NaN;
|
|
3326
|
+
if (Number.isFinite(t)) heartbeatAgeMs = Math.max(0, Date.now() - t);
|
|
3327
|
+
} catch { /* 取不到就是取不到 —— 下面如实说 unknown,不猜 0 */ }
|
|
3328
|
+
|
|
3329
|
+
// 🔴 三种取值,不能塌成两种:
|
|
3330
|
+
// known 两半都有,可以给绝对年龄
|
|
3331
|
+
// unknown_legacy_daemon 那台 daemon 根本没报这一格(旧版本)
|
|
3332
|
+
// unknown_no_heartbeat_time 它报了,但 hub 这边没有可用的心跳时间
|
|
3333
|
+
// ⇒ **补不出绝对年龄**,而不是"年龄是 0"
|
|
3334
|
+
const ageKnown = reportedAgeOk && heartbeatAgeMs !== null;
|
|
3236
3335
|
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
3237
3336
|
ok: false,
|
|
3238
3337
|
error: "daemon_cannot_create_nodes",
|
|
3239
3338
|
blocked_reason: reason,
|
|
3339
|
+
// 到**现在**为止,这个判断做出来有多久了(毫秒)。null = 补不出来。
|
|
3340
|
+
capability_observed_ms_ago: ageKnown ? heartbeatAgeMs! + (rawAge as number) : null,
|
|
3341
|
+
capability_age: ageKnown
|
|
3342
|
+
? "known"
|
|
3343
|
+
: (reportedAgeOk ? "unknown_no_heartbeat_time" : "unknown_legacy_daemon"),
|
|
3344
|
+
// 🔴 这里**不复制那张 code → 修法命令 的表**。它只有一个作者,在
|
|
3345
|
+
// `@sleep2agi/agent-network` 的 daemon-capability-display —— 抄一份到 hub,
|
|
3346
|
+
// 两份就会各自漂移,而「hub 教的修法」和「CLI 教的修法」不一致时,
|
|
3347
|
+
// 没有任何东西会红。这里只把人指到那一份。
|
|
3348
|
+
// (同 `blocked_reason` 是 z.enum 而不是自由文本的立场:hub 出**代码**,
|
|
3349
|
+
// 渲染留给客户端。)
|
|
3350
|
+
remediation_hint: "run `anet daemon list` where that daemon is configured — it prints the exact, paste-able fix command for this blocked_reason",
|
|
3240
3351
|
daemon_node_id,
|
|
3241
3352
|
}) }] };
|
|
3242
3353
|
}
|