@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.42",
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
- return {
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 为 NULL 的行。**写路径产不出这种行** —— send_desktop_message 有
92
- // 三道闸(canWrite / `if (!effectiveNetId)` / targetRole),拆掉任意一道都还有
93
- // 别的挡着,见 desktop-message-seam.test.ts 的两条孤儿断言。这里是手写夹具,
94
- // 用来钉「万一库里有这种行(历史数据、将来的第二个写入方),谁读得到」。
95
- seedMessage("dm_a_nonet", aUserId, null as any, "A 的无网络私信");
96
- seedMessage("dm_admin_nonet", adminUserId, null as any, "admin 的无网络私信");
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 网络行的可见性**取决于调用者的 scope,不取决于行本身**:
143
- // 受网络限制的成员 → addNetworkScope 生成 `network_id IN (...)`,NULL 不匹配 ⇒ 读不到
144
- // admin(resolveRestNetworkScope 给 networkIds=null,助手是 no-op) ⇒ 读得到
145
- // 两条一起才说明"NULL 读不到"是 scope 造成的,不是 user_id 过滤造成的。
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("dm_admin_nonet");
155
- expect(ids).not.toContain("dm_a1"); // 仍然只读自己的:admin 不绕过 user_id 过滤
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
- `INSERT INTO user_inbox (message_id, user_id, content) VALUES (?1, ?2, ?3)`,
41
- [mid, "u_def_1459", "body"],
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,