@sleep2agi/commhub-server 0.9.0-preview.32 → 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.32",
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)) {
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,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) });
@@ -11,8 +11,10 @@ import {
11
11
  __resetSSEClientsForTest,
12
12
  createNetworkObserverStream,
13
13
  createSSEStream,
14
+ createUserEventStream,
14
15
  pushEvent,
15
16
  pushNetworkObserverEvent,
17
+ pushUserEvent,
16
18
  } from "./push";
17
19
 
18
20
  afterEach(() => {
@@ -174,3 +176,77 @@ describe("#461 network observer stream", () => {
174
176
  await reader2.cancel();
175
177
  });
176
178
  });
179
+
180
+ describe("Desktop user SSE stream", () => {
181
+ test("user stream receives connected frame, then desktop_message", async () => {
182
+ const res = createUserEventStream("net_user_a", "u_user_a");
183
+ const reader = res.body!.getReader();
184
+
185
+ const connected = await readFrame(reader);
186
+ expect(connected.type).toBe("connected");
187
+ expect(connected.user).toBe(true);
188
+ expect(connected.network_id).toBe("net_user_a");
189
+ expect(connected.user_id).toBe("u_user_a");
190
+
191
+ pushUserEvent("net_user_a", "u_user_a", {
192
+ type: "desktop_message",
193
+ message_id: "dm_one",
194
+ from: "agent-a",
195
+ message: "hello",
196
+ });
197
+
198
+ const evt = await readFrame(reader);
199
+ expect(evt.type).toBe("desktop_message");
200
+ expect(evt.message_id).toBe("dm_one");
201
+ expect(evt.from).toBe("agent-a");
202
+ expect(evt.message).toBe("hello");
203
+ expect(evt.network_id).toBe("net_user_a");
204
+ expect(evt.user_id).toBe("u_user_a");
205
+ expect(evt.scope).toBe("user");
206
+ await reader.cancel();
207
+ });
208
+
209
+ test("user stream is isolated from alias and observer key spaces", async () => {
210
+ const userRes = createUserEventStream("net_user_iso", "u_iso");
211
+ const userReader = userRes.body!.getReader();
212
+ await readFrame(userReader);
213
+
214
+ const sessionRes = createSSEStream("u_iso", "net_user_iso");
215
+ const sessionReader = sessionRes.body!.getReader();
216
+ await readFrame(sessionReader);
217
+
218
+ const observerRes = createNetworkObserverStream("net_user_iso");
219
+ const observerReader = observerRes.body!.getReader();
220
+ await readFrame(observerReader);
221
+
222
+ pushEvent("u_iso", { type: "alias_marker", marker: "alias" }, "net_user_iso");
223
+ pushNetworkObserverEvent("net_user_iso", { type: "observer_marker", marker: "observer" });
224
+ pushUserEvent("net_user_iso", "u_iso", { type: "desktop_message", marker: "user" });
225
+
226
+ expect((await readFrame(sessionReader)).marker).toBe("alias");
227
+ expect((await readFrame(observerReader)).marker).toBe("observer");
228
+ expect((await readFrame(userReader)).marker).toBe("user");
229
+
230
+ await userReader.cancel();
231
+ await sessionReader.cancel();
232
+ await observerReader.cancel();
233
+ });
234
+
235
+ test("same user and network fan out to multiple live clients; cancelling one leaves the other live", async () => {
236
+ const r1 = createUserEventStream("net_user_multi", "u_multi");
237
+ const r2 = createUserEventStream("net_user_multi", "u_multi");
238
+ const reader1 = r1.body!.getReader();
239
+ const reader2 = r2.body!.getReader();
240
+ await readFrame(reader1);
241
+ await readFrame(reader2);
242
+
243
+ pushUserEvent("net_user_multi", "u_multi", { type: "desktop_message", message_id: "dm_fanout_1" });
244
+ expect((await readFrame(reader1)).message_id).toBe("dm_fanout_1");
245
+ expect((await readFrame(reader2)).message_id).toBe("dm_fanout_1");
246
+
247
+ await reader1.cancel();
248
+ pushUserEvent("net_user_multi", "u_multi", { type: "desktop_message", message_id: "dm_fanout_2" });
249
+ expect((await readFrame(reader2)).message_id).toBe("dm_fanout_2");
250
+ await reader2.cancel();
251
+ });
252
+ });
package/src/push.ts CHANGED
@@ -109,6 +109,7 @@ function clientKey(sessionName: string, networkId?: string | null): string {
109
109
  // master-token caller could even inject a NUL into network_id via the
110
110
  // legacy ?network_id= path, and master token is root already.
111
111
  const OBSERVER_KEY_PREFIX = "\u0000netobs:";
112
+ const USER_KEY_PREFIX = "\u0000user:";
112
113
 
113
114
  /** Printable form of `OBSERVER_KEY_PREFIX` — the shape emitted by
114
115
  * `printableKey()` (below) and therefore the shape callers see via
@@ -123,6 +124,10 @@ function observerKey(networkId: string): string {
123
124
  return `${OBSERVER_KEY_PREFIX}${networkId}`;
124
125
  }
125
126
 
127
+ function userKey(networkId: string, userId: string): string {
128
+ return `${USER_KEY_PREFIX}${networkId}:${userId}`;
129
+ }
130
+
126
131
  /** Render a client key for logs / stats. Observer keys embed a raw NUL
127
132
  * byte (collision guard above); printing that verbatim makes grep treat
128
133
  * hub logs as binary and breaks log shippers, and getSSEStats feeds
@@ -265,6 +270,15 @@ export function createNetworkObserverStream(networkId: string): Response {
265
270
  );
266
271
  }
267
272
 
273
+ /** Desktop/user client SSE stream. Keyed by authenticated user_id, not
274
+ * alias/session, so Desktop does not need to mint a pseudo node token. */
275
+ export function createUserEventStream(networkId: string, userId: string): Response {
276
+ return createStreamForKey(
277
+ userKey(networkId, userId),
278
+ { type: "connected", user: true, network_id: networkId, user_id: userId },
279
+ );
280
+ }
281
+
268
282
  function createStreamForKey(key: string, initialEvent: Record<string, unknown>): Response {
269
283
  const encoder = new TextEncoder();
270
284
  let client: SSEClient;
@@ -447,6 +461,34 @@ export function pushNetworkObserverEvent(
447
461
  if (needPrune) pruneClosed(key);
448
462
  }
449
463
 
464
+ /** Push an event to every active Desktop/web client for one user in one
465
+ * network. No-op for falsy network/user ids; auth must be enforced by
466
+ * the caller before invoking this helper. */
467
+ export function pushUserEvent(
468
+ networkId: string | null | undefined,
469
+ userId: string | null | undefined,
470
+ event: Record<string, unknown>,
471
+ ): void {
472
+ if (!networkId || !userId) return;
473
+ const key = userKey(networkId, userId);
474
+ const arr = clients.get(key);
475
+ if (!arr || arr.length === 0) return;
476
+
477
+ const data = `data: ${JSON.stringify({ ...event, network_id: networkId, user_id: userId, scope: "user" })}\n\n`;
478
+ let needPrune = false;
479
+
480
+ for (const c of arr) {
481
+ if (c.closed) {
482
+ needPrune = true;
483
+ continue;
484
+ }
485
+ const result = tryEnqueueBytes(c, c.encoder.encode(data));
486
+ if (result === "dead") needPrune = true;
487
+ }
488
+
489
+ if (needPrune) pruneClosed(key);
490
+ }
491
+
450
492
  export function __resetSSEClientsForTest(): void {
451
493
  for (const arr of clients.values()) {
452
494
  for (const c of arr) closeClient(c, "reset-for-test");
package/src/server.ts CHANGED
@@ -6,7 +6,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
6
6
  import { z } from "zod/v4";
7
7
  import { registerTools } from "./tools.js";
8
8
  import { db, logTaskEvent, logAudit, syncScheduledRunForTask } from "./db.js";
9
- import { createSSEStream, createNetworkObserverStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX } from "./push.js";
9
+ import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX } from "./push.js";
10
10
  import { assertNodeActive } from "./lifecycle-guard.js";
11
11
  import { addNetworkScope, canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
12
12
  import { validateAvatarUrl } from "./avatar-validate.js";
@@ -871,6 +871,32 @@ return Bun.serve({
871
871
  return createNetworkObserverStream(observedNetId);
872
872
  }
873
873
 
874
+ // GET /events/users/me?network_id=... → Desktop/user-client stream.
875
+ // This is intentionally keyed by authenticated user_id, not alias.
876
+ // Desktop must use its utok_; ntok_ is a node credential and cannot
877
+ // subscribe to user channels.
878
+ if (url.pathname === "/events/users/me" && req.method === "GET") {
879
+ const authErr = requireAuth(req);
880
+ if (authErr) return authErr;
881
+ const token = requestToken(req);
882
+ if (!token?.startsWith("utok_")) {
883
+ return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
884
+ }
885
+ const authCtx = resolveRequestAuth(req);
886
+ if (!authCtx) {
887
+ return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 403 }));
888
+ }
889
+ const networkId = url.searchParams.get("network_id");
890
+ if (!networkId) {
891
+ return withCors(req, Response.json({ ok: false, error: "network_id required" }, { status: 400 }));
892
+ }
893
+ const role = getUserNetworkRole(authCtx.userId, networkId);
894
+ if (!role) {
895
+ return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
896
+ }
897
+ return createUserEventStream(networkId, authCtx.userId);
898
+ }
899
+
874
900
  // ── SSE push: Agent 实时接收任务推送 ──
875
901
  // GET /events/知识哥 → 保持长连接,send_task 时秒推
876
902
  const eventsMatch = url.pathname.match(/^\/events\/(.+)$/);
@@ -2599,13 +2625,27 @@ return Bun.serve({
2599
2625
  if (url.pathname === "/api/messages") {
2600
2626
  const limit = Math.min(Number(url.searchParams.get("limit")) || 100, 500);
2601
2627
  const since = url.searchParams.get("since") ?? new Date(Date.now() - 3600000).toISOString().replace("T", " ").slice(0, 19);
2628
+ const alias = url.searchParams.get("alias")?.trim() || null;
2602
2629
  const params: any[] = [since];
2603
- let sql = "SELECT id, session_name as to_alias, from_session as from_alias, type, priority, content, created_at, network_id FROM inbox WHERE created_at >= ?1";
2630
+ let sql = "SELECT id, session_name as to_alias, from_session as from_alias, type, priority, content, acked, created_at, network_id FROM inbox WHERE created_at >= ?1";
2631
+ if (alias) {
2632
+ params.push(alias);
2633
+ sql += ` AND session_name = ?${params.length}`;
2634
+ }
2604
2635
  sql = addNetworkScope(sql, params, restScope);
2605
2636
  sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
2606
2637
  params.push(limit);
2607
2638
  const rows = db.all(sql, ...params);
2608
- return withCors(req, Response.json({ ok: true, messages: rows }));
2639
+
2640
+ let pendingCount: number | undefined;
2641
+ if (alias) {
2642
+ const countParams: any[] = [alias];
2643
+ let countSql = "SELECT COUNT(*) AS cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
2644
+ countSql = addNetworkScope(countSql, countParams, restScope);
2645
+ pendingCount = db.get<{ cnt: number }>(countSql, ...countParams)?.cnt ?? 0;
2646
+ }
2647
+
2648
+ return withCors(req, Response.json({ ok: true, messages: rows, ...(alias ? { pending_count: pendingCount ?? 0 } : {}) }));
2609
2649
  }
2610
2650
 
2611
2651
  // ── REST: stats summary ──
package/src/tools.ts CHANGED
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
4
4
  import { parseAliasFilter } from "./alias-filter.js";
5
5
  import { createHash } from "node:crypto";
6
6
  import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
7
- import { getSSEStats, hasSubscribers, pushEvent, pushNetworkObserverEvent } from "./push.js";
7
+ import { getSSEStats, hasSubscribers, pushEvent, pushNetworkObserverEvent, pushUserEvent } from "./push.js";
8
8
  import { assertNodeActive } from "./lifecycle-guard.js";
9
9
  import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
10
10
  import { canRestWriteNetwork, getUserNetworkIds, singleNetworkId } from "./network-scope.js";
@@ -2181,6 +2181,97 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2181
2181
  }
2182
2182
  );
2183
2183
 
2184
+ server.tool(
2185
+ "send_desktop_message",
2186
+ "Push a message to a user's active Desktop/web clients in one network. Targets user identity, not node alias.",
2187
+ {
2188
+ to_user_id: z.string().min(1).max(200).optional().describe("Target user_id. Mutually checked with to_username when both are supplied."),
2189
+ to_username: z.string().min(1).max(50).optional().describe("Target username. Mutually checked with to_user_id when both are supplied."),
2190
+ title: z.string().max(200).optional(),
2191
+ message: z.string().min(1).max(10000),
2192
+ severity: z.enum(["info", "success", "warning", "error"]).optional().default("info"),
2193
+ kind: z.string().min(1).max(80).optional().default("agent_message"),
2194
+ meta: z.record(z.unknown()).optional(),
2195
+ from_session: z.string().max(200).optional(),
2196
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens; ntok stays bound to its network)."),
2197
+ },
2198
+ async ({ to_user_id, to_username, title, message, severity, kind, meta, from_session: _fromIn, network_id: netId }) => {
2199
+ const fromMismatch = fromIdentityMismatchReply(_fromIn);
2200
+ if (fromMismatch) return fromMismatch;
2201
+ const from_session = defaultFrom(_fromIn);
2202
+ const effectiveNetId = getNetworkId(netId);
2203
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
2204
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
2205
+
2206
+ if (!to_user_id && !to_username) {
2207
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_required", message: "to_user_id or to_username is required" }) }] };
2208
+ }
2209
+
2210
+ const byId = to_user_id
2211
+ ? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE user_id = ?1", to_user_id)
2212
+ : null;
2213
+ const byName = to_username
2214
+ ? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE username = ?1", to_username)
2215
+ : null;
2216
+
2217
+ if (to_user_id && !byId) {
2218
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_user_id" }) }] };
2219
+ }
2220
+ if (to_username && !byName) {
2221
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_username" }) }] };
2222
+ }
2223
+ if (byId && byName && byId.user_id !== byName.user_id) {
2224
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_mismatch", to_user_id, to_username }) }] };
2225
+ }
2226
+
2227
+ const target = byId ?? byName;
2228
+ if (!target) {
2229
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found" }) }] };
2230
+ }
2231
+
2232
+ const targetRole = getUserNetworkRole(target.user_id, effectiveNetId);
2233
+ if (!targetRole) {
2234
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_in_network", network_id: effectiveNetId }) }] };
2235
+ }
2236
+
2237
+ const messageId = `dm_${uuidv4()}`;
2238
+ const event = {
2239
+ type: "desktop_message",
2240
+ message_id: messageId,
2241
+ kind,
2242
+ from: from_session,
2243
+ title: title || null,
2244
+ message,
2245
+ severity,
2246
+ created_at: new Date().toISOString(),
2247
+ ...(meta && Object.keys(meta).length ? { meta } : {}),
2248
+ };
2249
+
2250
+ db.run(
2251
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
2252
+ VALUES (?1, ?2, 'send_desktop_message', 'user', ?3, ?4, ?5)`,
2253
+ [
2254
+ enforceUserId || null,
2255
+ callerAlias || null,
2256
+ target.user_id,
2257
+ JSON.stringify({
2258
+ message_id: messageId,
2259
+ to_username: target.username,
2260
+ from: from_session,
2261
+ severity,
2262
+ kind,
2263
+ title: title || null,
2264
+ meta_keys: meta && typeof meta === "object" ? Object.keys(meta) : [],
2265
+ }),
2266
+ effectiveNetId,
2267
+ ],
2268
+ );
2269
+ pushUserEvent(effectiveNetId, target.user_id, event);
2270
+
2271
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, message_id: messageId, delivered_to_user_id: target.user_id, network_id: effectiveNetId }) }] };
2272
+ },
2273
+ );
2274
+
2184
2275
  server.tool(
2185
2276
  "broadcast",
2186
2277
  "Send a message to multiple sessions.",
@@ -2886,7 +2977,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2886
2977
  node_spec: z.object({
2887
2978
  name: z.string().min(1).max(64),
2888
2979
  runtime: z.string().min(1).max(64),
2889
- model: z.string().min(1).max(100),
2980
+ model: z.string().min(1).max(100).optional().nullable(),
2890
2981
  flags: z.record(z.unknown()).optional(),
2891
2982
  env_refs: z.array(z.string().max(64)).optional(),
2892
2983
  channels: z.array(z.unknown()).optional(),
@@ -3042,7 +3133,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3042
3133
  VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
3043
3134
  [
3044
3135
  requestId, daemon_node_id, node_spec.name, networkIdForChild,
3045
- node_spec.runtime, node_spec.model, JSON.stringify(node_spec.flags || {}),
3136
+ node_spec.runtime, node_spec.model ?? null, JSON.stringify(node_spec.flags || {}),
3046
3137
  JSON.stringify(envKeys), childTokenId, Date.now(), callerTokenId || "unknown",
3047
3138
  ],
3048
3139
  );
@@ -3062,7 +3153,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3062
3153
  daemon_node_id,
3063
3154
  child_name: node_spec.name,
3064
3155
  runtime: node_spec.runtime,
3065
- model: node_spec.model,
3156
+ model: node_spec.model ?? null,
3066
3157
  flag_keys: Object.keys(node_spec.flags || {}),
3067
3158
  env_keys: envKeys,
3068
3159
  },
@@ -3126,7 +3217,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3126
3217
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "env_blob_unavailable" }) }] };
3127
3218
  }
3128
3219
  // Hydrate spec from row.
3129
- const specRow = db.get<{ child_name: string; runtime: string; model: string; flags_json: string }>(
3220
+ const specRow = db.get<{ child_name: string; runtime: string; model: string | null; flags_json: string }>(
3130
3221
  `SELECT child_name, runtime, model, flags_json FROM node_create_requests WHERE request_id = ?1`,
3131
3222
  request_id,
3132
3223
  );