@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/src/server.ts CHANGED
@@ -3135,16 +3135,53 @@ 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;
3155
+ // #1545 —— 见 report_status schema 里 create_capability_observed_ms_ago 的注释:
3156
+ // 上报侧**故意收得宽**(无 int/min/max,免得一个诊断字段能让整份 report 被拒),
3157
+ // 消毒放在这里。非有限数、负数、超过 1 年的一律当作「没报」——
3158
+ // 🔴 不是 clamp 成边界值:clamp 会把一个坏值变成一个**看起来正常的年龄**,
3159
+ // 而这一格存在的全部意义就是让人分辨新鲜和陈旧。
3160
+ let snapObservedMsAgo: number | undefined;
3138
3161
  if (r.config_snapshot) {
3139
3162
  try {
3140
3163
  const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
3141
3164
  snapRole = typeof parsed?.role === "string" ? parsed.role : null;
3165
+ const caps = parsed?.daemon_capabilities;
3166
+ if (caps && typeof caps.can_create_nodes === "boolean") {
3167
+ snapCanCreate = caps.can_create_nodes;
3168
+ }
3169
+ if (caps && typeof caps.create_nodes_blocked_reason === "string"
3170
+ && caps.create_nodes_blocked_reason.length > 0) {
3171
+ snapBlockedReason = caps.create_nodes_blocked_reason;
3172
+ }
3173
+ const rawAge = caps?.create_capability_observed_ms_ago;
3174
+ if (typeof rawAge === "number" && Number.isFinite(rawAge)
3175
+ && rawAge >= 0 && rawAge <= 365 * 24 * 60 * 60 * 1000) {
3176
+ snapObservedMsAgo = rawAge;
3177
+ }
3142
3178
  } catch { /* malformed */ }
3143
3179
  }
3144
- return { row: r, role: snapRole };
3180
+ return { row: r, role: snapRole, canCreate: snapCanCreate, blockedReason: snapBlockedReason,
3181
+ observedMsAgo: snapObservedMsAgo };
3145
3182
  })
3146
3183
  .filter(({ role }) => role === "host_supervisor")
