@sleep2agi/commhub-server 0.9.0-preview.31 → 0.9.0-preview.33

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.31",
3
+ "version": "0.9.0-preview.33",
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,39 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { registerTools } from "./tools.js";
4
+
5
+ type ToolHandler = (args: any) => Promise<any> | any;
6
+
7
+ function captureSchemas(): Record<string, Record<string, any>> {
8
+ const server = new McpServer({ name: "test", version: "0" }) as any;
9
+ const schemas: Record<string, Record<string, any>> = {};
10
+ const origTool = server.tool.bind(server);
11
+ server.tool = (name: string, desc: string, schema: any, handler: ToolHandler) => {
12
+ schemas[name] = schema;
13
+ return origTool(name, desc, schema, handler);
14
+ };
15
+ registerTools(server, undefined, null, "u_schema_test", "schema-test", false, null);
16
+ return schemas;
17
+ }
18
+
19
+ describe("create_node MCP schema", () => {
20
+ test("model is optional on daemon create specs but empty strings stay invalid", () => {
21
+ const schemas = captureSchemas();
22
+ const nodeSpec = schemas.create_node.node_spec;
23
+
24
+ expect(nodeSpec.safeParse({
25
+ name: "child",
26
+ runtime: "claude-agent-sdk",
27
+ }).success).toBe(true);
28
+ expect(nodeSpec.safeParse({
29
+ name: "child",
30
+ runtime: "claude-agent-sdk",
31
+ model: null,
32
+ }).success).toBe(true);
33
+ expect(nodeSpec.safeParse({
34
+ name: "child",
35
+ runtime: "claude-agent-sdk",
36
+ model: "",
37
+ }).success).toBe(false);
38
+ });
39
+ });
@@ -169,6 +169,16 @@ describe("buildAnetArgs (§4.2.2 F2 — fully validated argv)", () => {
169
169
  expect(args).toContain("--permission-mode");
170
170
  expect(args[args.indexOf("--permission-mode") + 1]).toBe("plan");
171
171
  });
172
+ test("omitted model is allowed and does not emit --model", () => {
173
+ const args = buildAnetArgs({
174
+ name: "x", runtime: "codex-sdk",
175
+ flags: { permissionMode: "plan" },
176
+ });
177
+ expect(args).toEqual(["node", "create", "x", "--runtime", "codex-sdk", "--permission-mode", "plan"]);
178
+ });
179
+ test("empty model is still rejected", () => {
180
+ expect(() => buildAnetArgs({ name: "x", runtime: "codex-sdk", model: "" })).toThrow(ValidationError);
181
+ });
172
182
  test("rejects bad name with shell metachar", () => {
173
183
  expect(() => buildAnetArgs({ name: ";rm -rf /", runtime: "claude-agent-sdk", model: "x" })).toThrow(ValidationError);
174
184
  });
@@ -46,7 +46,8 @@ export function validateRuntime(s: unknown): asserts s is Runtime {
46
46
  }
47
47
  }
48
48
 
