@sleep2agi/commhub-server 0.9.0-preview.42 → 0.9.0-preview.43
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 +266 -0
- package/src/create-node-blocked-gate.test.ts +231 -0
- package/src/db.ts +76 -1
- package/src/server.ts +42 -3
- package/src/tools.ts +35 -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
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.43",
|
|
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",
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// #1353 Fix ② — /api/host-supervisors surfaces daemon's self-reported
|
|
2
|
+
// can_create_nodes + create_nodes_blocked_reason so dashboard's
|
|
3
|
+
// "选服务器" picker can filter or dim broken daemons.
|
|
4
|
+
//
|
|
5
|
+
// Fix ① (PR #1510) added the hub-side dispatch gate — daemon that
|
|
6
|
+
// reports can_create_nodes=false gets refused with the reason. But
|
|
7
|
+
// the dashboard picker still LISTS the daemon and the user finds out
|
|
8
|
+
// only after clicking. This exposes the fact one layer earlier so the
|
|
9
|
+
// picker never surfaces the daemon in the first place.
|
|
10
|
+
//
|
|
11
|
+
// 🔴 Pre-#1371 compat is the same shape as Fix ①: only include the
|
|
12
|
+
// two keys when the daemon actually reported them. Old daemons (that
|
|
13
|
+
// never report) get the same undefined-passthrough dashboard sees a
|
|
14
|
+
// row WITHOUT the keys, not a row with can_create_nodes:false, so
|
|
15
|
+
// the dashboard's own default UI doesn't silently dim every legacy
|
|
16
|
+
// daemon.
|
|
17
|
+
|
|
18
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
19
|
+
import "./require-explicit-test-db.js";
|
|
20
|
+
import { register, login } from "./auth.js";
|
|
21
|
+
import { db } from "./db.js";
|
|
22
|
+
|
|
23
|
+
let BASE = "";
|
|
24
|
+
let server: any = null;
|
|
25
|
+
|
|
26
|
+
let userToken = "";
|
|
27
|
+
let userNetworkId = "";
|
|
28
|
+
let userId = "";
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
process.env.HOST = "127.0.0.1";
|
|
32
|
+
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
33
|
+
const password = "BootstrapPw123Aa!";
|
|
34
|
+
|
|
35
|
+
// Seed admin so subsequent registrations aren't auto-admin (admin
|
|
36
|
+
// path skips resolveRestNetworkScope's default-network fallback —
|
|
37
|
+
// that would change our fixture's role/scope in ways unrelated to
|
|
38
|
+
// this fix).
|
|
39
|
+
const seed = register(`hsc_seed_${suffix}`, password, undefined, "seed");
|
|
40
|
+
if (!seed.ok) throw new Error("seed failed");
|
|
41
|
+
db.run("UPDATE users SET role = 'admin' WHERE username = ?1", [`hsc_seed_${suffix}`]);
|
|
42
|
+
|
|
43
|
+
const u = register(`hsc_u_${suffix}`, password, undefined, "seed");
|
|
44
|
+
if (!u.ok || !u.token) throw new Error("user register failed");
|
|
45
|
+
userToken = u.token;
|
|
46
|
+
userNetworkId = u.network_id ?? "";
|
|
47
|
+
userId = login(`hsc_u_${suffix}`, password).user!.user_id;
|
|
48
|
+
|
|
49
|
+
const { bootServer } = await import("./server.js");
|
|
50
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
51
|
+
BASE = `http://127.0.0.1:${server.port}`;
|
|
52
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterAll(() => {
|
|
56
|
+
try { server?.stop?.(true); } catch {}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function seedDaemon(alias: string, snapshotOverride: object | null): void {
|
|
60
|
+
const nodeId = `hsc_n_${alias}`;
|
|
61
|
+
const snap = snapshotOverride === null
|
|
62
|
+
? null
|
|
63
|
+
: JSON.stringify(snapshotOverride);
|
|
64
|
+
db.run(
|
|
65
|
+
`INSERT OR REPLACE INTO nodes (
|
|
66
|
+
node_id, node_name, alias, runtime, model, config_path,
|
|
67
|
+
channels, server, hostname, network_id,
|
|
68
|
+
config_revision, config_snapshot
|
|
69
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`,
|
|
70
|
+
[nodeId, alias, alias, "claude-agent-sdk", "claude-sonnet-4-5",
|
|
71
|
+
"/tmp/cfg.json", "[]", "test-host", "test-host", userNetworkId,
|
|
72
|
+
0, snap],
|
|
73
|
+
);
|
|
74
|
+
db.run(
|
|
75
|
+
`INSERT OR REPLACE INTO api_tokens (
|
|
76
|
+
token_id, token_hash, user_id, network_id, name, scope
|
|
77
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
|
|
78
|
+
[`hsc_t_${alias}`, `hash_${alias}`, userId, userNetworkId, `node:${alias}`, "network"],
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function clearDaemons(): void {
|
|
83
|
+
db.run("DELETE FROM nodes WHERE network_id = ?1", [userNetworkId]);
|
|
84
|
+
db.run("DELETE FROM api_tokens WHERE network_id = ?1 AND name LIKE 'node:%'", [userNetworkId]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function listDaemons(): Promise<any[]> {
|
|
88
|
+
const res = await fetch(`${BASE}/api/host-supervisors`, {
|
|
89
|
+
headers: { "Authorization": `Bearer ${userToken}` },
|
|
90
|
+
});
|
|
91
|
+
const body = await res.json();
|
|
92
|
+
if (!body.ok) throw new Error("list failed: " + JSON.stringify(body));
|
|
93
|
+
return body.daemons;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function findDaemon(rows: any[], alias: string): any {
|
|
97
|
+
const d = rows.find(r => r.alias === alias);
|
|
98
|
+
if (!d) throw new Error(`daemon ${alias} not in response`);
|
|
99
|
+
return d;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe("#1353 Fix ② — /api/host-supervisors surfaces can_create_nodes + blocked_reason", () => {
|
|
103
|
+
test("🔴 witnessed-red: blocked daemon → response carries can_create_nodes:false + blocked_reason", async () => {
|
|
104
|
+
clearDaemons();
|
|
105
|
+
seedDaemon("blocked-a", {
|
|
106
|
+
role: "host_supervisor",
|
|
107
|
+
daemon_capabilities: {
|
|
108
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
109
|
+
can_create_nodes: false,
|
|
110
|
+
create_nodes_blocked_reason: "anet_bin_permission",
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
const d = findDaemon(await listDaemons(), "blocked-a");
|
|
114
|
+
expect(d.can_create_nodes).toBe(false);
|
|
115
|
+
expect(d.create_nodes_blocked_reason).toBe("anet_bin_permission");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("healthy daemon (can_create_nodes:true) → can_create_nodes:true, NO blocked_reason", async () => {
|
|
119
|
+
clearDaemons();
|
|
120
|
+
seedDaemon("healthy-a", {
|
|
121
|
+
role: "host_supervisor",
|
|
122
|
+
daemon_capabilities: {
|
|
123
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
124
|
+
can_create_nodes: true,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
const d = findDaemon(await listDaemons(), "healthy-a");
|
|
128
|
+
expect(d.can_create_nodes).toBe(true);
|
|
129
|
+
// 🔴 defensive: dashboard's picker likely renders blocked_reason
|
|
130
|
+
// when present regardless of can_create_nodes value. Don't
|
|
131
|
+
// confuse it by shipping a stale reason on a healthy daemon.
|
|
132
|
+
expect(d.create_nodes_blocked_reason).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("healthy daemon that ALSO reports stale blocked_reason → reason NOT emitted (defensive)", async () => {
|
|
136
|
+
// Belt-and-braces: even if a future daemon shape bug leaves a
|
|
137
|
+
// stale reason string in the snapshot after recovering, the REST
|
|
138
|
+
// response should not surface it when the daemon is currently OK.
|
|
139
|
+
clearDaemons();
|
|
140
|
+
seedDaemon("healthy-b", {
|
|
141
|
+
role: "host_supervisor",
|
|
142
|
+
daemon_capabilities: {
|
|
143
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
144
|
+
can_create_nodes: true,
|
|
145
|
+
create_nodes_blocked_reason: "anet_bin_permission", // stale
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
const d = findDaemon(await listDaemons(), "healthy-b");
|
|
149
|
+
expect(d.can_create_nodes).toBe(true);
|
|
150
|
+
expect(d.create_nodes_blocked_reason).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("🔴 pre-#1371 compat: daemon that never reports can_create_nodes → BOTH keys absent from response", async () => {
|
|
154
|
+
// Same "known-blocked, NOT unknown-treated-as-blocked" rule as
|
|
155
|
+
// Fix ①. A pre-#1371 daemon (preview.10 through ~preview.55) never
|
|
156
|
+
// reports these fields. The response for it must not fabricate a
|
|
157
|
+
// false value — the dashboard could reasonably render an
|
|
158
|
+
// explicit `false` as "dim / hide" which would silently start
|
|
159
|
+
// hiding every daemon that hasn't upgraded.
|
|
160
|
+
clearDaemons();
|
|
161
|
+
seedDaemon("legacy-a", {
|
|
162
|
+
role: "host_supervisor",
|
|
163
|
+
daemon_capabilities: {
|
|
164
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
165
|
+
// no can_create_nodes / create_nodes_blocked_reason
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
const d = findDaemon(await listDaemons(), "legacy-a");
|
|
169
|
+
expect("can_create_nodes" in d).toBe(false);
|
|
170
|
+
expect("create_nodes_blocked_reason" in d).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("pre-#1371 compat: daemon with no daemon_capabilities at all → BOTH keys absent", async () => {
|
|
174
|
+
clearDaemons();
|
|
175
|
+
seedDaemon("legacy-b", { role: "host_supervisor" });
|
|
176
|
+
const d = findDaemon(await listDaemons(), "legacy-b");
|
|
177
|
+
expect("can_create_nodes" in d).toBe(false);
|
|
178
|
+
expect("create_nodes_blocked_reason" in d).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("pre-#1371 compat: NULL config_snapshot (very old daemon) → BOTH keys absent, daemon does NOT crash the listing", async () => {
|
|
182
|
+
// A daemon that never called report_status leaves config_snapshot NULL.
|
|
183
|
+
// /api/host-supervisors filters by role from the snapshot, so a NULL
|
|
184
|
+
// snapshot drops from the list entirely — but if it did surface (via
|
|
185
|
+
// any future path), it must not crash the reducer.
|
|
186
|
+
clearDaemons();
|
|
187
|
+
seedDaemon("legacy-c", { role: "host_supervisor" }); // seed with something to be listable
|
|
188
|
+
const rows = await listDaemons();
|
|
189
|
+
// Just prove the list doesn't throw and the daemon is present with
|
|
190
|
+
// no capability keys.
|
|
191
|
+
const d = findDaemon(rows, "legacy-c");
|
|
192
|
+
expect("can_create_nodes" in d).toBe(false);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("all 4 categorized reason codes round-trip through the REST response", async () => {
|
|
196
|
+
const codes = ["anet_bin_identity", "anet_bin_source", "anet_bin_shape", "anet_bin_permission"];
|
|
197
|
+
for (const code of codes) {
|
|
198
|
+
clearDaemons();
|
|
199
|
+
seedDaemon(`blocked-${code}`, {
|
|
200
|
+
role: "host_supervisor",
|
|
201
|
+
daemon_capabilities: {
|
|
202
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
203
|
+
can_create_nodes: false,
|
|
204
|
+
create_nodes_blocked_reason: code,
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
const d = findDaemon(await listDaemons(), `blocked-${code}`);
|
|
208
|
+
expect(d.can_create_nodes).toBe(false);
|
|
209
|
+
expect(d.create_nodes_blocked_reason).toBe(code);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test(".40-shape 'anet_bin_pin_unresolved' pre-#1377 code also passes through the REST response", async () => {
|
|
214
|
+
// The Zod enum still accepts this coarse pre-#1377 code (agent-node
|
|
215
|
+
// @2.5.0-preview.40 daemons in the wild emit it), and Fix ① surfaces
|
|
216
|
+
// it verbatim to callers; the REST surface must too, otherwise
|
|
217
|
+
// dashboard sees the daemon as "blocked with no reason".
|
|
218
|
+
clearDaemons();
|
|
219
|
+
seedDaemon("blocked-40shape", {
|
|
220
|
+
role: "host_supervisor",
|
|
221
|
+
daemon_capabilities: {
|
|
222
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
223
|
+
can_create_nodes: false,
|
|
224
|
+
create_nodes_blocked_reason: "anet_bin_pin_unresolved",
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
const d = findDaemon(await listDaemons(), "blocked-40shape");
|
|
228
|
+
expect(d.create_nodes_blocked_reason).toBe("anet_bin_pin_unresolved");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("malformed config_snapshot: existing behavior (daemon drops from list due to role-filter) unchanged", async () => {
|
|
232
|
+
// The parse is wrapped in try/catch (existing behavior). A malformed
|
|
233
|
+
// snapshot means snapRole stays null → filter drops it. This just
|
|
234
|
+
// pins that our added parsing doesn't turn a malformed snapshot
|
|
235
|
+
// into a crash.
|
|
236
|
+
clearDaemons();
|
|
237
|
+
// Seed a legit daemon so the list has SOMETHING to prove no crash.
|
|
238
|
+
seedDaemon("healthy-c", {
|
|
239
|
+
role: "host_supervisor",
|
|
240
|
+
daemon_capabilities: { runtimes_supported: [] },
|
|
241
|
+
});
|
|
242
|
+
// Seed the malformed one — should be filtered out silently.
|
|
243
|
+
const nodeId = "hsc_n_malformed";
|
|
244
|
+
db.run(
|
|
245
|
+
`INSERT OR REPLACE INTO nodes (
|
|
246
|
+
node_id, node_name, alias, runtime, model, config_path,
|
|
247
|
+
channels, server, hostname, network_id,
|
|
248
|
+
config_revision, config_snapshot
|
|
249
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`,
|
|
250
|
+
[nodeId, "malformed", "malformed", "claude-agent-sdk", "claude-sonnet-4-5",
|
|
251
|
+
"/tmp/cfg.json", "[]", "test-host", "test-host", userNetworkId,
|
|
252
|
+
0, "{not json"],
|
|
253
|
+
);
|
|
254
|
+
db.run(
|
|
255
|
+
`INSERT OR REPLACE INTO api_tokens (
|
|
256
|
+
token_id, token_hash, user_id, network_id, name, scope
|
|
257
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
|
|
258
|
+
["hsc_t_malformed", "hash_malformed", userId, userNetworkId, "node:malformed", "network"],
|
|
259
|
+
);
|
|
260
|
+
const rows = await listDaemons();
|
|
261
|
+
// healthy-c is present; malformed is filtered out because
|
|
262
|
+
// snapRole stayed null.
|
|
263
|
+
expect(rows.find((r: any) => r.alias === "healthy-c")).toBeDefined();
|
|
264
|
+
expect(rows.find((r: any) => r.alias === "malformed")).toBeUndefined();
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,231 @@
|
|
|
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 api_tokens WHERE network_id = ?1", [NET]); } catch {}
|
|
35
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
|
|
36
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
37
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER_ID]); } catch {}
|
|
38
|
+
}
|
|
39
|
+
beforeEach(cleanup);
|
|
40
|
+
afterAll(cleanup);
|
|
41
|
+
|
|
42
|
+
function seed(snapshot: object | string | null) {
|
|
43
|
+
db.run(
|
|
44
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
45
|
+
VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
46
|
+
[USER_ID, USER_ID],
|
|
47
|
+
);
|
|
48
|
+
db.run(
|
|
49
|
+
`INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at)
|
|
50
|
+
VALUES (?1, ?1, ?2, datetime('now'))`,
|
|
51
|
+
[NET, USER_ID],
|
|
52
|
+
);
|
|
53
|
+
db.run(
|
|
54
|
+
`INSERT INTO network_members (user_id, network_id, role, joined_at)
|
|
55
|
+
VALUES (?1, ?2, 'admin', datetime('now'))`,
|
|
56
|
+
[USER_ID, NET],
|
|
57
|
+
);
|
|
58
|
+
const snap = snapshot === null
|
|
59
|
+
? null
|
|
60
|
+
: (typeof snapshot === "string" ? snapshot : JSON.stringify(snapshot));
|
|
61
|
+
db.run(
|
|
62
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state)
|
|
63
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now'), datetime('now'), 'active')`,
|
|
64
|
+
[DAEMON_ID, DAEMON_ALIAS, DAEMON_ALIAS, NET, snap, `host-${DAEMON_ALIAS}`],
|
|
65
|
+
);
|
|
66
|
+
db.run(
|
|
67
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
68
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
69
|
+
[DAEMON_TOK, USER_ID, NET, `node:${DAEMON_ALIAS}`, `hash_${DAEMON_TOK}`],
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function tools(): Record<string, ToolHandler> {
|
|
74
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
75
|
+
const out: Record<string, ToolHandler> = {};
|
|
76
|
+
const origTool = server.tool.bind(server);
|
|
77
|
+
server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
|
|
78
|
+
out[name] = handler;
|
|
79
|
+
return origTool(name, _desc, _schema, handler);
|
|
80
|
+
};
|
|
81
|
+
const origRegisterTool = server.registerTool?.bind(server);
|
|
82
|
+
if (origRegisterTool) {
|
|
83
|
+
server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
|
|
84
|
+
out[name] = handler;
|
|
85
|
+
return origRegisterTool(name, _cfg, handler);
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
registerTools(
|
|
89
|
+
server, undefined,
|
|
90
|
+
/* enforceNetworkId */ NET,
|
|
91
|
+
/* enforceUserId */ USER_ID,
|
|
92
|
+
/* callerAlias */ null,
|
|
93
|
+
/* callerTokenIsNetwork */ true,
|
|
94
|
+
/* callerTokenId */ null,
|
|
95
|
+
);
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function call(handler: ToolHandler, args: any): Promise<Reply> {
|
|
100
|
+
const r = await handler(args);
|
|
101
|
+
return JSON.parse(r.content[0].text) as Reply;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const spec = {
|
|
105
|
+
name: "child-1353",
|
|
106
|
+
runtime: "claude-agent-sdk",
|
|
107
|
+
model: "claude-sonnet-4-5",
|
|
108
|
+
flags: {},
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
describe("#1353 Fix ① — hub gates create_node on daemon-reported can_create_nodes", () => {
|
|
112
|
+
test("🔴 witnessed-red: blocked daemon (can_create_nodes=false + reason) → refuse with reason, NO dispatch", async () => {
|
|
113
|
+
seed({
|
|
114
|
+
role: "host_supervisor",
|
|
115
|
+
daemon_capabilities: {
|
|
116
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
117
|
+
can_create_nodes: false,
|
|
118
|
+
create_nodes_blocked_reason: "anet_bin_permission",
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
const t = tools();
|
|
122
|
+
const r = await call(t.create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
123
|
+
expect(r.ok).toBe(false);
|
|
124
|
+
expect(r.error).toBe("daemon_cannot_create_nodes");
|
|
125
|
+
expect(r.blocked_reason).toBe("anet_bin_permission");
|
|
126
|
+
// No request row inserted — hub must refuse BEFORE dispatch.
|
|
127
|
+
const rows = db.get<{ n: number }>(
|
|
128
|
+
"SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1",
|
|
129
|
+
DAEMON_ID,
|
|
130
|
+
);
|
|
131
|
+
expect(rows?.n).toBe(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("blocked daemon with each of the 4 reason codes surfaces that specific code", async () => {
|
|
135
|
+
const codes = ["anet_bin_identity", "anet_bin_source", "anet_bin_shape", "anet_bin_permission"];
|
|
136
|
+
for (const code of codes) {
|
|
137
|
+
cleanup();
|
|
138
|
+
seed({
|
|
139
|
+
role: "host_supervisor",
|
|
140
|
+
daemon_capabilities: {
|
|
141
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
142
|
+
can_create_nodes: false,
|
|
143
|
+
create_nodes_blocked_reason: code,
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
147
|
+
expect(r.error).toBe("daemon_cannot_create_nodes");
|
|
148
|
+
expect(r.blocked_reason).toBe(code);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("blocked with .40-shape 'anet_bin_pin_unresolved' reason surfaces that literal (backcompat)", async () => {
|
|
153
|
+
// The Zod enum in tools.ts still accepts this coarse pre-#1377 code
|
|
154
|
+
// because agent-node@2.5.0-preview.40 daemons in the wild still emit
|
|
155
|
+
// it. If we ever swallow it into "unknown", those daemons' user
|
|
156
|
+
// messages get worse than they already are.
|
|
157
|
+
seed({
|
|
158
|
+
role: "host_supervisor",
|
|
159
|
+
daemon_capabilities: {
|
|
160
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
161
|
+
can_create_nodes: false,
|
|
162
|
+
create_nodes_blocked_reason: "anet_bin_pin_unresolved",
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
166
|
+
expect(r.blocked_reason).toBe("anet_bin_pin_unresolved");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("blocked with NO reason field → fallback 'anet_bin_unknown' (defensive)", async () => {
|
|
170
|
+
seed({
|
|
171
|
+
role: "host_supervisor",
|
|
172
|
+
daemon_capabilities: {
|
|
173
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
174
|
+
can_create_nodes: false,
|
|
175
|
+
// no create_nodes_blocked_reason
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
179
|
+
expect(r.error).toBe("daemon_cannot_create_nodes");
|
|
180
|
+
expect(r.blocked_reason).toBe("anet_bin_unknown");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("can_create_nodes=true daemon dispatches normally (gate must not false-positive on healthy daemon)", async () => {
|
|
184
|
+
seed({
|
|
185
|
+
role: "host_supervisor",
|
|
186
|
+
daemon_capabilities: {
|
|
187
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
188
|
+
can_create_nodes: true,
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
192
|
+
// Doesn't matter whether downstream dispatch succeeds — the point
|
|
193
|
+
// is that THIS gate did not reject. If reject, r.error would be
|
|
194
|
+
// "daemon_cannot_create_nodes".
|
|
195
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("🔴 pre-#1371 compat: snapshot with NO can_create_nodes field passes through (must NOT fail-closed)", async () => {
|
|
199
|
+
// preview.10 through preview.55 daemons never report this field.
|
|
200
|
+
// If we fail-closed on undefined we silently break every in-flight
|
|
201
|
+
// daemon that hasn't upgraded. Guard is "known-blocked", not
|
|
202
|
+
// "unknown-treated-as-blocked".
|
|
203
|
+
seed({
|
|
204
|
+
role: "host_supervisor",
|
|
205
|
+
daemon_capabilities: {
|
|
206
|
+
runtimes_supported: ["claude-agent-sdk"],
|
|
207
|
+
// no can_create_nodes at all
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
211
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("pre-#1371 compat: snapshot with no daemon_capabilities at all → passes through", async () => {
|
|
215
|
+
seed({ role: "host_supervisor" });
|
|
216
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
217
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("pre-#1371 compat: NULL config_snapshot passes through (very old daemon shape)", async () => {
|
|
221
|
+
seed(null);
|
|
222
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
223
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("malformed config_snapshot: permissive fallthrough (mirrors runtimes_supported catch), no crash", async () => {
|
|
227
|
+
seed("{not json");
|
|
228
|
+
const r = await call(tools().create_node, { daemon_node_id: DAEMON_ID, node_spec: spec });
|
|
229
|
+
expect(r.error).not.toBe("daemon_cannot_create_nodes");
|
|
230
|
+
});
|
|
231
|
+
});
|
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);
|
package/src/server.ts
CHANGED
|
@@ -3135,16 +3135,41 @@ return Bun.serve({
|
|
|
3135
3135
|
const daemons = sqlRows
|
|
3136
3136
|
.map(r => {
|
|
3137
3137
|
let snapRole: string | null = null;
|
|
3138
|
+
// #1353 Fix ② — surface daemon self-reported create-node
|
|
3139
|
+
// capability so dashboard's "选服务器" picker can filter or
|
|
3140
|
+
// dim broken daemons instead of listing them as online.
|
|
3141
|
+
// Fix ① (hub-side dispatch gate, PR #1510) refuses the
|
|
3142
|
+
// request but the picker still lists the daemon and the user
|
|
3143
|
+
// finds out only after clicking. Exposing the fact one layer
|
|
3144
|
+
// earlier lets the picker never surface the daemon in the
|
|
3145
|
+
// first place.
|
|
3146
|
+
//
|
|
3147
|
+
// 🔴 Pre-#1371 daemons don't report either field. Leave the
|
|
3148
|
+
// response keys UNDEFINED in that case (not `false`, not
|
|
3149
|
+
// `null`) — mirrors the Fix ① rule "known-blocked, NOT
|
|
3150
|
+
// unknown-treated-as-blocked". Emitting a fabricated `false`
|
|
3151
|
+
// would silently start dimming every daemon that hasn't
|
|
3152
|
+
// upgraded past preview.55.
|
|
3153
|
+
let snapCanCreate: boolean | undefined;
|
|
3154
|
+
let snapBlockedReason: string | undefined;
|
|
3138
3155
|
if (r.config_snapshot) {
|
|
3139
3156
|
try {
|
|
3140
3157
|
const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
|
|
3141
3158
|
snapRole = typeof parsed?.role === "string" ? parsed.role : null;
|
|
3159
|
+
const caps = parsed?.daemon_capabilities;
|
|
3160
|
+
if (caps && typeof caps.can_create_nodes === "boolean") {
|
|
3161
|
+
snapCanCreate = caps.can_create_nodes;
|
|
3162
|
+
}
|
|
3163
|
+
if (caps && typeof caps.create_nodes_blocked_reason === "string"
|
|
3164
|
+
&& caps.create_nodes_blocked_reason.length > 0) {
|
|
3165
|
+
snapBlockedReason = caps.create_nodes_blocked_reason;
|
|
3166
|
+
}
|
|
3142
3167
|
} catch { /* malformed */ }
|
|
3143
3168
|
}
|
|
3144
|
-
return { row: r, role: snapRole };
|
|
3169
|
+
return { row: r, role: snapRole, canCreate: snapCanCreate, blockedReason: snapBlockedReason };
|
|
3145
3170
|
})
|
|
3146
3171
|
.filter(({ role }) => role === "host_supervisor")
|
|
3147
|
-
.map(({ row: r }) => {
|
|
3172
|
+
.map(({ row: r, canCreate, blockedReason }) => {
|
|
3148
3173
|
let online = false;
|
|
3149
3174
|
let lastSeenAt: string | null = null;
|
|
3150
3175
|
if (r.session_last_seen) {
|
|
@@ -3174,7 +3199,7 @@ return Bun.serve({
|
|
|
3174
3199
|
telemetry.mem_gb = r.session_mem_total_gb ?? null;
|
|
3175
3200
|
telemetry.ip_internal = r.session_ip ?? null;
|
|
3176
3201
|
}
|
|
3177
|
-
|
|
3202
|
+
const out: Record<string, unknown> = {
|
|
3178
3203
|
daemon_node_id: r.node_id,
|
|
3179
3204
|
alias: r.alias,
|
|
3180
3205
|
hostname: r.hostname,
|
|
@@ -3184,6 +3209,20 @@ return Bun.serve({
|
|
|
3184
3209
|
allowed_secret_keys: secrets,
|
|
3185
3210
|
host_telemetry: telemetry,
|
|
3186
3211
|
};
|
|
3212
|
+
// #1353 Fix ② — only emit the two capability keys when the
|
|
3213
|
+
// daemon actually reported them. Undefined vs. false is a
|
|
3214
|
+
// real distinction here (see Fix ② comment above).
|
|
3215
|
+
if (typeof canCreate === "boolean") {
|
|
3216
|
+
out.can_create_nodes = canCreate;
|
|
3217
|
+
// Blocked reason only meaningful when blocked. Some legacy
|
|
3218
|
+
// shapes could carry a reason without can_create_nodes=false
|
|
3219
|
+
// (defensive fallback in Fix ①); mirror the same choice —
|
|
3220
|
+
// don't confuse consumers with a reason on a healthy daemon.
|
|
3221
|
+
if (canCreate === false && blockedReason) {
|
|
3222
|
+
out.create_nodes_blocked_reason = blockedReason;
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
return out;
|
|
3187
3226
|
});
|
|
3188
3227
|
return withCors(req, Response.json({ ok: true, daemons, count: daemons.length }));
|
|
3189
3228
|
}
|
package/src/tools.ts
CHANGED
|
@@ -3207,6 +3207,41 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3207
3207
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
|
|
3208
3208
|
}
|
|
3209
3209
|
|
|
3210
|
+
// #1353 Fix ① — daemon self-declared "I can't create nodes right now"
|
|
3211
|
+
// is now a dispatch-time gate, not just a stored fact. Before this,
|
|
3212
|
+
// daemon reported `daemon_capabilities.can_create_nodes=false` (via
|
|
3213
|
+
// #1371 + #1377), hub stored it in config_snapshot, and then no
|
|
3214
|
+
// consumer read it — dispatch still returned `{ok:true, request_id}`
|
|
3215
|
+
// and pushed an SSE doorbell to the broken daemon. That's the exact
|
|
3216
|
+
// "在线但静默失败" case the issue title reports.
|
|
3217
|
+
//
|
|
3218
|
+
// Vincent's own 2026-08-28 comment on #1353 authorizes this shape:
|
|
3219
|
+
// "daemon 侧确知不可用时,hub 应当**拒绝派发**并返回可操作的错误,
|
|
3220
|
+
// 而不是发一个注定失败的 doorbell".
|
|
3221
|
+
//
|
|
3222
|
+
// 🔴 Pre-#1371 compat: a daemon that has NOT reported
|
|
3223
|
+
// `can_create_nodes` at all leaves the field UNDEFINED in the
|
|
3224
|
+
// snapshot. Do NOT fail-closed on undefined — that would silently
|
|
3225
|
+
// start rejecting every daemon on the old (preview.10 through .55
|
|
3226
|
+
// roughly) shape. Only `=== false` gates; `=== true` and `undefined`
|
|
3227
|
+
// both pass through (new-guard-matches-existing-stance).
|
|
3228
|
+
try {
|
|
3229
|
+
const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
|
|
3230
|
+
const caps = snap?.daemon_capabilities;
|
|
3231
|
+
if (caps && caps.can_create_nodes === false) {
|
|
3232
|
+
const reason: string = (typeof caps.create_nodes_blocked_reason === "string"
|
|
3233
|
+
&& caps.create_nodes_blocked_reason.length > 0)
|
|
3234
|
+
? caps.create_nodes_blocked_reason
|
|
3235
|
+
: "anet_bin_unknown";
|
|
3236
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
3237
|
+
ok: false,
|
|
3238
|
+
error: "daemon_cannot_create_nodes",
|
|
3239
|
+
blocked_reason: reason,
|
|
3240
|
+
daemon_node_id,
|
|
3241
|
+
}) }] };
|
|
3242
|
+
}
|
|
3243
|
+
} catch { /* malformed snapshot: permissive, mirrors L3230 fallthrough */ }
|
|
3244
|
+
|
|
3210
3245
|
// Read daemon's host_supervisor capability + allowlist from its
|
|
3211
3246
|
// last reported config_snapshot. PR3 (#338) canonical path is
|
|
3212
3247
|
// `daemon_capabilities.runtimes_supported` (RFC-026 §9.3).
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// #1493 — user_inbox.network_id 升 schema 级 NOT NULL(belt-and-suspenders)。
|
|
2
|
+
//
|
|
3
|
+
// 「不产生 NULL 孤儿」原是代码级三闸(#1492 证不可达)。schema 级 NOT NULL 让**任何**
|
|
4
|
+
// 未来绕过那三闸写 NULL 的回归在 INSERT 处被 DB 直接拒,而不是产出 scoped-unreadable
|
|
5
|
+
// 的静默孤儿。生产 hub 已在 preview.41、user_inbox 有真实行 → 迁移必须:① 新库直接
|
|
6
|
+
// NOT NULL;② 存量库无损重建(建新表→copy→drop→rename);③ 迁移前若发现 NULL 行(理应
|
|
7
|
+
// 为 0)fail-closed 停下,不静默丢。
|
|
8
|
+
//
|
|
9
|
+
// 后两条要预置「旧 schema(nullable)」的库 → 用 bun:sqlite 手造库 + 子进程 import db.js
|
|
10
|
+
// 触发迁移(与 boot-side-effect.test.ts 同款子进程范式)。
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from "bun:test";
|
|
13
|
+
import { Database } from "bun:sqlite";
|
|
14
|
+
import { mkdtempSync } from "fs";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
import { tmpdir } from "os";
|
|
17
|
+
import { db } from "./db.js";
|
|
18
|
+
|
|
19
|
+
const OLD_SCHEMA = `CREATE TABLE user_inbox (
|
|
20
|
+
message_id TEXT PRIMARY KEY, network_id TEXT, user_id TEXT NOT NULL, from_session TEXT,
|
|
21
|
+
kind TEXT NOT NULL DEFAULT 'info', title TEXT, content TEXT NOT NULL,
|
|
22
|
+
severity TEXT NOT NULL DEFAULT 'info', meta_json TEXT, acked INTEGER NOT NULL DEFAULT 0,
|
|
23
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')), acked_at TEXT)`;
|
|
24
|
+
|
|
25
|
+
/** 预置一个「旧 schema(network_id 可空)」的库文件,可选带若干行。返回文件路径。 */
|
|
26
|
+
function seedOldDb(rows: Array<{ mid: string; net: string | null; user: string }>): string {
|
|
27
|
+
const file = join(mkdtempSync(join(tmpdir(), "anet-1493-")), "hub.db");
|
|
28
|
+
const raw = new Database(file);
|
|
29
|
+
raw.exec(OLD_SCHEMA);
|
|
30
|
+
for (const r of rows) {
|
|
31
|
+
raw.run("INSERT INTO user_inbox (message_id, network_id, user_id, content) VALUES (?1, ?2, ?3, 'body')",
|
|
32
|
+
[r.mid, r.net, r.user]);
|
|
33
|
+
}
|
|
34
|
+
raw.close();
|
|
35
|
+
return file;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 子进程里 import db.js(触发迁移),回读一段结果。返回 {exitCode, stdout, stderr}。 */
|
|
39
|
+
function bootWithDb(file: string, probe: string) {
|
|
40
|
+
const script = `
|
|
41
|
+
process.env.COMMHUB_DB = ${JSON.stringify(file)};
|
|
42
|
+
const { db } = await import("./src/db.js");
|
|
43
|
+
${probe}
|
|
44
|
+
process.exit(0);
|
|
45
|
+
`;
|
|
46
|
+
const child = Bun.spawnSync({
|
|
47
|
+
cmd: ["bun", "-e", script],
|
|
48
|
+
cwd: `${import.meta.dir}/..`,
|
|
49
|
+
env: { ...process.env, COMMHUB_DB: file },
|
|
50
|
+
timeout: 20_000,
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
exitCode: child.exitCode,
|
|
54
|
+
stdout: new TextDecoder().decode(child.stdout),
|
|
55
|
+
stderr: new TextDecoder().decode(child.stderr),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("#1493 user_inbox.network_id NOT NULL (belt-and-suspenders)", () => {
|
|
60
|
+
test("new DB: network_id is NOT NULL and rejects a NULL insert", () => {
|
|
61
|
+
const col = db.all<{ name: string; notnull: number }>("PRAGMA table_info(user_inbox)").find(c => c.name === "network_id");
|
|
62
|
+
expect(col?.notnull).toBe(1);
|
|
63
|
+
// 绕过代码闸直接写 NULL → DB 拒(这正是 belt-and-suspenders 的价值)
|
|
64
|
+
expect(() => db.run(
|
|
65
|
+
"INSERT INTO user_inbox (message_id, user_id, content) VALUES (?1, ?2, ?3)",
|
|
66
|
+
[`dm_nn_${Date.now()}`, "u_nn", "hi"],
|
|
67
|
+
)).toThrow(/NOT NULL/i);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("migration: old nullable table with valid rows → rebuilt NOT NULL, rows preserved, index recreated", () => {
|
|
71
|
+
const file = seedOldDb([
|
|
72
|
+
{ mid: "dm_1", net: "net_a", user: "u_1" },
|
|
73
|
+
{ mid: "dm_2", net: "net_b", user: "u_2" },
|
|
74
|
+
]);
|
|
75
|
+
const r = bootWithDb(file, `
|
|
76
|
+
const col = db.all("PRAGMA table_info(user_inbox)").find(c => c.name === "network_id");
|
|
77
|
+
const rows = db.get("SELECT COUNT(*) AS n FROM user_inbox").n;
|
|
78
|
+
const idx = db.get("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='index' AND name='idx_user_inbox_user_acked'").n;
|
|
79
|
+
let nullRejected = false;
|
|
80
|
+
try { db.run("INSERT INTO user_inbox (message_id, user_id, content) VALUES ('dm_z','u_z','x')"); }
|
|
81
|
+
catch { nullRejected = true; }
|
|
82
|
+
console.log("RESULT:" + JSON.stringify({ notnull: col.notnull, rows, idx, nullRejected }));
|
|
83
|
+
`);
|
|
84
|
+
expect(r.exitCode).toBe(0);
|
|
85
|
+
const m = r.stdout.match(/RESULT:(\{.*\})/);
|
|
86
|
+
expect(m).not.toBeNull();
|
|
87
|
+
const out = JSON.parse(m![1]);
|
|
88
|
+
expect(out.notnull).toBe(1); // 迁移后 NOT NULL
|
|
89
|
+
expect(out.rows).toBe(2); // 两行无损保留
|
|
90
|
+
expect(out.idx).toBe(1); // 索引重建
|
|
91
|
+
expect(out.nullRejected).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("migration guard: old table with a network_id IS NULL row → fail-safe (warn + skip, hub boots, data intact)", () => {
|
|
95
|
+
const file = seedOldDb([
|
|
96
|
+
{ mid: "dm_ok", net: "net_a", user: "u_1" },
|
|
97
|
+
{ mid: "dm_orphan", net: null, user: "u_2" }, // 理应不可达;若真出现 → warn+跳过,不停机
|
|
98
|
+
]);
|
|
99
|
+
const r = bootWithDb(file, `
|
|
100
|
+
const col = db.all("PRAGMA table_info(user_inbox)").find(c => c.name === "network_id");
|
|
101
|
+
const rows = db.get("SELECT COUNT(*) AS n FROM user_inbox").n;
|
|
102
|
+
console.log("RESULT:" + JSON.stringify({ notnull: col.notnull, rows }));
|
|
103
|
+
`);
|
|
104
|
+
expect(r.exitCode).toBe(0); // hub 照常启动(fail-safe,不停机)
|
|
105
|
+
expect(r.stderr).toMatch(/migrate #1493/); // 大声 warn(明确运维信号)
|
|
106
|
+
expect(r.stderr).toMatch(/network_id IS NULL/);
|
|
107
|
+
const m = r.stdout.match(/RESULT:(\{.*\})/);
|
|
108
|
+
expect(m).not.toBeNull();
|
|
109
|
+
const out = JSON.parse(m![1]);
|
|
110
|
+
expect(out.notnull).toBe(0); // 迁移被跳过 → 列暂保持可空
|
|
111
|
+
expect(out.rows).toBe(2); // 数据没丢(没盲目清空)
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("idempotent: booting an already-migrated (NOT NULL) DB twice is a no-op", () => {
|
|
115
|
+
const file = seedOldDb([{ mid: "dm_a", net: "net_a", user: "u_1" }]);
|
|
116
|
+
const r1 = bootWithDb(file, `console.log("RESULT:" + db.all("PRAGMA table_info(user_inbox)").find(c=>c.name==="network_id").notnull);`);
|
|
117
|
+
expect(r1.exitCode).toBe(0);
|
|
118
|
+
const r2 = bootWithDb(file, `console.log("RESULT:" + db.all("PRAGMA table_info(user_inbox)").find(c=>c.name==="network_id").notnull);`);
|
|
119
|
+
expect(r2.exitCode).toBe(0);
|
|
120
|
+
expect(r2.stdout).toContain("RESULT:1"); // 第二次仍 NOT NULL、不炸
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -16,7 +16,7 @@ let base = "";
|
|
|
16
16
|
let adminToken = "", aToken = "", bToken = "";
|
|
17
17
|
const MASTER_TOKEN = "legacy-master-token-for-test";
|
|
18
18
|
let aUserId = "", bUserId = "", aNet = "", bNet = "";
|
|
19
|
-
let adminUserId = "";
|
|
19
|
+
let adminUserId = "", adminNet = "";
|
|
20
20
|
|
|
21
21
|
const get = async (path: string, token: string) => {
|
|
22
22
|
const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
@@ -56,6 +56,7 @@ beforeAll(async () => {
|
|
|
56
56
|
expect(admin.ok).toBe(true);
|
|
57
57
|
adminToken = admin.token!;
|
|
58
58
|
adminUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `ui_admin_${stamp}`)!.user_id;
|
|
59
|
+
adminNet = admin.network_id!;
|
|
59
60
|
|
|
60
61
|
const a = register(`ui_a_${stamp}`, "UiA-Strong-1!");
|
|
61
62
|
const b = register(`ui_b_${stamp}`, "UiB-Strong-1!");
|
|
@@ -88,12 +89,18 @@ beforeAll(async () => {
|
|
|
88
89
|
// A 自己的、但落在 A 不是成员的 network 上的行(例如 A 被移出该网后的遗留)。
|
|
89
90
|
// 它是 addNetworkScope 这次调用的唯一 witnessed-red 对象:user_id 过滤对它无效。
|
|
90
91
|
seedMessage("dm_a_foreign_net", aUserId, bNet, "A 在外网的遗留私信");
|
|
91
|
-
// network_id
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
92
|
+
// #1493 —— network_id 升到 schema 级 NOT NULL 后,「network_id 为 NULL 的行」已
|
|
93
|
+
// **schema 级不可能**(写路径原本三闸挡、现在 DB 直接拒)。原先手播的 dm_a_nonet /
|
|
94
|
+
// dm_admin_nonet 两条 NULL 夹具随之删除:
|
|
95
|
+
// · dm_a_nonet(证"受网络限制成员读不到 NULL 行")—— 网络 scope 隔离已由下面
|
|
96
|
+
// dm_a_foreign_net(A 自己的行、落在 A 非成员的网)覆盖,不丢覆盖。
|
|
97
|
+
// · dm_admin_nonet 重锚到 admin **自己网络**的非 NULL 行 dm_admin_own,保住
|
|
98
|
+
// "admin(scope 助手 no-op)仍只读自己的、不绕过 user_id 过滤"这条覆盖。
|
|
99
|
+
seedMessage("dm_admin_own", adminUserId, adminNet, "admin 自己网络里的私信");
|
|
100
|
+
// #1493 SDK马 建议:用**非 NULL** 行补回"admin(scope 助手 no-op)能读到别网的自己的行"
|
|
101
|
+
// 这维覆盖(原先靠 NULL 行,现 NULL 不可能)。对照 dm_a_foreign_net(受限成员读不到),
|
|
102
|
+
// 两边都不依赖 NULL。
|
|
103
|
+
seedMessage("dm_admin_foreign_net", adminUserId, bNet, "admin 在别网的自己的私信");
|
|
97
104
|
seedMessage("dm_a_read", aUserId, aNet, "A 已读的一条", undefined, 1);
|
|
98
105
|
seedMessage("dm_a_secret", aUserId, aNet,
|
|
99
106
|
"凭据在正文里 ntok_abcdef123456", JSON.stringify({ k: "ghp_" + "A".repeat(24) }));
|
|
@@ -139,20 +146,16 @@ describe("#1459 ① P3 GET /api/messages?scope=user", () => {
|
|
|
139
146
|
expect(row.meta_json).toContain("ghp_***redacted***");
|
|
140
147
|
});
|
|
141
148
|
|
|
142
|
-
// NULL
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
test("NULL 网络行:受网络限制的成员读不到", async () => {
|
|
147
|
-
const { body } = await get("/api/messages?scope=user", aToken);
|
|
148
|
-
expect(body.messages.map((m: any) => m.message_id)).not.toContain("dm_a_nonet");
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test("NULL 网络行:scope 为 no-op 的调用者(admin)读得到自己的那条", async () => {
|
|
149
|
+
// #1493 后 NULL 网络行不可能,原"受网络限制成员读不到 NULL 行"一测删除——
|
|
150
|
+
// 网络 scope 隔离已由上面 dm_a_foreign_net(承重断言,test 112 line 121)覆盖。
|
|
151
|
+
// admin 一测重锚到非 NULL:验 admin(scope 助手 no-op)**仍只读自己的、不绕过 user_id 过滤**。
|
|
152
|
+
test("admin(scope 助手 no-op)仍受 user_id 限制:只读自己的、不读别人的", async () => {
|
|
152
153
|
const { body } = await get("/api/messages?scope=user", adminToken);
|
|
153
154
|
const ids = body.messages.map((m: any) => m.message_id);
|
|
154
|
-
expect(ids).toContain("
|
|
155
|
-
expect(ids).
|
|
155
|
+
expect(ids).toContain("dm_admin_own"); // 读得到自己的
|
|
156
|
+
expect(ids).toContain("dm_admin_foreign_net"); // scope 助手 no-op → 跨网也读得到自己的
|
|
157
|
+
expect(ids).not.toContain("dm_a1"); // 承重:admin 不绕过 user_id 过滤读别人的
|
|
158
|
+
expect(body.messages.every((m: any) => m.user_id === adminUserId)).toBe(true);
|
|
156
159
|
});
|
|
157
160
|
|
|
158
161
|
test("unread 是未读数而非总数(A:4 条里 3 条未读)", async () => {
|
|
@@ -37,8 +37,9 @@ describe("#1459 user_inbox schema (P1)", () => {
|
|
|
37
37
|
const mid = `dm_def_${Date.now()}`;
|
|
38
38
|
try {
|
|
39
39
|
db.run(
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// #1493 后 network_id 是 NOT NULL,补上(本测试只验其它列的默认值)
|
|
41
|
+
`INSERT INTO user_inbox (message_id, network_id, user_id, content) VALUES (?1, ?2, ?3, ?4)`,
|
|
42
|
+
[mid, "net_def_1459", "u_def_1459", "body"],
|
|
42
43
|
);
|
|
43
44
|
const row = db.get<{ acked: number; kind: string; severity: string }>(
|
|
44
45
|
"SELECT acked, kind, severity FROM user_inbox WHERE message_id = ?1", mid,
|