3147
- .map(({ row: r }) => {
3184
+ .map(({ row: r, canCreate, blockedReason, observedMsAgo }) => {
3148
3185
  let online = false;
3149
3186
  let lastSeenAt: string | null = null;
3150
3187
  if (r.session_last_seen) {
@@ -3174,7 +3211,7 @@ return Bun.serve({
3174
3211
  telemetry.mem_gb = r.session_mem_total_gb ?? null;
3175
3212
  telemetry.ip_internal = r.session_ip ?? null;
3176
3213
  }
3177
- return {
3214
+ const out: Record<string, unknown> = {
3178
3215
  daemon_node_id: r.node_id,
3179
3216
  alias: r.alias,
3180
3217
  hostname: r.hostname,
@@ -3184,6 +3221,32 @@ return Bun.serve({
3184
3221
  allowed_secret_keys: secrets,
3185
3222
  host_telemetry: telemetry,
3186
3223
  };
3224
+ // #1353 Fix ② — only emit the two capability keys when the
3225
+ // daemon actually reported them. Undefined vs. false is a
3226
+ // real distinction here (see Fix ② comment above).
3227
+ if (typeof canCreate === "boolean") {
3228
+ out.can_create_nodes = canCreate;
3229
+ // Blocked reason only meaningful when blocked. Some legacy
3230
+ // shapes could carry a reason without can_create_nodes=false
3231
+ // (defensive fallback in Fix ①); mirror the same choice —
3232
+ // don't confuse consumers with a reason on a healthy daemon.
3233
+ if (canCreate === false && blockedReason) {
3234
+ out.create_nodes_blocked_reason = blockedReason;
3235
+ }
3236
+ // #1545 —— 只在 daemon 真的报了这一格时才出现。
3237
+ // 🔴 缺席**不能**当成 0:那等于替一个从不重算的旧 daemon 宣称
3238
+ // 「这是刚测的」,正好朝「没问题」方向说谎。缺席就让它缺席,
3239
+ // 读的人据此说「年龄未知」(与 can_create_nodes 本身
3240
+ // undefined-vs-false 的处理同一条规矩,见上面 #1353 Fix ②)。
3241
+ //
3242
+ // 语义:该能力值是在**这份 report 发出前 N 毫秒**测得的。
3243
+ // 绝对年龄 = (now - last_seen_at) + create_capability_observed_ms_ago。
3244
+ // 用节点自己的钟只量了「多久以前」,绝对时间全部由 hub 的钟出。
3245
+ if (typeof observedMsAgo === "number") {
3246
+ out.create_capability_observed_ms_ago = observedMsAgo;
3247
+ }
3248
+ }
3249
+ return out;
3187
3250
  });
3188
3251
  return withCors(req, Response.json({ ok: true, daemons, count: daemons.length }));
3189
3252
  }
package/src/tools.ts CHANGED
@@ -517,6 +517,28 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
517
517
  model: z.string().max(200).optional().describe("AI model name"),
518
518
  node_name: z.string().max(200).optional().describe("Stable node display name (may differ from alias)"),
519
519
  network_id: z.string().max(200).optional().describe("Network this agent belongs to"),
520
+ // ╭─ 🔴 往 report_status 里加字段之前先读这一段(#1545 实测,zod 4.3.6)─────────╮
521
+ // │ **这份 schema 的子对象一半严一半松,而后果完全不同。**
522
+ // │
523
+ // │ 非 strict:host / process_telemetry / config_snapshot / daemon_capabilities
524
+ // │ `.strict()`:external_schedules(含其 schedules[] 元素)
525
+ // │ side_thread_capability(含其 exactBoundary)
526
+ // │
527
+ // │ 往**非 strict** 的对象里加一个 hub 还不认识的键 ⇒ zod **静默丢弃**。
528
+ // │ 节点不会掉线,但字段人间蒸发 —— 现场表现为「daemon 明明发了、hub 上没有」,
529
+ // │ 排查起来和 daemon 侧 bug 一模一样。
530
+ // │ 往**strict** 的对象里加同一个键 ⇒ `unrecognized_keys`,**整份 report_status 被拒**。
531
+ // │ 而 agent-node 的 register() 是 `await` 且无人 catch ⇒ **节点进程当场死掉**
532
+ // │ (#1225 就是这么全网躺倒的,见下面 host.ip 那段)。
533
+ // │
534
+ // │ 所以「新字段能不能 daemon 先发」**没有统一答案,取决于你往哪个子对象里加**:
535
+ // │ 非 strict → 可以先发(会丢,不会死);strict → hub 必须先合,否则升级即失联。
536
+ // │
537
+ // │ 🔴 别把这条读成「strict 更危险、都改成非 strict」。两者各有其位:
538
+ // │ external_schedules / side_thread_capability 是**有界快照**,strict 挡的是
539
+ // │ 「节点往里塞任意键」;daemon_capabilities 是**能力自述**,天然要向前兼容
540
+ // │ (旧 hub 见到新能力应当忽略而不是拒绝整份上报)。**加字段前先看清你在哪一边。**
541
+ // ╰──────────────────────────────────────────────────────────────────────────────╯
520
542
  host: z.object({
521
543
  // 🔴 `.nullable()` 不是防御性冗余,是**发送方声明的类型**:agent-node 的
522
544
  // HostTelemetry 是 `ip: string | null`,`firstNonInternalIPv4()` 在没有
@@ -599,6 +621,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
599
621
  // never saw them, max_concurrent_children stayed default + the
600
622
  // allowlists stayed unenforced. PR3 nit ① per 通信龙).
601
623
  // Soft caps avoid abuse via attacker daemon.
624
+ // 🔴 这个对象**故意不是 `.strict()`** —— 能力自述必须向前兼容:
625
+ // 旧 hub 见到新 daemon 报的新能力,应当忽略那一格,而不是拒掉整份 report
626
+ // 把节点踢下线。代价是新键在 hub 升级前会被**静默丢弃**(所以新字段仍然
627
+ // 应当 hub 先合)。同一份 schema 里 side_thread_capability / external_schedules
628
+ // 是 `.strict()` 的,后果不同 —— 见 `host:` 上方那段方框注释。
602
629
  daemon_capabilities: z.object({
603
630
  runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
604
631
  allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
@@ -636,6 +663,40 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
636
663
  "anet_bin_permission",
637
664
  "anet_bin_unknown",
638
665
  ]).optional(),
666
+ // #1545 —— 上面那一格**是什么时候测出来的**。
667
+ //
668
+ // 🔴 版本号一律带包名 + 完整版本:仓里三个包各自独立编号,裸的 `preview.NN`
669
+ // 同时是三个不同的时间点。此处曾写成「preview.67」——
670
+ // 那是 agent-network 的号,agent-node 根本没有 .67(npm 404,最高 .56)。
671
+ // 🔴 为什么必须有:**agent-node ≤ 2.5.0-preview.54** 的 `daemonCreateCapability()`
672
+ // 用一个进程级缓存(`_createCapCache`)只算一次。开机时 pin 坏 ⇒ 之后
673
+ // 永远上报 blocked(哪怕运维已经写好 path.conf);开机时好 ⇒ 之后把二进制
674
+ // chmod 掉也**永远上报 ready**。后者是朝「没问题」方向说谎。
675
+ // 而 hub 这边,一个 3 秒前测的 blocked 和一个三周前测的 blocked,
676
+ // 在 `last_seen_at` 上长得一模一样 —— 心跳是新的,那一格不是。
677
+ //
678
+ // 🔴 为什么是「多久以前」(时长)而不是绝对时间戳:这个值来自**节点自己的钟**。
679
+ // 时钟偏移下,绝对时间戳会算出一个既可能"永远新鲜"也可能"来自 1970"的年龄,
680
+ // 而且错的方向不可预测。时长对偏移免疫 —— hub 用自己的钟在收到时换算。
681
+ //
682
+ // 🔴 为什么不加 `.min()/.max()/.int()`:**这几个约束都会让整条 report_status 被拒**
683
+ // (zod 对象里任何一个已知字段验证失败 = 整份被拒,不是丢掉这一格),
684
+ // 而这一格是纯诊断信息,不值得拿一台节点的在线状态去换。
685
+ // 收得宽,**在读取处消毒**(见 /api/host-supervisors)。
686
+ // 这条不是我新立的:同一个 schema 上方 `anet_bin_pin_unresolved` 那条注释
687
+ // 记的就是同一次教训 —— 删一个 enum 值会让所有 .40 daemon 失联。
688
+ //
689
+ // 🔴 `.catch(null)` 不是装饰:光写 `z.number()` **仍然会拒 NaN**
690
+ // (实测:`z.number().nullable().optional()` 对 NaN 抛),而那意味着
691
+ // 一次算错的时长能让整台节点在 hub 上失联 —— 正是 #1225 里 host.ip
692
+ // 那个形状。`.catch(null)` 让这一格**在任何输入下都不可能拒掉整份 report**:
693
+ // 合法数字透传,其余一律变 null(读取侧当作「没报」)。
694
+ // 类型仍然是 number 而不是 unknown —— 别给一个 attacker daemon
695
+ // 留下"往快照里塞任意大对象"的口子(同 schema 上方 soft cap 的立场)。
696
+ //
697
+ // 不上报这一格 ≠ 0。旧 daemon 压根不发,读的人必须能把
698
+ // 「刚测的」「很久以前测的」「不知道」分成三件事说。
699
+ create_capability_observed_ms_ago: z.number().nullable().catch(null).optional(),
639
700
  }).optional(),
640
701
  }).optional().describe("RFC-024 — masked node config snapshot"),
641
702
  },
@@ -3207,6 +3268,91 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3207
3268
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
3208
3269
  }
3209
3270
 
3271
+ // #1353 Fix ① — daemon self-declared "I can't create nodes right now"
3272
+ // is now a dispatch-time gate, not just a stored fact. Before this,
3273
+ // daemon reported `daemon_capabilities.can_create_nodes=false` (via
3274
+ // #1371 + #1377), hub stored it in config_snapshot, and then no
3275
+ // consumer read it — dispatch still returned `{ok:true, request_id}`
3276
+ // and pushed an SSE doorbell to the broken daemon. That's the exact
3277
+ // "在线但静默失败" case the issue title reports.
3278
+ //
3279
+ // Vincent's own 2026-08-28 comment on #1353 authorizes this shape:
3280
+ // "daemon 侧确知不可用时,hub 应当**拒绝派发**并返回可操作的错误,
3281
+ // 而不是发一个注定失败的 doorbell".
3282
+ //
3283
+ // 🔴 Pre-#1371 compat: a daemon that has NOT reported
3284
+ // `can_create_nodes` at all leaves the field UNDEFINED in the
3285
+ // snapshot. Do NOT fail-closed on undefined — that would silently
3286
+ // start rejecting every daemon on the old (preview.10 through .55
3287
+ // roughly) shape. Only `=== false` gates; `=== true` and `undefined`
3288
+ // both pass through (new-guard-matches-existing-stance).
3289
+ try {
3290
+ const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
3291
+ const caps = snap?.daemon_capabilities;
3292
+ if (caps && caps.can_create_nodes === false) {
3293
+ const reason: string = (typeof caps.create_nodes_blocked_reason === "string"
3294
+ && caps.create_nodes_blocked_reason.length > 0)
3295
+ ? caps.create_nodes_blocked_reason
3296
+ : "anet_bin_unknown";
3297
+ // #1545 —— 光说 `blocked_reason` 不够:**那个判断是什么时候做出来的**
3298
+ // 决定了下一步完全不同的两件事。
3299
+ // 刚测的 blocked ⇒ 去那台机器按 reason 修
3300
+ // 三周前测的 blocked ⇒ 那台 daemon 是**开机算一次就永久缓存**的旧版本
3301
+ // (agent-node ≤ 2.5.0-preview.54),它可能早就好了 ——
3302
+ // 先重启/升级它,再谈修 pin
3303
+ // 而在拒绝这一刻,用户正好在决定"去修哪台机器"。
3304
+ //
3305
+ // 🔴 年龄未知时给的是**显式 null + 一个说明为什么的枚举**,不是省略这两个键。
3306
+ // 省略读起来像"没有这个问题",而调用方(常常是个 agent)会默认它是刚测的
3307
+ // —— 那是朝「更可信」方向撒谎,比不给更糟。
3308
+ const rawAge = caps.create_capability_observed_ms_ago;
3309
+ const reportedAgeOk = typeof rawAge === "number" && Number.isFinite(rawAge)
3310
+ && rawAge >= 0 && rawAge <= 365 * 24 * 60 * 60 * 1000;
3311
+ // 🔴 daemon 报的是「测完到**这份 report 发出**之间隔了多久」,不是「到现在」。
3312
+ // 两者差一个心跳间隔(3 分钟),而对一个已经失联的 daemon 差多少都可能。
3313
+ // 直接把它当成"多久以前测的"渲染,会**朝更新鲜的方向**低报 ——
3314
+ // 正是这一格要防的那个方向。所以在这里补上心跳那一段:
3315
+ // 绝对年龄 = (now − last_seen_at) + daemon 报的时长
3316
+ // hub 的钟出绝对时间,daemon 只提供时长(它自己的钟偏移污染不了这个数)。
3317
+ //
3318
+ // 这是冷路径(只有被拒时才走),多一次小查询不影响心跳路径。
3319
+ let heartbeatAgeMs: number | null = null;
3320
+ try {
3321
+ const sess = db.get<{ last_seen_at: string | null }>(
3322
+ "SELECT last_seen_at FROM sessions WHERE node_id = ?1 ORDER BY last_seen_at DESC LIMIT 1",
3323
+ daemon_node_id,
3324
+ );
3325
+ const t = sess?.last_seen_at ? Date.parse(sess.last_seen_at) : NaN;
3326
+ if (Number.isFinite(t)) heartbeatAgeMs = Math.max(0, Date.now() - t);
3327
+ } catch { /* 取不到就是取不到 —— 下面如实说 unknown,不猜 0 */ }
3328
+
3329
+ // 🔴 三种取值,不能塌成两种:
3330
+ // known 两半都有,可以给绝对年龄
3331
+ // unknown_legacy_daemon 那台 daemon 根本没报这一格(旧版本)
3332
+ // unknown_no_heartbeat_time 它报了,但 hub 这边没有可用的心跳时间
3333
+ // ⇒ **补不出绝对年龄**,而不是"年龄是 0"
3334
+ const ageKnown = reportedAgeOk && heartbeatAgeMs !== null;
3335
+ return { content: [{ type: "text" as const, text: JSON.stringify({
3336
+ ok: false,
3337
+ error: "daemon_cannot_create_nodes",
3338
+ blocked_reason: reason,
3339
+ // 到**现在**为止,这个判断做出来有多久了(毫秒)。null = 补不出来。
3340
+ capability_observed_ms_ago: ageKnown ? heartbeatAgeMs! + (rawAge as number) : null,
3341
+ capability_age: ageKnown
3342
+ ? "known"
3343
+ : (reportedAgeOk ? "unknown_no_heartbeat_time" : "unknown_legacy_daemon"),
3344
+ // 🔴 这里**不复制那张 code → 修法命令 的表**。它只有一个作者,在
3345
+ // `@sleep2agi/agent-network` 的 daemon-capability-display —— 抄一份到 hub,
3346
+ // 两份就会各自漂移,而「hub 教的修法」和「CLI 教的修法」不一致时,
3347
+ // 没有任何东西会红。这里只把人指到那一份。
3348
+ // (同 `blocked_reason` 是 z.enum 而不是自由文本的立场:hub 出**代码**,
3349
+ // 渲染留给客户端。)
3350
+ remediation_hint: "run `anet daemon list` where that daemon is configured — it prints the exact, paste-able fix command for this blocked_reason",
3351
+ daemon_node_id,
3352
+ }) }] };
3353
+ }
3354
+ } catch { /* malformed snapshot: permissive, mirrors L3230 fallthrough */ }
3355
+
3210
3356
  // Read daemon's host_supervisor capability + allowlist from its
3211
3357
  // last reported config_snapshot. PR3 (#338) canonical path is
3212
3358
  // `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,