49
- export function validateModel(s: unknown): asserts s is string {
49
+ export function validateModel(s: unknown): asserts s is string | undefined | null {
50
+ if (s === undefined || s === null) return;
50
51
  if (typeof s !== "string" || s.length === 0 || s.length > 100 || !MODEL_RE.test(s)) {
51
52
  throw new ValidationError("model_invalid", { value: typeof s === "string" ? s.slice(0, 80) : typeof s });
52
53
  }
@@ -173,7 +174,7 @@ export function validateChannelsP1(channels: unknown): void {
173
174
  export interface NodeSpec {
174
175
  name: string;
175
176
  runtime: Runtime;
176
- model: string;
177
+ model?: string | null;
177
178
  flags?: Record<string, unknown>;
178
179
  channels?: unknown;
179
180
  }
@@ -192,7 +193,8 @@ export function buildAnetArgs(spec: NodeSpec): string[] {
192
193
  validateModel(spec.model);
193
194
  validateChannelsP1(spec.channels);
194
195
 
195
- const args: string[] = ["node", "create", spec.name, "--runtime", spec.runtime, "--model", spec.model];
196
+ const args: string[] = ["node", "create", spec.name, "--runtime", spec.runtime];
197
+ if (spec.model) args.push("--model", spec.model);
196
198
  if (spec.flags && typeof spec.flags === "object") {
197
199
  for (const [k, v] of Object.entries(spec.flags)) {
198
200
  if (!(FLAG_KEYS as readonly string[]).includes(k)) {
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { parseDbTimestampMs } from "./db-timestamp.js";
3
+
4
+ const NAIVE = "2026-08-27 11:23:54";
5
+ const EXPECTED = Date.UTC(2026, 7, 27, 11, 23, 54);
6
+
7
+ describe("parseDbTimestampMs", () => {
8
+ // ⚠ 这条在 **TZ=UTC 的环境**下没有分辨力:裸 Date.parse 在 UTC 下恰好正确。
9
+ // CI runner 与容器默认就是 UTC(本仓 Docker 套件亦然),所以缺陷在常规跑法里
10
+ // 恒绿。(更正:Bun 是尊重 TZ 的,不存在"bun test 固定 UTC"——通信评审牛 #1279
11
+ // 独审③ 独立复跑证伪了我最初的说法。)真正稳定见红的门是下面那条强制子进程。
12
+ it("treats a naive SQLite timestamp as UTC", () => {
13
+ expect(parseDbTimestampMs(NAIVE)).toBe(EXPECTED);
14
+ });
15
+
16
+ // 分辨力关键:宿主是 UTC 时,裸 Date.parse 与正确写法结果相同,断言恒绿。
17
+ // 强制子进程 TZ=Asia/Shanghai,让缺陷在任何 CI 时区下都显形。
18
+ it("is unaffected by a non-UTC host timezone (child process TZ=Asia/Shanghai)", () => {
19
+ const r = Bun.spawnSync({
20
+ cmd: ["bun", "-e",
21
+ `const {parseDbTimestampMs} = await import("${import.meta.dir}/db-timestamp.ts");` +
22
+ `console.log(parseDbTimestampMs("${NAIVE}"));`],
23
+ env: { ...process.env, TZ: "Asia/Shanghai" },
24
+ });
25
+ expect(Number(r.stdout.toString().trim())).toBe(EXPECTED);
26
+ });
27
+
28
+ it("keeps explicit-offset timestamps intact", () => {
29
+ expect(parseDbTimestampMs("2026-08-27T11:23:54Z")).toBe(EXPECTED);
30
+ expect(parseDbTimestampMs("2026-08-27T19:23:54+08:00")).toBe(EXPECTED);
31
+ });
32
+
33
+ it("an online window of 5min holds for a heartbeat written 1s ago", () => {
34
+ const now = Date.now();
35
+ const iso = new Date(now - 1000).toISOString().slice(0, 19).replace("T", " ");
36
+ expect(now - parseDbTimestampMs(iso)).toBeLessThanOrEqual(5 * 60_000);
37
+ });
38
+ });
@@ -0,0 +1,15 @@
1
+ /**
2
+ * SQLite 里的时间戳列(sessions.last_seen_at、tasks.created_at 等)存的是
3
+ * **无时区标记的 UTC 串**(`YYYY-MM-DD HH:MM:SS`,由 SQLite `datetime('now')` 写入)。
4
+ *
5
+ * 裸 `Date.parse("2026-08-27 11:23:54")` 会按**宿主机本地时区**解释这个串。
6
+ * 生产机 TZ=CST+0800 时,它比真实 UTC 时刻早 8 小时——所以 `now - parsed`
7
+ * 恒为 +480 分钟,任何「最近 N 秒内算在线」的判据都会恒判离线。
8
+ * 在 TZ=UTC 的机器上两种写法结果相同,缺陷不会显形。
9
+ */
10
+ export function parseDbTimestampMs(value: string): number {
11
+ const s = String(value).trim();
12
+ // 已带时区标记(ISO 的 Z / ±HH:MM)就原样交给 Date
13
+ if (/[Zz]$|[+-]\d{2}:?\d{2}$/.test(s)) return Date.parse(s);
14
+ return Date.parse(s.replace(" ", "T") + "Z");
15
+ }
package/src/db.ts CHANGED
@@ -543,7 +543,7 @@ db.exec(`
543
543
  child_name TEXT NOT NULL,
544
544
  network_id TEXT NOT NULL,
545
545
  runtime TEXT NOT NULL,
546
- model TEXT NOT NULL,
546
+ model TEXT,
547
547
  flags_json TEXT NOT NULL,
548
548
  env_keys TEXT NOT NULL,
549
549
  status TEXT NOT NULL,
@@ -560,6 +560,63 @@ db.exec(`
560
560
  CREATE INDEX IF NOT EXISTS idx_ncr_network ON node_create_requests(network_id);
561
561
  CREATE INDEX IF NOT EXISTS idx_ncr_child_name ON node_create_requests(child_name);
562
562
  `);
563
+ function migrateNodeCreateRequestsModelNullable() {
564
+ if (db.dialect === "postgres") {
565
+ try { db.exec("ALTER TABLE node_create_requests ALTER COLUMN model DROP NOT NULL"); } catch {}
566
+ return;
567
+ }
568
+
569
+ let needsRebuild = false;
570
+ try {
571
+ const createSql = db.get<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'node_create_requests'");
572
+ needsRebuild = !!createSql?.sql && /\bmodel\s+TEXT\s+NOT\s+NULL\b/i.test(createSql.sql);
573
+ } catch {}
574
+ if (!needsRebuild) return;
575
+
576
+ db.transaction(() => {
577
+ db.exec("DROP TABLE IF EXISTS node_create_requests_migrated");
578
+ db.exec(`
579
+ CREATE TABLE node_create_requests_migrated (
580
+ request_id TEXT PRIMARY KEY,
581
+ daemon_node_id TEXT NOT NULL,
582
+ child_name TEXT NOT NULL,
583
+ network_id TEXT NOT NULL,
584
+ runtime TEXT NOT NULL,
585
+ model TEXT,
586
+ flags_json TEXT NOT NULL,
587
+ env_keys TEXT NOT NULL,
588
+ status TEXT NOT NULL,
589
+ error TEXT,
590
+ child_token_id TEXT,
591
+ created_at INTEGER NOT NULL,
592
+ created_by_token TEXT NOT NULL,
593
+ delivered_at INTEGER,
594
+ acked_at INTEGER,
595
+ child_node_id TEXT
596
+ )
597
+ `);
598
+ db.exec(`
599
+ INSERT INTO node_create_requests_migrated (
600
+ request_id, daemon_node_id, child_name, network_id, runtime, model,
601
+ flags_json, env_keys, status, error, child_token_id, created_at,
602
+ created_by_token, delivered_at, acked_at, child_node_id
603
+ )
604
+ SELECT
605
+ request_id, daemon_node_id, child_name, network_id, runtime, model,
606
+ flags_json, env_keys, status, error, child_token_id, created_at,
607
+ created_by_token, delivered_at, acked_at, child_node_id
608
+ FROM node_create_requests
609
+ `);
610
+ db.exec("DROP TABLE node_create_requests");
611
+ db.exec("ALTER TABLE node_create_requests_migrated RENAME TO node_create_requests");
612
+ });
613
+ }
614
+ migrateNodeCreateRequestsModelNullable();
615
+ db.exec(`
616
+ CREATE INDEX IF NOT EXISTS idx_ncr_daemon_status ON node_create_requests(daemon_node_id, status);
617
+ CREATE INDEX IF NOT EXISTS idx_ncr_network ON node_create_requests(network_id);
618
+ CREATE INDEX IF NOT EXISTS idx_ncr_child_name ON node_create_requests(child_name);
619
+ `);
563
620
  try {
564
621
  // Single-flight per (daemon, child_name) — prevents racing dashboard
565
622
  // create on same name. Mirrors RFC-024 uniq_ncu_node_inflight.
@@ -0,0 +1,140 @@
1
+ // f015d9d6 症状①:tasks.status 被置为 'delivered',而 SSE 推送根本没发出。
2
+ //
3
+ // send_task 无条件以字面量 'delivered' 插入 tasks 行,推送却另外被
4
+ // `if (target.state === "online")` 挡着(tools.ts:1432)。同一段代码里给
5
+ // network observer 的事件把同一件事标成 "queued" —— 也就是说代码**知道**
6
+ // 它没投出去,却仍然把 delivered 写进了 tasks.status。
7
+ //
8
+ // 而 "online" 的判据是 resolveDeliveryTarget 里的
9
+ // stale = now - (last_seen_at || updated_at) > 5 分钟
10
+ // 且 last_seen_at 只在 report_status 时刷新(tools.ts:674/702)。
11
+ // 全网「停发心跳/状态类消息」的省额度规则之下,一个安静的 MCP 会话必然变 stale。
12
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
+ import { db } from "./db.js";
15
+ import { registerTools } from "./tools.js";
16
+ import { __resetSSEClientsForTest, createSSEStream } from "./push.js";
17
+
18
+ const NET = "net_delivered_push_f015";
19
+ const USER = "user_delivered_push_f015";
20
+ const SENDER = "sender-f015b";
21
+ const TARGET = "quiet-mcp-f015b";
22
+
23
+ type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
24
+
25
+ function cleanup() {
26
+ __resetSSEClientsForTest();
27
+ for (const t of ["tasks", "inbox", "task_events", "sessions", "api_tokens", "nodes", "rename_txn"]) {
28
+ try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
29
+ }
30
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
31
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
32
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
33
+ }
34
+
35
+ /** minutesAgo 控制目标会话的 last_seen_at —— 这是本文件唯一要动的变量。 */
36
+ function seed(minutesAgo: number) {
37
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'admin', datetime('now'))", [USER]);
38
+ db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
39
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
40
+ for (const alias of [SENDER, TARGET]) {
41
+ db.run(
42
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, 'network')",
43
+ [`token-${alias}`, `hash-${alias}`, USER, NET, `node:${alias}`],
44
+ );
45
+ }
46
+ db.run(
47
+ "INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?1, 'idle', NULL, ?2, datetime('now'), datetime('now', ?3))",
48
+ [SENDER, NET, "-1 minutes"],
49
+ );
50
+ // 🔴 目标:MCP 挂载的 claude-code 会话形状(node_id NULL),
51
+ // status 是 'idle'(不是 offline),只是安静了 minutesAgo 分钟。
52
+ db.run(
53
+ "INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', NULL, ?3, datetime('now', ?4), datetime('now', ?4))",
54
+ [`cc-n_${TARGET}`, TARGET, NET, `-${minutesAgo} minutes`],
55
+ );
56
+ }
57
+
58
+ function toolsFor(alias: string): Record<string, ToolHandler> {
59
+ const server = new McpServer({ name: "delivered-push-f015", version: "0" }) as any;
60
+ const tools: Record<string, ToolHandler> = {};
61
+ const original = server.tool.bind(server);
62
+ server.tool = (name: string, description: string, schema: any, handler: ToolHandler) => {
63
+ tools[name] = handler; return original(name, description, schema, handler);
64
+ };
65
+ registerTools(server, undefined, NET, USER, alias, true, `token-${alias}`);
66
+ return tools;
67
+ }
68
+
69
+ /** 从订阅者那一侧真正读出字节 —— 地面真相,不是连接计数。 */
70
+ async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, needle: string, ms = 1500): Promise<string | null> {
71
+ const dec = new TextDecoder();
72
+ let buf = "";
73
+ const deadline = Date.now() + ms;
74
+ while (Date.now() < deadline) {
75
+ const chunk = await Promise.race([
76
+ reader.read(),
77
+ new Promise<{ done: true; value: undefined }>((r) => setTimeout(() => r({ done: true, value: undefined }), 200)),
78
+ ]);
79
+ if (chunk.value) buf += dec.decode(chunk.value, { stream: true });
80
+ if (buf.includes(needle)) return buf;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ let sendSeq = 0;
86
+ /** minutesAgo = 目标安静了多久;withSubscriber = 是否真的有人连着。 */
87
+ async function send(minutesAgo: number, withSubscriber = true) {
88
+ cleanup(); seed(minutesAgo);
89
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
90
+ if (withSubscriber) {
91
+ const res = createSSEStream(TARGET, NET);
92
+ reader = res.body!.getReader();
93
+ }
94
+ const r = await toolsFor(SENDER).send_task({ alias: TARGET, task: `ping-${++sendSeq}`, priority: "normal" });
95
+ const out = JSON.parse(r.content[0].text);
96
+ const row = db.get<{ status: string }>("SELECT status FROM tasks WHERE to_name = ?1 AND network_id = ?2", TARGET, NET);
97
+ const received = reader ? await readUntil(reader, "new_task") : null;
98
+ return { out, taskStatus: row?.status ?? null, received };
99
+ }
100
+
101
+ describe("f015d9d6 症状① — delivered 置位与实际推送不同步", () => {
102
+ beforeEach(() => cleanup());
103
+ afterAll(() => cleanup());
104
+
105
+ test("对照:目标 1 分钟前还活跃 ⇒ 订阅者收到 new_task", async () => {
106
+ const { taskStatus, received } = await send(1);
107
+ expect(taskStatus).toBe("delivered");
108
+ expect(received).toContain("new_task");
109
+ });
110
+
111
+ test("🔴 核心:目标安静 10 分钟但订阅者连着 ⇒ 仍然收得到(修复前这里收到 null)", async () => {
112
+ const { taskStatus, received } = await send(10);
113
+ expect(received).toContain("new_task");
114
+ expect(taskStatus).toBe("delivered"); // 现在这个 delivered 是真话了
115
+ });
116
+
117
+ test("🔴 可达性判据必须来自订阅者注册表,不是 last_seen_at 的时间戳猜测", async () => {
118
+ // 安静 10 分钟 = 时间戳判据会说 offline;而实际上有人在听。
119
+ const quiet = await send(10);
120
+ expect(quiet.received).toContain("new_task");
121
+ // 真没人听的时候,pushEvent 本来就是 no-op,不需要额外的闸来保护。
122
+ const nobody = await send(10, false);
123
+ expect(nobody.received).toBeNull();
124
+ });
125
+
126
+ test("真的没有订阅者时,tasks 行仍写 delivered —— 本 PR 刻意不改,记录在案", async () => {
127
+ const { taskStatus, received } = await send(10, false);
128
+ expect(received).toBeNull();
129
+ // 这一条钉的是**当前行为**,不是我认为对的行为:没人收到,行仍自称 delivered。
130
+ expect(taskStatus).toBe("delivered");
131
+ });
132
+
133
+ test("活性判据的输入 last_seen_at 只由 report_status 刷新 —— 发送方不刷新它", async () => {
134
+ cleanup(); seed(4);
135
+ const before = db.get<{ last_seen_at: string }>("SELECT last_seen_at FROM sessions WHERE alias = ?1 AND network_id = ?2", TARGET, NET);
136
+ await toolsFor(SENDER).send_task({ alias: TARGET, task: `ping-${++sendSeq}`, priority: "normal" });
137
+ const after = db.get<{ last_seen_at: string }>("SELECT last_seen_at FROM sessions WHERE alias = ?1 AND network_id = ?2", TARGET, NET);
138
+ expect(after?.last_seen_at).toBe(before!.last_seen_at);
139
+ });
140
+ });
@@ -0,0 +1,222 @@
1
+ // Desktop user push contract.
2
+ //
3
+ // Pins the new user-keyed SSE path and send_desktop_message MCP tool:
4
+ // - user streams are keyed by networkId:userId, never alias
5
+ // - ntok callers cannot cross network by supplying a foreign to_user_id/network_id
6
+ // - target ambiguity/miss is fail-closed before audit/push
7
+
8
+ import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { db } from "./db.js";
11
+ import { registerTools } from "./tools.js";
12
+ import {
13
+ __resetSSEClientsForTest,
14
+ createUserEventStream,
15
+ pushUserEvent,
16
+ } from "./push.js";
17
+
18
+ const NET_A = "net_desktop_a";
19
+ const NET_B = "net_desktop_b";
20
+ const NODE_USER = "u_desktop_node_owner";
21
+ const SENDER_USER = "u_desktop_sender";
22
+ const TARGET_A = "u_desktop_target_a";
23
+ const TARGET_B = "u_desktop_target_b";
24
+ const TARGET_BOTH = "u_desktop_target_both";
25
+ const ALL_USERS = [NODE_USER, SENDER_USER, TARGET_A, TARGET_B, TARGET_BOTH];
26
+
27
+ type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
28
+
29
+ function cleanup() {
30
+ try { db.run("DELETE FROM audit_log WHERE action = 'send_desktop_message' OR network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
31
+ try { db.run("DELETE FROM network_members WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
32
+ try { db.run("DELETE FROM networks WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
33
+ for (const u of ALL_USERS) {
34
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [u]); } catch {}
35
+ }
36
+ }
37
+
38
+ function seed() {
39
+ for (const u of ALL_USERS) {
40
+ db.run(
41
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
42
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
43
+ [u, `${u}_name`],
44
+ );
45
+ }
46
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_A, SENDER_USER]);
47
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_B, TARGET_B]);
48
+ const member = (userId: string, networkId: string, role: string) =>
49
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [userId, networkId, role]);
50
+ member(NODE_USER, NET_A, "member");
51
+ member(SENDER_USER, NET_A, "owner");
52
+ member(TARGET_A, NET_A, "member");
53
+ member(TARGET_B, NET_B, "member");
54
+ member(TARGET_BOTH, NET_A, "member");
55
+ member(TARGET_BOTH, NET_B, "member");
56
+ }
57
+
58
+ beforeEach(() => { cleanup(); seed(); });
59
+ afterEach(() => { __resetSSEClientsForTest(); });
60
+ afterAll(cleanup);
61
+
62
+ function buildHandlers(opts: { netId?: string | null; userId?: string | null; alias?: string | null; isNetwork?: boolean }) {
63
+ const server = new McpServer({ name: "test", version: "0" }) as any;
64
+ const tools: Record<string, ToolHandler> = {};
65
+ const origTool = server.tool.bind(server);
66
+ server.tool = (name: string, _desc: string, schema: any, handler: ToolHandler) => {
67
+ tools[name] = handler;
68
+ return origTool(name, _desc, schema, handler);
69
+ };
70
+ registerTools(
71
+ server,
72
+ undefined,
73
+ opts.netId ?? null,
74
+ opts.userId ?? null,
75
+ opts.alias ?? opts.userId ?? null,
76
+ opts.isNetwork ?? false,
77
+ "tok_desktop_test",
78
+ );
79
+ return tools;
80
+ }
81
+
82
+ async function call(handler: ToolHandler, args: any): Promise<Record<string, any>> {
83
+ const r = await handler(args);
84
+ return JSON.parse(r.content[0].text);
85
+ }
86
+
87
+ async function readFrame(
88
+ reader: ReadableStreamDefaultReader<Uint8Array>,
89
+ timeoutMs = 2_000,
90
+ ): Promise<Record<string, any>> {
91
+ const decoder = new TextDecoder();
92
+ let buf = "";
93
+ const deadline = Date.now() + timeoutMs;
94
+ while (Date.now() < deadline) {
95
+ const result = await Promise.race([
96
+ reader.read(),
97
+ new Promise<never>((_, rej) =>
98
+ setTimeout(() => rej(new Error(`no SSE frame within ${timeoutMs}ms`)), deadline - Date.now()),
99
+ ),
100
+ ]);
101
+ if (result.done) throw new Error("SSE stream ended unexpectedly");
102
+ buf += decoder.decode(result.value, { stream: true });
103
+ const sep = buf.indexOf("\n\n");
104
+ if (sep === -1) continue;
105
+ const rawFrame = buf.slice(0, sep);
106
+ buf = buf.slice(sep + 2);
107
+ const dataLine = rawFrame.split("\n").find((l) => l.startsWith("data: "));
108
+ if (!dataLine) continue;
109
+ return JSON.parse(dataLine.slice(6));
110
+ }
111
+ throw new Error(`no SSE frame within ${timeoutMs}ms`);
112
+ }
113
+
114
+ function auditCount() {
115
+ return db.get<{ n: number }>("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'send_desktop_message'")?.n ?? 0;
116
+ }
117
+
118
+ describe("send_desktop_message", () => {
119
+ test("ntok caller can push to a user in the same bound network", async () => {
120
+ const stream = createUserEventStream(NET_A, TARGET_A);
121
+ const reader = stream.body!.getReader();
122
+ await readFrame(reader);
123
+
124
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
125
+ const result = await call(tools.send_desktop_message, {
126
+ to_user_id: TARGET_A,
127
+ message: "desktop hello",
128
+ severity: "success",
129
+ });
130
+
131
+ expect(result.ok).toBe(true);
132
+ expect(result.message_id).toStartWith("dm_");
133
+ expect(auditCount()).toBe(1);
134
+ const evt = await readFrame(reader);
135
+ expect(evt.type).toBe("desktop_message");
136
+ expect(evt.message_id).toBe(result.message_id);
137
+ expect(evt.message).toBe("desktop hello");
138
+ expect(evt.from).toBe("node-a");
139
+ expect(evt.network_id).toBe(NET_A);
140
+ expect(evt.user_id).toBe(TARGET_A);
141
+ expect(evt.scope).toBe("user");
142
+ await reader.cancel();
143
+ });
144
+
145
+ test("utok caller can push with explicit network_id to a member user", async () => {
146
+ const stream = createUserEventStream(NET_A, TARGET_A);
147
+ const reader = stream.body!.getReader();
148
+ await readFrame(reader);
149
+
150
+ const tools = buildHandlers({ userId: SENDER_USER, alias: "sender-user" });
151
+ const result = await call(tools.send_desktop_message, {
152
+ to_username: `${TARGET_A}_name`,
153
+ network_id: NET_A,
154
+ title: "Heads up",
155
+ message: "from user token",
156
+ });
157
+
158
+ expect(result.ok).toBe(true);
159
+ const evt = await readFrame(reader);
160
+ expect(evt.title).toBe("Heads up");
161
+ expect(evt.message).toBe("from user token");
162
+ expect(evt.network_id).toBe(NET_A);
163
+ await reader.cancel();
164
+ });
165
+
166
+ test("ntok caller cannot cross network with foreign network_id and to_user_id; no audit or push happens first", async () => {
167
+ const stream = createUserEventStream(NET_B, TARGET_B);
168
+ const reader = stream.body!.getReader();
169
+ await readFrame(reader);
170
+
171
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
172
+ const result = await call(tools.send_desktop_message, {
173
+ to_user_id: TARGET_B,
174
+ network_id: NET_B,
175
+ message: "must not cross",
176
+ });
177
+
178
+ expect(result).toEqual({ ok: false, error: "desktop_target_not_in_network", network_id: NET_A });
179
+ expect(auditCount()).toBe(0);
180
+
181
+ pushUserEvent(NET_B, TARGET_B, { type: "desktop_message", message_id: "marker_after_denied" });
182
+ expect((await readFrame(reader)).message_id).toBe("marker_after_denied");
183
+ await reader.cancel();
184
+ });
185
+
186
+ test("missing target is fail-closed before audit", async () => {
187
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
188
+ const result = await call(tools.send_desktop_message, { message: "nobody" });
189
+ expect(result).toEqual({
190
+ ok: false,
191
+ error: "desktop_target_required",
192
+ message: "to_user_id or to_username is required",
193
+ });
194
+ expect(auditCount()).toBe(0);
195
+ });
196
+
197
+ test("conflicting to_user_id and to_username is fail-closed before audit", async () => {
198
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
199
+ const result = await call(tools.send_desktop_message, {
200
+ to_user_id: TARGET_A,
201
+ to_username: `${TARGET_BOTH}_name`,
202
+ message: "conflict",
203
+ });
204
+ expect(result).toEqual({
205
+ ok: false,
206
+ error: "desktop_target_mismatch",
207
+ to_user_id: TARGET_A,
208
+ to_username: `${TARGET_BOTH}_name`,
209
+ });
210
+ expect(auditCount()).toBe(0);
211
+ });
212
+
213
+ test("unknown target is fail-closed before audit", async () => {
214
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
215
+ const result = await call(tools.send_desktop_message, {
216
+ to_username: "not_a_real_user",
217
+ message: "lost",
218
+ });
219
+ expect(result).toEqual({ ok: false, error: "desktop_target_not_found", field: "to_username" });
220
+ expect(auditCount()).toBe(0);
221
+ });
222
+ });
@@ -187,6 +187,81 @@ describe("#461 GET /events/network/:id — auth", () => {
187
187
  });
188
188
  });
189
189
 
190
+ describe("GET /events/users/me — Desktop user stream auth", () => {
191
+ test("anonymous → 401", async () => {
192
+ const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(memberNetworkId)}`);
193
+ expect(res.status).toBe(401);
194
+ });
195
+
196
+ test("ntok is refused for user stream", async () => {
197
+ const minted = createNetworkTokenForNode(memberUserId, memberNetworkId, "desktop-ntok-probe");
198
+ expect(minted.ok).toBe(true);
199
+ const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(memberNetworkId)}`, { headers: auth(minted.token!) });
200
+ expect(res.status).toBe(403);
201
+ const body = await res.json() as any;
202
+ expect(body.error).toBe("user_token_required");
203
+ });
204
+
205
+ test("utok without network_id → 400", async () => {
206
+ const res = await fetch(`${BASE}/events/users/me`, { headers: auth(memberToken) });
207
+ expect(res.status).toBe(400);
208
+ const body = await res.json() as any;
209
+ expect(body.error).toBe("network_id required");
210
+ });
211
+
212
+ test("non-member utok → 403", async () => {
213
+ const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(memberNetworkId)}`, { headers: auth(outsiderToken) });
214
+ expect(res.status).toBe(403);
215
+ const body = await res.json() as any;
216
+ expect(body.error).toBe("not a member of this network");
217
+ });
218
+
219
+ test("member utok → user stream connected frame keyed to authenticated user", async () => {
220
+ const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(memberNetworkId)}`, { headers: auth(memberToken) });
221
+ expect(res.status).toBe(200);
222
+ expect(res.headers.get("content-type") || "").toContain("text/event-stream");
223
+ const reader = res.body!.getReader();
224
+ const connected = await readFrame(reader);
225
+ expect(connected.type).toBe("connected");
226
+ expect(connected.user).toBe(true);
227
+ expect(connected.network_id).toBe(memberNetworkId);
228
+ expect(connected.user_id).toBe(memberUserId);
229
+ await reader.cancel();
230
+ });
231
+ });
232
+
233
+ describe("GET /api/messages — client inbox count", () => {
234
+ test("alias filter returns only that inbox and pending_count for unacked rows", async () => {
235
+ const selfAlias = `user-${memberUserId}`;
236
+ db.run(
237
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, acked, network_id, created_at)
238
+ VALUES (?1, ?2, 'task', 'normal', 'unread self', 'agent-a', 0, ?3, datetime('now'))`,
239
+ [`msg_self_unread_${Date.now()}`, selfAlias, memberNetworkId],
240
+ );
241
+ db.run(
242
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, acked, network_id, created_at)
243
+ VALUES (?1, ?2, 'task', 'normal', 'read self', 'agent-a', 1, ?3, datetime('now'))`,
244
+ [`msg_self_read_${Date.now()}`, selfAlias, memberNetworkId],
245
+ );
246
+ db.run(
247
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, acked, network_id, created_at)
248
+ VALUES (?1, 'other-alias', 'task', 'normal', 'other inbox', 'agent-b', 0, ?2, datetime('now'))`,
249
+ [`msg_other_${Date.now()}`, memberNetworkId],
250
+ );
251
+
252
+ const res = await fetch(`${BASE}/api/messages?alias=${encodeURIComponent(selfAlias)}&network_id=${encodeURIComponent(memberNetworkId)}&limit=20`, {
253
+ headers: auth(memberToken),
254
+ });
255
+ expect(res.status).toBe(200);
256
+ const body = await res.json() as any;
257
+ expect(body.ok).toBe(true);
258
+ expect(body.pending_count).toBe(1);
259
+ expect(body.messages.length).toBeGreaterThanOrEqual(2);
260
+ expect(body.messages.every((m: any) => m.to_alias === selfAlias)).toBe(true);
261
+ expect(body.messages.some((m: any) => m.content === "other inbox")).toBe(false);
262
+ });
263
+ });
264
+
190
265
  describe("#461 observer receives third-party traffic summaries", () => {
191
266
  test("REST /api/task dispatch → observer gets new_task summary, no content", async () => {
192
267
  const res = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(memberToken) });