@sleep2agi/commhub-server 0.9.0-preview.32 → 0.9.0-preview.34
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 +65 -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/rest-explicit-columns-http.test.ts +1 -0
- package/src/rest-projections.ts +1 -1
- package/src/server.ts +43 -3
- package/src/task-consumption.test.ts +73 -0
- package/src/tools.ts +144 -14
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.34",
|
|
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
|
@@ -200,6 +200,8 @@ db.exec(`
|
|
|
200
200
|
started_at TEXT,
|
|
201
201
|
runtime_submitted_at TEXT,
|
|
202
202
|
consumed_at TEXT,
|
|
203
|
+
thread_id TEXT,
|
|
204
|
+
turn_id TEXT,
|
|
203
205
|
completed_at TEXT,
|
|
204
206
|
expires_at TEXT
|
|
205
207
|
);
|
|
@@ -543,7 +545,7 @@ db.exec(`
|
|
|
543
545
|
child_name TEXT NOT NULL,
|
|
544
546
|
network_id TEXT NOT NULL,
|
|
545
547
|
runtime TEXT NOT NULL,
|
|
546
|
-
model TEXT
|
|
548
|
+
model TEXT,
|
|
547
549
|
flags_json TEXT NOT NULL,
|
|
548
550
|
env_keys TEXT NOT NULL,
|
|
549
551
|
status TEXT NOT NULL,
|
|
@@ -560,6 +562,63 @@ db.exec(`
|
|
|
560
562
|
CREATE INDEX IF NOT EXISTS idx_ncr_network ON node_create_requests(network_id);
|
|
561
563
|
CREATE INDEX IF NOT EXISTS idx_ncr_child_name ON node_create_requests(child_name);
|
|
562
564
|
`);
|
|
565
|
+
function migrateNodeCreateRequestsModelNullable() {
|
|
566
|
+
if (db.dialect === "postgres") {
|
|
567
|
+
try { db.exec("ALTER TABLE node_create_requests ALTER COLUMN model DROP NOT NULL"); } catch {}
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
let needsRebuild = false;
|
|
572
|
+
try {
|
|
573
|
+
const createSql = db.get<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'node_create_requests'");
|
|
574
|
+
needsRebuild = !!createSql?.sql && /\bmodel\s+TEXT\s+NOT\s+NULL\b/i.test(createSql.sql);
|
|
575
|
+
} catch {}
|
|
576
|
+
if (!needsRebuild) return;
|
|
577
|
+
|
|
578
|
+
db.transaction(() => {
|
|
579
|
+
db.exec("DROP TABLE IF EXISTS node_create_requests_migrated");
|
|
580
|
+
db.exec(`
|
|
581
|
+
CREATE TABLE node_create_requests_migrated (
|
|
582
|
+
request_id TEXT PRIMARY KEY,
|
|
583
|
+
daemon_node_id TEXT NOT NULL,
|
|
584
|
+
child_name TEXT NOT NULL,
|
|
585
|
+
network_id TEXT NOT NULL,
|
|
586
|
+
runtime TEXT NOT NULL,
|
|
587
|
+
model TEXT,
|
|
588
|
+
flags_json TEXT NOT NULL,
|
|
589
|
+
env_keys TEXT NOT NULL,
|
|
590
|
+
status TEXT NOT NULL,
|
|
591
|
+
error TEXT,
|
|
592
|
+
child_token_id TEXT,
|
|
593
|
+
created_at INTEGER NOT NULL,
|
|
594
|
+
created_by_token TEXT NOT NULL,
|
|
595
|
+
delivered_at INTEGER,
|
|
596
|
+
acked_at INTEGER,
|
|
597
|
+
child_node_id TEXT
|
|
598
|
+
)
|
|
599
|
+
`);
|
|
600
|
+
db.exec(`
|
|
601
|
+
INSERT INTO node_create_requests_migrated (
|
|
602
|
+
request_id, daemon_node_id, child_name, network_id, runtime, model,
|
|
603
|
+
flags_json, env_keys, status, error, child_token_id, created_at,
|
|
604
|
+
created_by_token, delivered_at, acked_at, child_node_id
|
|
605
|
+
)
|
|
606
|
+
SELECT
|
|
607
|
+
request_id, daemon_node_id, child_name, network_id, runtime, model,
|
|
608
|
+
flags_json, env_keys, status, error, child_token_id, created_at,
|
|
609
|
+
created_by_token, delivered_at, acked_at, child_node_id
|
|
610
|
+
FROM node_create_requests
|
|
611
|
+
`);
|
|
612
|
+
db.exec("DROP TABLE node_create_requests");
|
|
613
|
+
db.exec("ALTER TABLE node_create_requests_migrated RENAME TO node_create_requests");
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
migrateNodeCreateRequestsModelNullable();
|
|
617
|
+
db.exec(`
|
|
618
|
+
CREATE INDEX IF NOT EXISTS idx_ncr_daemon_status ON node_create_requests(daemon_node_id, status);
|
|
619
|
+
CREATE INDEX IF NOT EXISTS idx_ncr_network ON node_create_requests(network_id);
|
|
620
|
+
CREATE INDEX IF NOT EXISTS idx_ncr_child_name ON node_create_requests(child_name);
|
|
621
|
+
`);
|
|
563
622
|
try {
|
|
564
623
|
// Single-flight per (daemon, child_name) — prevents racing dashboard
|
|
565
624
|
// create on same name. Mirrors RFC-024 uniq_ncu_node_inflight.
|
|
@@ -1100,6 +1159,11 @@ try { db.exec("ALTER TABLE tasks ADD COLUMN meta_json TEXT"); } catch {}
|
|
|
1100
1159
|
// started_at (legacy report_status/content matching). Old binaries ignore them.
|
|
1101
1160
|
try { db.exec("ALTER TABLE tasks ADD COLUMN runtime_submitted_at TEXT"); } catch {}
|
|
1102
1161
|
try { db.exec("ALTER TABLE tasks ADD COLUMN consumed_at TEXT"); } catch {}
|
|
1162
|
+
// Exact vendor-runtime boundary for SideThread-capable clients. These values
|
|
1163
|
+
// are written only by the token-bound target node after attributable runtime
|
|
1164
|
+
// evidence; the Hub never derives them from task text or caller input.
|
|
1165
|
+
try { db.exec("ALTER TABLE tasks ADD COLUMN thread_id TEXT"); } catch {}
|
|
1166
|
+
try { db.exec("ALTER TABLE tasks ADD COLUMN turn_id TEXT"); } catch {}
|
|
1103
1167
|
|
|
1104
1168
|
// #1181 durable outbound terminal cursor. A task's created_at cannot be used
|
|
1105
1169
|
// as a recovery watermark because an old task may become terminal after newer
|
|
@@ -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");
|
|
@@ -52,6 +52,7 @@ const GOLDEN_RESPONSE_KEYS = {
|
|
|
52
52
|
"status", "content", "result", "in_reply_to", "requires_response", "scope",
|
|
53
53
|
"created_at", "delivered_at", "started_at", "runtime_submitted_at", "consumed_at",
|
|
54
54
|
"completed_at", "expires_at", "network_id", "parent_task_id", "meta_json",
|
|
55
|
+
"thread_id", "turn_id",
|
|
55
56
|
],
|
|
56
57
|
audit: [
|
|
57
58
|
"id", "user_id", "username", "action", "target_type", "target_id", "detail",
|
package/src/rest-projections.ts
CHANGED
|
@@ -36,7 +36,7 @@ export const TASK_REST_COLUMNS = [
|
|
|
36
36
|
"task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
|
|
37
37
|
"status", "content", "result", "in_reply_to", "requires_response", "scope",
|
|
38
38
|
"created_at", "delivered_at", "started_at", "runtime_submitted_at", "consumed_at", "completed_at", "expires_at",
|
|
39
|
-
"network_id", "parent_task_id", "meta_json",
|
|
39
|
+
"network_id", "parent_task_id", "meta_json", "thread_id", "turn_id",
|
|
40
40
|
] as const;
|
|
41
41
|
|
|
42
42
|
export const COMPLETION_REST_COLUMNS = [
|
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 ──
|
|
@@ -92,6 +92,13 @@ function runtimeSubmittedAt(taskId: string): string | null {
|
|
|
92
92
|
)?.runtime_submitted_at ?? null;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
function runtimeContext(taskId: string): { thread_id: string | null; turn_id: string | null } {
|
|
96
|
+
return db.get<{ thread_id: string | null; turn_id: string | null }>(
|
|
97
|
+
"SELECT thread_id, turn_id FROM tasks WHERE task_id = ?1",
|
|
98
|
+
taskId,
|
|
99
|
+
)!;
|
|
100
|
+
}
|
|
101
|
+
|
|
95
102
|
beforeEach(() => { cleanup(); seed(); });
|
|
96
103
|
afterAll(cleanup);
|
|
97
104
|
|
|
@@ -218,6 +225,72 @@ describe("task consumed_at identity and lifecycle", () => {
|
|
|
218
225
|
expect(listedTask?.consumed_at).toBe(timestamp);
|
|
219
226
|
});
|
|
220
227
|
|
|
228
|
+
test("token-bound consumed evidence persists and exposes an exact BTW boundary", async () => {
|
|
229
|
+
const taskId = await createTask("runtime emitted exact Codex context");
|
|
230
|
+
const handler = toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_consumed;
|
|
231
|
+
const context = { task_id: taskId, thread_id: "thread_btw_exact", turn_id: "turn_btw_exact" };
|
|
232
|
+
|
|
233
|
+
const first = await call(handler, { task_ids: [taskId], task_contexts: [context] });
|
|
234
|
+
expect(first.ok).toBe(true);
|
|
235
|
+
expect(first.tasks[0]).toMatchObject(context);
|
|
236
|
+
expect(runtimeContext(taskId)).toEqual({
|
|
237
|
+
thread_id: context.thread_id,
|
|
238
|
+
turn_id: context.turn_id,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const listed = await call(toolsFor().list_tasks, { alias: NODE_A, limit: 20 });
|
|
242
|
+
expect(listed.tasks.find((task: any) => task.task_id === taskId)).toMatchObject(context);
|
|
243
|
+
|
|
244
|
+
const replay = await call(handler, { task_ids: [taskId], task_contexts: [context] });
|
|
245
|
+
expect(replay.ok).toBe(true);
|
|
246
|
+
const conflict = await call(handler, {
|
|
247
|
+
task_ids: [taskId],
|
|
248
|
+
task_contexts: [{ ...context, turn_id: "turn_sibling" }],
|
|
249
|
+
});
|
|
250
|
+
expect(conflict).toEqual({
|
|
251
|
+
ok: false,
|
|
252
|
+
error: "task_runtime_context_conflict",
|
|
253
|
+
task_id: taskId,
|
|
254
|
+
});
|
|
255
|
+
expect(runtimeContext(taskId)).toEqual({
|
|
256
|
+
thread_id: context.thread_id,
|
|
257
|
+
turn_id: context.turn_id,
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("runtime context rejects user tokens, foreign tasks, and unrequested ids without partial writes", async () => {
|
|
262
|
+
const ownTask = await createTask("owned context", NODE_A);
|
|
263
|
+
const foreignTask = await createTask("foreign context", NODE_B);
|
|
264
|
+
const ownContext = { task_id: ownTask, thread_id: "thread_owned", turn_id: "turn_owned" };
|
|
265
|
+
const foreignContext = { task_id: foreignTask, thread_id: "thread_foreign", turn_id: "turn_foreign" };
|
|
266
|
+
|
|
267
|
+
expect(await call(toolsFor().mark_tasks_consumed, {
|
|
268
|
+
task_ids: [ownTask], task_contexts: [ownContext],
|
|
269
|
+
})).toEqual({ ok: false, error: "node_token_required" });
|
|
270
|
+
expect(await call(toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_consumed, {
|
|
271
|
+
task_ids: [ownTask, foreignTask], task_contexts: [ownContext, foreignContext],
|
|
272
|
+
})).toEqual({ ok: false, error: "task_not_owned", task_id: foreignTask });
|
|
273
|
+
expect(await call(toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_consumed, {
|
|
274
|
+
task_ids: [ownTask], task_contexts: [foreignContext],
|
|
275
|
+
})).toEqual({ ok: false, error: "task_context_not_requested", task_id: foreignTask });
|
|
276
|
+
expect(runtimeContext(ownTask)).toEqual({ thread_id: null, turn_id: null });
|
|
277
|
+
expect(runtimeContext(foreignTask)).toEqual({ thread_id: null, turn_id: null });
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("submitted evidence cannot smuggle a runtime context", async () => {
|
|
281
|
+
const taskId = await createTask("submission has no authoritative turn yet");
|
|
282
|
+
const result = await call(
|
|
283
|
+
toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_runtime_submitted,
|
|
284
|
+
{
|
|
285
|
+
task_ids: [taskId],
|
|
286
|
+
task_contexts: [{ task_id: taskId, thread_id: "thread_early", turn_id: "turn_early" }],
|
|
287
|
+
},
|
|
288
|
+
);
|
|
289
|
+
expect(result).toEqual({ ok: false, error: "runtime_context_requires_consumed" });
|
|
290
|
+
expect(runtimeSubmittedAt(taskId)).toBeNull();
|
|
291
|
+
expect(runtimeContext(taskId)).toEqual({ thread_id: null, turn_id: null });
|
|
292
|
+
});
|
|
293
|
+
|
|
221
294
|
test("one runtime wake can mark an exact batch of owned tasks", async () => {
|
|
222
295
|
const firstTask = await createTask("batched wake first");
|
|
223
296
|
const secondTask = await createTask("batched wake second");
|
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";
|
|
@@ -991,6 +991,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
991
991
|
const markTaskRuntimeEvidence = (
|
|
992
992
|
taskIds: string[],
|
|
993
993
|
level: "submitted" | "consumed",
|
|
994
|
+
taskContexts: Array<{ task_id: string; thread_id: string; turn_id: string }> = [],
|
|
994
995
|
) => {
|
|
995
996
|
const task_ids = taskIds;
|
|
996
997
|
if (!callerTokenIsNetwork || !callerAlias || !enforceNetworkId) {
|
|
@@ -1017,6 +1018,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1017
1018
|
};
|
|
1018
1019
|
}
|
|
1019
1020
|
|
|
1021
|
+
if (level !== "consumed" && taskContexts.length > 0) {
|
|
1022
|
+
return {
|
|
1023
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "runtime_context_requires_consumed" }) }],
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
const contextByTaskId = new Map(taskContexts.map((context) => [context.task_id, context]));
|
|
1027
|
+
if (contextByTaskId.size !== taskContexts.length) {
|
|
1028
|
+
return {
|
|
1029
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_context" }) }],
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
const foreignContext = taskContexts.find((context) => !uniqueTaskIds.includes(context.task_id));
|
|
1033
|
+
if (foreignContext) {
|
|
1034
|
+
return {
|
|
1035
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task_context_not_requested", task_id: foreignContext.task_id }) }],
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1020
1039
|
const placeholders = uniqueTaskIds.map((_, i) => `?${i + 2}`).join(", ");
|
|
1021
1040
|
|
|
1022
1041
|
// Keep ownership preflight and every stamp in one SQLite transaction.
|
|
@@ -1031,8 +1050,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1031
1050
|
enforceNetworkId,
|
|
1032
1051
|
canonicalCaller,
|
|
1033
1052
|
);
|
|
1034
|
-
const rows = db.all<{ task_id: string; to_node_id: string | null; to_name: string }>(
|
|
1035
|
-
`SELECT task_id, to_node_id, to_name FROM tasks
|
|
1053
|
+
const rows = db.all<{ task_id: string; to_node_id: string | null; to_name: string; thread_id: string | null; turn_id: string | null }>(
|
|
1054
|
+
`SELECT task_id, to_node_id, to_name, thread_id, turn_id FROM tasks
|
|
1036
1055
|
WHERE network_id = ?1 AND task_id IN (${placeholders})`,
|
|
1037
1056
|
enforceNetworkId,
|
|
1038
1057
|
...uniqueTaskIds,
|
|
@@ -1050,6 +1069,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1050
1069
|
if (rejectedTaskId) {
|
|
1051
1070
|
return { ok: false as const, error: "task_not_owned", task_id: rejectedTaskId };
|
|
1052
1071
|
}
|
|
1072
|
+
const conflictingContext = uniqueTaskIds.find((taskId) => {
|
|
1073
|
+
const context = contextByTaskId.get(taskId);
|
|
1074
|
+
if (!context) return false;
|
|
1075
|
+
const row = owned.get(taskId)!;
|
|
1076
|
+
return (row.thread_id !== null && row.thread_id !== context.thread_id)
|
|
1077
|
+
|| (row.turn_id !== null && row.turn_id !== context.turn_id);
|
|
1078
|
+
});
|
|
1079
|
+
if (conflictingContext) {
|
|
1080
|
+
return { ok: false as const, error: "task_runtime_context_conflict", task_id: conflictingContext };
|
|
1081
|
+
}
|
|
1053
1082
|
|
|
1054
1083
|
for (const taskId of uniqueTaskIds) {
|
|
1055
1084
|
const row = owned.get(taskId)!;
|
|
@@ -1059,12 +1088,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1059
1088
|
const ownerValue = row.to_node_id ?? row.to_name;
|
|
1060
1089
|
let updateResult;
|
|
1061
1090
|
if (level === "consumed") {
|
|
1091
|
+
const context = contextByTaskId.get(taskId);
|
|
1062
1092
|
updateResult = db.run(
|
|
1063
1093
|
`UPDATE tasks SET
|
|
1064
1094
|
runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now')),
|
|
1065
|
-
consumed_at = COALESCE(consumed_at, datetime('now'))
|
|
1095
|
+
consumed_at = COALESCE(consumed_at, datetime('now')),
|
|
1096
|
+
thread_id = COALESCE(thread_id, ?4),
|
|
1097
|
+
turn_id = COALESCE(turn_id, ?5)
|
|
1066
1098
|
WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
|
|
1067
|
-
[enforceNetworkId, taskId, ownerValue],
|
|
1099
|
+
[enforceNetworkId, taskId, ownerValue, context?.thread_id ?? null, context?.turn_id ?? null],
|
|
1068
1100
|
);
|
|
1069
1101
|
} else {
|
|
1070
1102
|
updateResult = db.run(
|
|
@@ -1083,8 +1115,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1083
1115
|
task_id: string;
|
|
1084
1116
|
runtime_submitted_at: string;
|
|
1085
1117
|
consumed_at: string | null;
|
|
1118
|
+
thread_id: string | null;
|
|
1119
|
+
turn_id: string | null;
|
|
1086
1120
|
}>(
|
|
1087
|
-
`SELECT task_id, runtime_submitted_at, consumed_at FROM tasks
|
|
1121
|
+
`SELECT task_id, runtime_submitted_at, consumed_at, thread_id, turn_id FROM tasks
|
|
1088
1122
|
WHERE network_id = ?1 AND task_id IN (${placeholders})`,
|
|
1089
1123
|
enforceNetworkId,
|
|
1090
1124
|
...uniqueTaskIds,
|
|
@@ -1102,20 +1136,25 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1102
1136
|
|
|
1103
1137
|
const taskRuntimeEvidenceSchema = {
|
|
1104
1138
|
task_ids: z.array(z.string().min(1).max(200)).min(1).max(100),
|
|
1139
|
+
task_contexts: z.array(z.object({
|
|
1140
|
+
task_id: z.string().min(1).max(200),
|
|
1141
|
+
thread_id: z.string().min(1).max(500),
|
|
1142
|
+
turn_id: z.string().min(1).max(500),
|
|
1143
|
+
}).strict()).max(100).optional(),
|
|
1105
1144
|
};
|
|
1106
1145
|
|
|
1107
1146
|
server.tool(
|
|
1108
1147
|
"mark_tasks_runtime_submitted",
|
|
1109
1148
|
"Internal agent-node signal: exact task bodies were submitted to this token-bound node's vendor runtime.",
|
|
1110
1149
|
taskRuntimeEvidenceSchema,
|
|
1111
|
-
async ({ task_ids }) => markTaskRuntimeEvidence(task_ids, "submitted"),
|
|
1150
|
+
async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "submitted", task_contexts),
|
|
1112
1151
|
);
|
|
1113
1152
|
|
|
1114
1153
|
server.tool(
|
|
1115
1154
|
"mark_tasks_consumed",
|
|
1116
1155
|
"Internal agent-node signal: exact tasks produced attributable turn-start/activity evidence in this token-bound node's runtime.",
|
|
1117
1156
|
taskRuntimeEvidenceSchema,
|
|
1118
|
-
async ({ task_ids }) => markTaskRuntimeEvidence(task_ids, "consumed"),
|
|
1157
|
+
async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "consumed", task_contexts),
|
|
1119
1158
|
);
|
|
1120
1159
|
|
|
1121
1160
|
// ═══════════════════════════════════════════
|
|
@@ -2031,7 +2070,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2031
2070
|
const tasks = db.all(
|
|
2032
2071
|
`SELECT t.task_id, t.from_node_id, t.from_name, t.to_node_id, t.to_name, t.priority,
|
|
2033
2072
|
t.status, t.content, t.result, t.created_at, t.runtime_submitted_at,
|
|
2034
|
-
t.consumed_at, t.completed_at, e.terminal_seq
|
|
2073
|
+
t.consumed_at, t.thread_id, t.turn_id, t.completed_at, e.terminal_seq
|
|
2035
2074
|
FROM task_terminal_events e
|
|
2036
2075
|
JOIN tasks t ON t.task_id = e.task_id
|
|
2037
2076
|
WHERE COALESCE(e.network_id, 'default') = COALESCE(?1, 'default')
|
|
@@ -2048,7 +2087,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2048
2087
|
has_more: tasks.length === limit,
|
|
2049
2088
|
}) }] };
|
|
2050
2089
|
}
|
|
2051
|
-
let sql = "SELECT task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, result, created_at, runtime_submitted_at, consumed_at, completed_at FROM tasks WHERE 1=1";
|
|
2090
|
+
let sql = "SELECT task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, result, created_at, runtime_submitted_at, consumed_at, thread_id, turn_id, completed_at FROM tasks WHERE 1=1";
|
|
2052
2091
|
const params: any[] = [];
|
|
2053
2092
|
sql = addReadScope(sql, params, readScope);
|
|
2054
2093
|
if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
|
|
@@ -2181,6 +2220,97 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2181
2220
|
}
|
|
2182
2221
|
);
|
|
2183
2222
|
|
|
2223
|
+
server.tool(
|
|
2224
|
+
"send_desktop_message",
|
|
2225
|
+
"Push a message to a user's active Desktop/web clients in one network. Targets user identity, not node alias.",
|
|
2226
|
+
{
|
|
2227
|
+
to_user_id: z.string().min(1).max(200).optional().describe("Target user_id. Mutually checked with to_username when both are supplied."),
|
|
2228
|
+
to_username: z.string().min(1).max(50).optional().describe("Target username. Mutually checked with to_user_id when both are supplied."),
|
|
2229
|
+
title: z.string().max(200).optional(),
|
|
2230
|
+
message: z.string().min(1).max(10000),
|
|
2231
|
+
severity: z.enum(["info", "success", "warning", "error"]).optional().default("info"),
|
|
2232
|
+
kind: z.string().min(1).max(80).optional().default("agent_message"),
|
|
2233
|
+
meta: z.record(z.unknown()).optional(),
|
|
2234
|
+
from_session: z.string().max(200).optional(),
|
|
2235
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens; ntok stays bound to its network)."),
|
|
2236
|
+
},
|
|
2237
|
+
async ({ to_user_id, to_username, title, message, severity, kind, meta, from_session: _fromIn, network_id: netId }) => {
|
|
2238
|
+
const fromMismatch = fromIdentityMismatchReply(_fromIn);
|
|
2239
|
+
if (fromMismatch) return fromMismatch;
|
|
2240
|
+
const from_session = defaultFrom(_fromIn);
|
|
2241
|
+
const effectiveNetId = getNetworkId(netId);
|
|
2242
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
2243
|
+
if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
|
|
2244
|
+
|
|
2245
|
+
if (!to_user_id && !to_username) {
|
|
2246
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_required", message: "to_user_id or to_username is required" }) }] };
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
const byId = to_user_id
|
|
2250
|
+
? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE user_id = ?1", to_user_id)
|
|
2251
|
+
: null;
|
|
2252
|
+
const byName = to_username
|
|
2253
|
+
? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE username = ?1", to_username)
|
|
2254
|
+
: null;
|
|
2255
|
+
|
|
2256
|
+
if (to_user_id && !byId) {
|
|
2257
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_user_id" }) }] };
|
|
2258
|
+
}
|
|
2259
|
+
if (to_username && !byName) {
|
|
2260
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_username" }) }] };
|
|
2261
|
+
}
|
|
2262
|
+
if (byId && byName && byId.user_id !== byName.user_id) {
|
|
2263
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_mismatch", to_user_id, to_username }) }] };
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
const target = byId ?? byName;
|
|
2267
|
+
if (!target) {
|
|
2268
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found" }) }] };
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
const targetRole = getUserNetworkRole(target.user_id, effectiveNetId);
|
|
2272
|
+
if (!targetRole) {
|
|
2273
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_in_network", network_id: effectiveNetId }) }] };
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
const messageId = `dm_${uuidv4()}`;
|
|
2277
|
+
const event = {
|
|
2278
|
+
type: "desktop_message",
|
|
2279
|
+
message_id: messageId,
|
|
2280
|
+
kind,
|
|
2281
|
+
from: from_session,
|
|
2282
|
+
title: title || null,
|
|
2283
|
+
message,
|
|
2284
|
+
severity,
|
|
2285
|
+
created_at: new Date().toISOString(),
|
|
2286
|
+
...(meta && Object.keys(meta).length ? { meta } : {}),
|
|
2287
|
+
};
|
|
2288
|
+
|
|
2289
|
+
db.run(
|
|
2290
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
2291
|
+
VALUES (?1, ?2, 'send_desktop_message', 'user', ?3, ?4, ?5)`,
|
|
2292
|
+
[
|
|
2293
|
+
enforceUserId || null,
|
|
2294
|
+
callerAlias || null,
|
|
2295
|
+
target.user_id,
|
|
2296
|
+
JSON.stringify({
|
|
2297
|
+
message_id: messageId,
|
|
2298
|
+
to_username: target.username,
|
|
2299
|
+
from: from_session,
|
|
2300
|
+
severity,
|
|
2301
|
+
kind,
|
|
2302
|
+
title: title || null,
|
|
2303
|
+
meta_keys: meta && typeof meta === "object" ? Object.keys(meta) : [],
|
|
2304
|
+
}),
|
|
2305
|
+
effectiveNetId,
|
|
2306
|
+
],
|
|
2307
|
+
);
|
|
2308
|
+
pushUserEvent(effectiveNetId, target.user_id, event);
|
|
2309
|
+
|
|
2310
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, message_id: messageId, delivered_to_user_id: target.user_id, network_id: effectiveNetId }) }] };
|
|
2311
|
+
},
|
|
2312
|
+
);
|
|
2313
|
+
|
|
2184
2314
|
server.tool(
|
|
2185
2315
|
"broadcast",
|
|
2186
2316
|
"Send a message to multiple sessions.",
|
|
@@ -2886,7 +3016,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2886
3016
|
node_spec: z.object({
|
|
2887
3017
|
name: z.string().min(1).max(64),
|
|
2888
3018
|
runtime: z.string().min(1).max(64),
|
|
2889
|
-
model: z.string().min(1).max(100),
|
|
3019
|
+
model: z.string().min(1).max(100).optional().nullable(),
|
|
2890
3020
|
flags: z.record(z.unknown()).optional(),
|
|
2891
3021
|
env_refs: z.array(z.string().max(64)).optional(),
|
|
2892
3022
|
channels: z.array(z.unknown()).optional(),
|
|
@@ -3042,7 +3172,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3042
3172
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
|
|
3043
3173
|
[
|
|
3044
3174
|
requestId, daemon_node_id, node_spec.name, networkIdForChild,
|
|
3045
|
-
node_spec.runtime, node_spec.model, JSON.stringify(node_spec.flags || {}),
|
|
3175
|
+
node_spec.runtime, node_spec.model ?? null, JSON.stringify(node_spec.flags || {}),
|
|
3046
3176
|
JSON.stringify(envKeys), childTokenId, Date.now(), callerTokenId || "unknown",
|
|
3047
3177
|
],
|
|
3048
3178
|
);
|
|
@@ -3062,7 +3192,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3062
3192
|
daemon_node_id,
|
|
3063
3193
|
child_name: node_spec.name,
|
|
3064
3194
|
runtime: node_spec.runtime,
|
|
3065
|
-
model: node_spec.model,
|
|
3195
|
+
model: node_spec.model ?? null,
|
|
3066
3196
|
flag_keys: Object.keys(node_spec.flags || {}),
|
|
3067
3197
|
env_keys: envKeys,
|
|
3068
3198
|
},
|
|
@@ -3126,7 +3256,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3126
3256
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "env_blob_unavailable" }) }] };
|
|
3127
3257
|
}
|
|
3128
3258
|
// Hydrate spec from row.
|
|
3129
|
-
const specRow = db.get<{ child_name: string; runtime: string; model: string; flags_json: string }>(
|
|
3259
|
+
const specRow = db.get<{ child_name: string; runtime: string; model: string | null; flags_json: string }>(
|
|
3130
3260
|
`SELECT child_name, runtime, model, flags_json FROM node_create_requests WHERE request_id = ?1`,
|
|
3131
3261
|
request_id,
|
|
3132
3262
|
);
|