@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 +1 -1
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +10 -0
- package/src/create-node-validate.ts +5 -3
- package/src/db.ts +58 -1
- package/src/desktop-message.test.ts +222 -0
- package/src/observer-avatar-http.test.ts +75 -0
- package/src/observer-push.test.ts +76 -0
- package/src/push.ts +42 -0
- package/src/server.ts +43 -3
- package/src/tools.ts +96 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
);
|