@sleep2agi/commhub-server 0.9.0-preview.42 → 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 +1 -1
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/db.ts +76 -1
- package/src/report-status-host-schema.test.ts +135 -0
- package/src/server.ts +66 -3
- package/src/tools.ts +146 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +22 -19
- package/src/user-inbox-schema.test.ts +3 -2
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { registerTools } from "./tools.js";
|
|
5
|
+
|
|
6
|
+
// #1353 Fix ① — hub gates create_node dispatch on the daemon's
|
|
7
|
+
// self-reported can_create_nodes bit. Before this, daemon reported
|
|
8
|
+
// can_create_nodes=false (via #1371 + #1377) → hub stored it → nothing
|
|
9
|
+
// read it → dispatch returned {ok:true, request_id} and pushed a
|
|
10
|
+
// doorbell to a daemon that had already told hub it couldn't create
|
|
11
|
+
// nodes. That's the exact "在线但静默失败" case the issue title reports.
|
|
12
|
+
//
|
|
13
|
+
// Vincent 2026-08-28 comment on #1353:
|
|
14
|
+
// "daemon 侧确知不可用时,hub 应当**拒绝派发**并返回可操作的错误,
|
|
15
|
+
// 而不是发一个注定失败的 doorbell"
|
|
16
|
+
//
|
|
17
|
+
// 🔴 Pre-#1371 daemons (preview.55 and earlier) don't report the field
|
|
18
|
+
// at all. That must NOT fail-closed — the guard is "known-blocked",
|
|
19
|
+
// not "unknown-treated-as-blocked". Tested explicitly below.
|
|
20
|
+
|
|
21
|
+
const NET = "net_1353_gate";
|
|
22
|
+
const USER_ID = "u_1353_admin";
|
|
23
|
+
const DAEMON_ID = "node_1353_daemon";
|
|
24
|
+
const DAEMON_ALIAS = "gate-daemon";
|
|
25
|
+
const DAEMON_TOK = "tok_1353_daemon";
|
|
26
|
+
|
|
27
|
+
interface ToolHandler { (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>; }
|
|
28
|
+
interface Reply { ok?: boolean; error?: string; blocked_reason?: string; request_id?: string; [k: string]: unknown; }
|
|
29
|
+
|
|
30
|
+
function cleanup() {
|
|
31
|
+
try { db.run("DELETE FROM node_create_requests WHERE network_id = ?1", [NET]); } catch {}
|
|
32
|
+
try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [NET]); } catch {}
|
|
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 {}
|
|
35
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET]); } catch {}
|
|
36
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
|
|
37
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
38
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
|
|
39
|
+
}
|
|
40
|
+
beforeEach(cleanup);
|
|
41
|
+
afterAll(cleanup);
|
|
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
|
+
|
|
55
|
+
function seed(snapshot: object | string | null) {
|
|
56
|
+
db.run(
|
|
57
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
58
|
+
VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
59
|
+
[USER_ID, USER_ID],
|
|
60
|
+
);
|
|
61
|
+
db.run(
|
|
62
|
+
`INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at)
|
|
63
|
+
VALUES (?1, ?1, ?2, datetime('now'))`,
|
|
64
|
+
[NET, USER_ID],
|
|
65
|
+
);
|
|
66
|
+
db.run(
|
|
67
|
+
`INSERT INTO network_members (user_id, network_id, role, joined_at)
|
|
68
|
+
VALUES (?1, ?2, 'admin', datetime('now'))`,
|
|
69
|
+
[USER_ID, NET],
|
|
70
|
+
);
|
|
71
|
+
const snap = snapshot === null
|
|
72
|
+
? null
|
|
73
|
+
: (typeof snapshot === "string" ? snapshot : JSON.stringify(snapshot));
|
|
74
|
+
db.run(
|
|
75
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
|
|
76
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now'), datetime('now'), 'active')`,
|
|
77
|
+
[DAEMON_ID, DAEMON_ALIAS, DAEMON_ALIAS, NET, snap, `host-${DAEMON_ALIAS}`],
|
|
78
|
+
);
|
|
79
|
+
db.run(
|
|
80
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
81
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
82
|
+
[DAEMON_TOK, USER_ID, NET, `node:${DAEMON_ALIAS}`, `hash_${DAEMON_TOK}`],
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function tools(): Record<string, ToolHandler> {
|
|
87
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
88
|
+
const out: Record<string, ToolHandler> = {};
|
|
89
|
+
const origTool = server.tool.bind(server);
|
|
90
|
+
server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
|
|
91
|
+
out[name] = handler;
|
|
92
|
+
return origTool(name, _desc, _schema, handler);
|
|
93
|
+
};
|
|
94
|
+
const origRegisterTool = server.registerTool?.bind(server);
|
|
95
|
+
if (origRegisterTool) {
|
|
96
|
+
server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
|
|
97
|
+
out[name] = handler;
|
|
98
|
+
return origRegisterTool(name, _cfg, handler);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
registerTools(
|
|
102
|
+
server, undefined,
|
|
103
|
+
/* enforceNetworkId */ NET,
|
|
104
|
+
/* enforceUserId */ USER_ID,
|
|
105
|
+
/* callerAlias */ null,
|
|
106
|
+
/* callerTokenIsNetwork */ true,
|
|
107
|
+
/* callerTokenId */ null,
|
|
108
|
+
);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function call(handler: ToolHandler, args: any): Promise<Reply> {
|
|
113
|
+
const r = await handler(args);
|
|
114
|
+
return JSON.parse(r.content[0].text) as Reply;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const spec = {
|
|
118
|
+
name: "child-1353",
|
|
119
|
+
runtime: "claude-agent-sdk",
|
|
120
|
+
model: "claude-sonnet-4-5",
|
|
121
|
+
flags: {},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
describe("#1353 Fix ① — hub gates create_node on daemon-reported can_create_nodes", () => {
|
|
125
|
+
test("🔴 witnessed-red: blocked daemon (can_create_nodes=false + reason) → refuse with reason, NO dispatch", async () => {
|
|
126
|
+
seed({
|
|
127
|
+
role: "host_supervisor",
|
|
128
|
+
daemon_capabilities: {
|
|
129
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
130
|
+
can_create_nodes: false,
|
|
131
|
+
create_nodes_blocked_reason: "anet_bin_permission",
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
const t = tools();
|
|
135
|
+
const r = await call(t.create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
136
|
+
expect(r.ok).toBe(false);
|
|
137
|
+
expect(r.error).toBe("daemon_cannot_create_nodes");
|
|
138
|
+
expect(r.blocked_reason).toBe("anet_bin_permission");
|
|
139
|
+
// No request row inserted — hub must refuse BEFORE dispatch.
|
|
140
|
+
const rows = db.get<{ n: number }>(
|
|
141
|
+
"SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1",
|
|
142
|
+
DAEMON_ID,
|
|
143
|
+
);
|
|
144
|
+
expect(rows?.n).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("blocked daemon with each of the 4 reason codes surfaces that specific code", async () => {
|
|
148
|
+
const codes = ["anet_bin_identity", "anet_bin_source", "anet_bin_shape", "anet_bin_permission"];
|
|
149
|
+
for (const code of codes) {
|
|
150
|
+
cleanup();
|
|
151
|
+
seed({
|
|
152
|
+
role: "host_supervisor",
|
|
153
|
+
daemon_capabilities: {
|
|
154
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
155
|
+
can_create_nodes: false,
|
|
156
|
+
create_nodes_blocked_reason: code,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
160
|
+
expect(r.error).toBe("daemon_cannot_create_nodes");
|
|
161
|
+
expect(r.blocked_reason).toBe(code);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("blocked with .40-shape 'anet_bin_pin_unresolved' reason surfaces that literal (backcompat)", async () => {
|
|
166
|
+
// The Zod enum in tools.ts still accepts this coarse pre-#1377 code
|
|
167
|
+
// because agent-node@2.5.0-preview.40 daemons in the wild still emit
|
|
168
|
+
// it. If we ever swallow it into "unknown", those daemons' user
|
|
169
|
+
// messages get worse than they already are.
|
|
170
|
+
seed({
|
|
171
|
+
role: "host_supervisor",
|
|
172
|
+
daemon_capabilities: {
|
|
173
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
174
|
+
can_create_nodes: false,
|
|
175
|
+
create_nodes_blocked_reason: "anet_bin_pin_unresolved",
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
179
|
+
expect(r.blocked_reason).toBe("anet_bin_pin_unresolved");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("blocked with NO reason field → fallback 'anet_bin_unknown' (defensive)", async () => {
|
|
183
|
+
seed({
|
|
184
|
+
role: "host_supervisor",
|
|
185
|
+
daemon_capabilities: {
|
|
186
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
187
|
+
can_create_nodes: false,
|
|
188
|
+
// no create_nodes_blocked_reason
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
192
|
+
expect(r.error).toBe("daemon_cannot_create_nodes");
|
|
193
|
+
expect(r.blocked_reason).toBe("anet_bin_unknown");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("can_create_nodes=true daemon dispatches normally (gate must not false-positive on healthy daemon)", async () => {
|
|
197
|
+
seed({
|
|
198
|
+
role: "host_supervisor",
|
|
199
|
+
daemon_capabilities: {
|
|
200
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
201
|
+
can_create_nodes: true,
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
205
|
+
// Doesn't matter whether downstream dispatch succeeds — the point
|
|
206
|
+
// is that THIS gate did not reject. If reject, r.error would be
|
|
207
|
+
// "daemon_cannot_create_nodes".
|
|
208
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("🔴 pre-#1371 compat: snapshot with NO can_create_nodes field passes through (must NOT fail-closed)", async () => {
|
|
212
|
+
// preview.10 through preview.55 daemons never report this field.
|
|
213
|
+
// If we fail-closed on undefined we silently break every in-flight
|
|
214
|
+
// daemon that hasn't upgraded. Guard is "known-blocked", not
|
|
215
|
+
// "unknown-treated-as-blocked".
|
|
216
|
+
seed({
|
|
217
|
+
role: "host_supervisor",
|
|
218
|
+
daemon_capabilities: {
|
|
219
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
220
|
+
// no can_create_nodes at all
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
224
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("pre-#1371 compat: snapshot with no daemon_capabilities at all → passes through", async () => {
|
|
228
|
+
seed({ role: "host_supervisor" });
|
|
229
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
230
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("pre-#1371 compat: NULL config_snapshot passes through (very old daemon shape)", async () => {
|
|
234
|
+
seed(null);
|
|
235
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
236
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("malformed config_snapshot: permissive fallthrough (mirrors runtimes_supported catch), no crash", async () => {
|
|
240
|
+
seed("{not json");
|
|
241
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
242
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
243
|
+
});
|
|
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
|
+
});
|
package/src/db.ts
CHANGED
|
@@ -63,7 +63,7 @@ db.exec(`
|
|
|
63
63
|
-- 部分索引 acked=0 若「近期」读含已 ack 行则用不上)。P1 只落表。
|
|
64
64
|
CREATE TABLE IF NOT EXISTS user_inbox (
|
|
65
65
|
message_id TEXT PRIMARY KEY,
|
|
66
|
-
network_id TEXT,
|
|
66
|
+
network_id TEXT NOT NULL,
|
|
67
67
|
user_id TEXT NOT NULL,
|
|
68
68
|
from_session TEXT,
|
|
69
69
|
kind TEXT NOT NULL DEFAULT 'info',
|
|
@@ -681,6 +681,81 @@ function migrateNodeCreateRequestsModelNullable() {
|
|
|
681
681
|
});
|
|
682
682
|
}
|
|
683
683
|
migrateNodeCreateRequestsModelNullable();
|
|
684
|
+
|
|
685
|
+
// #1493 — 把 user_inbox.network_id 升到 **schema 级 NOT NULL**(belt-and-suspenders,
|
|
686
|
+
// 叠在 send_desktop_message 的代码级三闸之上:canWrite / `!effectiveNetId` return /
|
|
687
|
+
// getUserNetworkRole,tools.ts)。「不产生 network_id=NULL 孤儿」现是代码级保证 + #1492
|
|
688
|
+
// 三道测试;schema 级约束让**任何**未来绕过那三闸写 NULL 的回归在 INSERT 处直接被
|
|
689
|
+
// 数据库拒(而非产出 scoped-unreadable 的静默孤儿)。
|
|
690
|
+
//
|
|
691
|
+
// SQLite 不能 `ALTER COLUMN ADD NOT NULL` → 建新表→copy→drop→rename(与本文件既有
|
|
692
|
+
// recreate-table 迁移同范式);新库的 CREATE 已直接 NOT NULL,迁移检测到就跳过(幂等)。
|
|
693
|
+
function migrateUserInboxNetworkIdNotNull() {
|
|
694
|
+
if (db.dialect === "postgres") {
|
|
695
|
+
try { db.exec("ALTER TABLE user_inbox ALTER COLUMN network_id SET NOT NULL"); } catch {}
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
let createSql: string | undefined;
|
|
699
|
+
try {
|
|
700
|
+
createSql = db.get<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'user_inbox'")?.sql;
|
|
701
|
+
} catch { return; }
|
|
702
|
+
if (!createSql) return; // 表不存在:上面的 CREATE 已建成 NOT NULL 新表
|
|
703
|
+
if (/network_id\s+TEXT\s+NOT\s+NULL/i.test(createSql)) return; // 已是 NOT NULL → 幂等跳过
|
|
704
|
+
|
|
705
|
+
// 🔴 迁移前守卫:存量若有 network_id IS NULL 行(按代码级三闸理应 0)→ **大声 warn +
|
|
706
|
+
// 跳过迁移**(列暂保持可空),hub 照常启动。fail-safe,不 throw(SDK马 review #1516):
|
|
707
|
+
// · 这条 NOT NULL 是 belt-and-suspenders,代码级三闸(#1492 三测钉着)已挡新 NULL;
|
|
708
|
+
// 为一条冗余保险带让**整个舰队的 hub** boot 期停摆,代价与收益不成比例;
|
|
709
|
+
// · 生产这个 COUNT 没人量过,而 #1493 第一条原则就是"没量过的数不要动手"——只要生产
|
|
710
|
+
// 有一行历史 NULL,throw 就把升级变成停机;
|
|
711
|
+
// · fail-closed 的真正目的(不盲目清空、不 COALESCE 猜网络)用 warn+skip 一样达到。
|
|
712
|
+
// ⚠ 校正:本文件既有迁移(migrateNodeCreateRequestsModelNullable 内部 catch{}+return;
|
|
713
|
+
// migrateSessionsNetworkAliasUnique 全函数 throw 0 次)都**不**在 boot 期打死 hub——
|
|
714
|
+
// "顶层无 try/catch"是真前提,但"所以 throw 符合惯例"是没验的推论(SDK马 量出),撤回。
|
|
715
|
+
// 孤儿行仍有明确运维信号,三闸仍生效;等人工量过、清干净,下次启动幂等判据自动完成迁移。
|
|
716
|
+
const nullCount = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE network_id IS NULL")?.n ?? 0;
|
|
717
|
+
if (nullCount > 0) {
|
|
718
|
+
console.error(
|
|
719
|
+
`[migrate #1493] user_inbox 有 ${nullCount} 行 network_id IS NULL —— 与 send_desktop_message ` +
|
|
720
|
+
`的代码级三闸矛盾,可能存在更早的写路径漏洞;已**跳过** NOT NULL 迁移(列暂保持可空),` +
|
|
721
|
+
`hub 正常启动。请人工排查这些孤儿行来源、清理后下次启动会自动完成迁移(不要盲目清空)。`,
|
|
722
|
+
);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
db.transaction(() => {
|
|
727
|
+
db.exec("DROP TABLE IF EXISTS user_inbox_migrated");
|
|
728
|
+
db.exec(`
|
|
729
|
+
CREATE TABLE user_inbox_migrated (
|
|
730
|
+
message_id TEXT PRIMARY KEY,
|
|
731
|
+
network_id TEXT NOT NULL,
|
|
732
|
+
user_id TEXT NOT NULL,
|
|
733
|
+
from_session TEXT,
|
|
734
|
+
kind TEXT NOT NULL DEFAULT 'info',
|
|
735
|
+
title TEXT,
|
|
736
|
+
content TEXT NOT NULL,
|
|
737
|
+
severity TEXT NOT NULL DEFAULT 'info',
|
|
738
|
+
meta_json TEXT,
|
|
739
|
+
acked INTEGER NOT NULL DEFAULT 0,
|
|
740
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
741
|
+
acked_at TEXT
|
|
742
|
+
)
|
|
743
|
+
`);
|
|
744
|
+
db.exec(`
|
|
745
|
+
INSERT INTO user_inbox_migrated
|
|
746
|
+
(message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, acked, created_at, acked_at)
|
|
747
|
+
SELECT
|
|
748
|
+
message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, acked, created_at, acked_at
|
|
749
|
+
FROM user_inbox
|
|
750
|
+
`);
|
|
751
|
+
db.exec("DROP TABLE user_inbox");
|
|
752
|
+
db.exec("ALTER TABLE user_inbox_migrated RENAME TO user_inbox");
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
migrateUserInboxNetworkIdNotNull();
|
|
756
|
+
// 迁移会 drop 掉旧表上的 idx_user_inbox_user_acked → 重建(IF NOT EXISTS;未迁移时 no-op)。
|
|
757
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_user_inbox_user_acked ON user_inbox(user_id, acked, created_at);`);
|
|
758
|
+
|
|
684
759
|
db.exec(`
|
|
685
760
|
CREATE INDEX IF NOT EXISTS idx_ncr_daemon_status ON node_create_requests(daemon_node_id, status);
|
|
686
761
|
CREATE INDEX IF NOT EXISTS idx_ncr_network ON node_create_requests(network_id);
|
|
@@ -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
|
+
});
|