@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.71
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/README.md +130 -10
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-delete-network-http.test.ts +177 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access.ts +461 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +167 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +423 -29
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter-translate.test.ts +112 -0
- package/src/db-adapter.ts +246 -83
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +799 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +149 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/push.ts +204 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +708 -0
- package/src/requirements-mcp.test.ts +142 -0
- package/src/requirements-migrate.ts +83 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +899 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +10 -1
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +625 -0
- package/src/scheduled-tasks.ts +597 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4385 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +92 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3446 -257
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// f015d9d6 症状①:tasks.status 被置为 'delivered',而 SSE 推送根本没发出。
|
|
2
|
+
//
|
|
3
|
+
// send_task 无条件以字面量 'delivered' 插入 tasks 行,推送却另外被
|
|
4
|
+
// `if (target.state === "online")` 挡着(tools.ts:1432)。同一段代码里给
|
|
5
|
+
// network observer 的事件把同一件事标成 "queued" —— 也就是说代码**知道**
|
|
6
|
+
// 它没投出去,却仍然把 delivered 写进了 tasks.status。
|
|
7
|
+
//
|
|
8
|
+
// 而 "online" 的判据是 resolveDeliveryTarget 里的
|
|
9
|
+
// stale = now - (last_seen_at || updated_at) > 5 分钟
|
|
10
|
+
// 且 last_seen_at 只在 report_status 时刷新(tools.ts:674/702)。
|
|
11
|
+
// 全网「停发心跳/状态类消息」的省额度规则之下,一个安静的 MCP 会话必然变 stale。
|
|
12
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
|
+
import { db } from "./db.js";
|
|
15
|
+
import { registerTools } from "./tools.js";
|
|
16
|
+
import { __resetSSEClientsForTest, createSSEStream } from "./push.js";
|
|
17
|
+
|
|
18
|
+
const NET = "net_delivered_push_f015";
|
|
19
|
+
const USER = "user_delivered_push_f015";
|
|
20
|
+
const SENDER = "sender-f015b";
|
|
21
|
+
const TARGET = "quiet-mcp-f015b";
|
|
22
|
+
|
|
23
|
+
type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
|
|
24
|
+
|
|
25
|
+
function cleanup() {
|
|
26
|
+
__resetSSEClientsForTest();
|
|
27
|
+
for (const t of ["tasks", "inbox", "task_events", "sessions", "api_tokens", "nodes", "rename_txn"]) {
|
|
28
|
+
try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
|
|
29
|
+
}
|
|
30
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
|
|
31
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
32
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** minutesAgo 控制目标会话的 last_seen_at —— 这是本文件唯一要动的变量。 */
|
|
36
|
+
function seed(minutesAgo: number) {
|
|
37
|
+
db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'admin', datetime('now'))", [USER]);
|
|
38
|
+
db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
|
|
39
|
+
db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
|
|
40
|
+
for (const alias of [SENDER, TARGET]) {
|
|
41
|
+
db.run(
|
|
42
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, 'network')",
|
|
43
|
+
[`token-${alias}`, `hash-${alias}`, USER, NET, `node:${alias}`],
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
db.run(
|
|
47
|
+
"INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?1, 'idle', NULL, ?2, datetime('now'), datetime('now', ?3))",
|
|
48
|
+
[SENDER, NET, "-1 minutes"],
|
|
49
|
+
);
|
|
50
|
+
// 🔴 目标:MCP 挂载的 claude-code 会话形状(node_id NULL),
|
|
51
|
+
// status 是 'idle'(不是 offline),只是安静了 minutesAgo 分钟。
|
|
52
|
+
db.run(
|
|
53
|
+
"INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', NULL, ?3, datetime('now', ?4), datetime('now', ?4))",
|
|
54
|
+
[`cc-n_${TARGET}`, TARGET, NET, `-${minutesAgo} minutes`],
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toolsFor(alias: string): Record<string, ToolHandler> {
|
|
59
|
+
const server = new McpServer({ name: "delivered-push-f015", version: "0" }) as any;
|
|
60
|
+
const tools: Record<string, ToolHandler> = {};
|
|
61
|
+
const original = server.tool.bind(server);
|
|
62
|
+
server.tool = (name: string, description: string, schema: any, handler: ToolHandler) => {
|
|
63
|
+
tools[name] = handler; return original(name, description, schema, handler);
|
|
64
|
+
};
|
|
65
|
+
registerTools(server, undefined, NET, USER, alias, true, `token-${alias}`);
|
|
66
|
+
return tools;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 从订阅者那一侧真正读出字节 —— 地面真相,不是连接计数。 */
|
|
70
|
+
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, needle: string, ms = 1500): Promise<string | null> {
|
|
71
|
+
const dec = new TextDecoder();
|
|
72
|
+
let buf = "";
|
|
73
|
+
const deadline = Date.now() + ms;
|
|
74
|
+
while (Date.now() < deadline) {
|
|
75
|
+
const chunk = await Promise.race([
|
|
76
|
+
reader.read(),
|
|
77
|
+
new Promise<{ done: true; value: undefined }>((r) => setTimeout(() => r({ done: true, value: undefined }), 200)),
|
|
78
|
+
]);
|
|
79
|
+
if (chunk.value) buf += dec.decode(chunk.value, { stream: true });
|
|
80
|
+
if (buf.includes(needle)) return buf;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let sendSeq = 0;
|
|
86
|
+
/** minutesAgo = 目标安静了多久;withSubscriber = 是否真的有人连着。 */
|
|
87
|
+
async function send(minutesAgo: number, withSubscriber = true) {
|
|
88
|
+
cleanup(); seed(minutesAgo);
|
|
89
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
90
|
+
if (withSubscriber) {
|
|
91
|
+
const res = createSSEStream(TARGET, NET);
|
|
92
|
+
reader = res.body!.getReader();
|
|
93
|
+
}
|
|
94
|
+
const r = await toolsFor(SENDER).send_task({ alias: TARGET, task: `ping-${++sendSeq}`, priority: "normal" });
|
|
95
|
+
const out = JSON.parse(r.content[0].text);
|
|
96
|
+
const row = db.get<{ status: string }>("SELECT status FROM tasks WHERE to_name = ?1 AND network_id = ?2", TARGET, NET);
|
|
97
|
+
const received = reader ? await readUntil(reader, "new_task") : null;
|
|
98
|
+
return { out, taskStatus: row?.status ?? null, received };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("f015d9d6 症状① — delivered 置位与实际推送不同步", () => {
|
|
102
|
+
beforeEach(() => cleanup());
|
|
103
|
+
afterAll(() => cleanup());
|
|
104
|
+
|
|
105
|
+
test("对照:目标 1 分钟前还活跃 ⇒ 订阅者收到 new_task", async () => {
|
|
106
|
+
const { taskStatus, received } = await send(1);
|
|
107
|
+
expect(taskStatus).toBe("delivered");
|
|
108
|
+
expect(received).toContain("new_task");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("🔴 核心:目标安静 10 分钟但订阅者连着 ⇒ 仍然收得到(修复前这里收到 null)", async () => {
|
|
112
|
+
const { taskStatus, received } = await send(10);
|
|
113
|
+
expect(received).toContain("new_task");
|
|
114
|
+
expect(taskStatus).toBe("delivered"); // 现在这个 delivered 是真话了
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("🔴 可达性判据必须来自订阅者注册表,不是 last_seen_at 的时间戳猜测", async () => {
|
|
118
|
+
// 安静 10 分钟 = 时间戳判据会说 offline;而实际上有人在听。
|
|
119
|
+
const quiet = await send(10);
|
|
120
|
+
expect(quiet.received).toContain("new_task");
|
|
121
|
+
// 真没人听的时候,pushEvent 本来就是 no-op,不需要额外的闸来保护。
|
|
122
|
+
const nobody = await send(10, false);
|
|
123
|
+
expect(nobody.received).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("真的没有订阅者时,tasks 行仍写 delivered —— 本 PR 刻意不改,记录在案", async () => {
|
|
127
|
+
const { taskStatus, received } = await send(10, false);
|
|
128
|
+
expect(received).toBeNull();
|
|
129
|
+
// 这一条钉的是**当前行为**,不是我认为对的行为:没人收到,行仍自称 delivered。
|
|
130
|
+
expect(taskStatus).toBe("delivered");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("活性判据的输入 last_seen_at 只由 report_status 刷新 —— 发送方不刷新它", async () => {
|
|
134
|
+
cleanup(); seed(4);
|
|
135
|
+
const before = db.get<{ last_seen_at: string }>("SELECT last_seen_at FROM sessions WHERE alias = ?1 AND network_id = ?2", TARGET, NET);
|
|
136
|
+
await toolsFor(SENDER).send_task({ alias: TARGET, task: `ping-${++sendSeq}`, priority: "normal" });
|
|
137
|
+
const after = db.get<{ last_seen_at: string }>("SELECT last_seen_at FROM sessions WHERE alias = ?1 AND network_id = ?2", TARGET, NET);
|
|
138
|
+
expect(after?.last_seen_at).toBe(before!.last_seen_at);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// #1459 —— P2 写路径与 P3 读路径的**接缝**。
|
|
2
|
+
//
|
|
3
|
+
// 通信龙 在 origin/main 上核出的覆盖缝:desktop-message.test.ts 测真实 send 但
|
|
4
|
+
// 不碰 scope=user 读;user-inbox-read-http.test.ts 测 scope=user 读但用 SQL 直接
|
|
5
|
+
// 播种行。两半各自对着自己的夹具绿,而"真实 send 写进的行、真实 scope=user
|
|
6
|
+
// 读得回来"从没验过。本文件就走这一条:真 send → 真 HTTP 读。
|
|
7
|
+
//
|
|
8
|
+
// 附带钉住 network_id 不为 NULL 的**真实机制**:不是 INSERT 时兜底,而是
|
|
9
|
+
// tools.ts 里 `if (!effectiveNetId) return writeDeniedReply(...)` 在写之前就拒。
|
|
10
|
+
// 见下面 "解析不出 network 时拒发" 那条 —— 把那个 if 拿掉,本文件会红。
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
+
|
|
17
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-dm-seam-"));
|
|
18
|
+
process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
|
|
19
|
+
|
|
20
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
21
|
+
let base = "";
|
|
22
|
+
let targetToken = "";
|
|
23
|
+
let senderId = "", targetId = "", senderNet = "", secondNet = "";
|
|
24
|
+
let send: (args: any) => Promise<Record<string, any>>;
|
|
25
|
+
|
|
26
|
+
function buildSend(opts: { netId: string | null; userId: string | null; alias: string | null }) {
|
|
27
|
+
const mcp = new McpServer({ name: "test", version: "0" }) as any;
|
|
28
|
+
const tools: Record<string, any> = {};
|
|
29
|
+
const origTool = mcp.tool.bind(mcp);
|
|
30
|
+
mcp.tool = (name: string, _d: string, _s: any, handler: any) => { tools[name] = handler; return origTool(name, _d, _s, handler); };
|
|
31
|
+
const { registerTools } = require("./tools.js");
|
|
32
|
+
registerTools(mcp, undefined, opts.netId, opts.userId, opts.alias, false, "tok_seam_test");
|
|
33
|
+
return async (args: any) => JSON.parse((await tools["send_desktop_message"](args)).content[0].text);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
const { db } = await import("./db.js");
|
|
38
|
+
const { register, addNetworkMember } = await import("./auth.js");
|
|
39
|
+
const stamp = Date.now();
|
|
40
|
+
|
|
41
|
+
// 首个注册用户自动成为全局 admin,而 admin 读侧不走 scope 过滤分支。
|
|
42
|
+
// 收件人必须是普通 user,否则"读得回来"可能只是因为它绕过了过滤。
|
|
43
|
+
expect(register(`seam_admin_${stamp}`, "SeamAdmin-Strong-1!").ok).toBe(true);
|
|
44
|
+
const s = register(`seam_sender_${stamp}`, "SeamSender-Strong-1!");
|
|
45
|
+
const t = register(`seam_target_${stamp}`, "SeamTarget-Strong-1!");
|
|
46
|
+
expect(s.ok && t.ok).toBe(true);
|
|
47
|
+
targetToken = t.token!;
|
|
48
|
+
senderNet = s.network_id!;
|
|
49
|
+
secondNet = t.network_id!;
|
|
50
|
+
senderId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `seam_sender_${stamp}`)!.user_id;
|
|
51
|
+
targetId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `seam_target_${stamp}`)!.user_id;
|
|
52
|
+
expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", targetId)?.role).toBe("user");
|
|
53
|
+
|
|
54
|
+
// 收件人进发件人的网(真实形态:同网两成员)。
|
|
55
|
+
expect(addNetworkMember(senderNet, targetId, "member").ok).toBe(true);
|
|
56
|
+
|
|
57
|
+
send = buildSend({ netId: senderNet, userId: senderId, alias: "seam-sender" });
|
|
58
|
+
|
|
59
|
+
const mod = await import("./server.js");
|
|
60
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
61
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterAll(() => {
|
|
65
|
+
try { server?.stop(true); } catch {}
|
|
66
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const readOwn = async (token: string, qs = "") => {
|
|
70
|
+
const r = await fetch(`${base}/api/messages?scope=user${qs}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
71
|
+
return { status: r.status, body: await r.json() as any };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
describe("#1459 P2 写 → P3 读 接缝", () => {
|
|
75
|
+
test("🔴 真实 send_desktop_message 写进的行,真实 GET ?scope=user 读得回来", async () => {
|
|
76
|
+
const sent = await send({ to_user_id: targetId, message: "接缝测试正文", title: "接缝", severity: "warning", kind: "agent_message" });
|
|
77
|
+
expect([sent.ok, sent.persisted]).toEqual([true, true]);
|
|
78
|
+
|
|
79
|
+
const { status, body } = await readOwn(targetToken);
|
|
80
|
+
expect(status).toBe(200);
|
|
81
|
+
const row = body.messages.find((m: any) => m.message_id === sent.message_id);
|
|
82
|
+
expect(row).toBeTruthy(); // 承重:两半接得上
|
|
83
|
+
expect(row.content).toBe("接缝测试正文");
|
|
84
|
+
expect(row.title).toBe("接缝");
|
|
85
|
+
expect(row.severity).toBe("warning");
|
|
86
|
+
expect(row.user_id).toBe(targetId);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("🔴 落库的 network_id 就是授权用的那个网,不是 NULL —— 否则 scoped 读回不来", async () => {
|
|
90
|
+
const { db } = require("./db.js");
|
|
91
|
+
const sent = await send({ to_user_id: targetId, message: "网络归属" });
|
|
92
|
+
const row = db.get<any>("SELECT network_id FROM user_inbox WHERE message_id = ?1", sent.message_id);
|
|
93
|
+
expect(row.network_id).toBe(senderNet);
|
|
94
|
+
|
|
95
|
+
const { body } = await readOwn(targetToken);
|
|
96
|
+
expect(body.messages.map((m: any) => m.message_id)).toContain(sent.message_id);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("unread 随真实 send 增长", async () => {
|
|
100
|
+
const before = (await readOwn(targetToken)).body.unread;
|
|
101
|
+
await send({ to_user_id: targetId, message: "计数 +1" });
|
|
102
|
+
expect((await readOwn(targetToken)).body.unread).toBe(before + 1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("redact-at-read 作用在真实写进去的正文上", async () => {
|
|
106
|
+
const sent = await send({ to_user_id: targetId, message: "凭据 ntok_seamtoken123456 在正文里" });
|
|
107
|
+
const { db } = require("./db.js");
|
|
108
|
+
// 存储侧保留原文,遮蔽发生在**回读**时 —— 这两句一起才说明是 read-time 脱敏。
|
|
109
|
+
expect(db.get<any>("SELECT content FROM user_inbox WHERE message_id = ?1", sent.message_id).content)
|
|
110
|
+
.toContain("ntok_seamtoken123456");
|
|
111
|
+
const row = (await readOwn(targetToken)).body.messages.find((m: any) => m.message_id === sent.message_id);
|
|
112
|
+
expect(row.content).toContain("ntok_***redacted***");
|
|
113
|
+
expect(row.content).not.toContain("ntok_seamtoken123456");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// 🔴 这一条是 `if (!effectiveNetId) return` 这道守卫**唯一**能被触发的场景。
|
|
117
|
+
// 有 auth ctx 时前一道 `canWrite` 就拦了;而 canRestWriteNetwork 对
|
|
118
|
+
// legacy 全局 token / --dev-open(authCtx 为 null)是 `return true`
|
|
119
|
+
// (network-scope.ts:96)—— 那条路上只剩这道守卫挡着 NULL 孤儿行。
|
|
120
|
+
// 把 tools.ts:2320 那行删掉,本条会红(写出一行 network_id=NULL)。
|
|
121
|
+
test("🔴 legacy 无 auth ctx 的调用者同样拒发,不写 NULL 孤儿行", async () => {
|
|
122
|
+
const { db } = require("./db.js");
|
|
123
|
+
const legacy = buildSend({ netId: null, userId: null as any, alias: null as any });
|
|
124
|
+
const before = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox")!.n;
|
|
125
|
+
const r = await legacy({ to_user_id: targetId, message: "legacy 无网络上下文" });
|
|
126
|
+
expect(r.ok).toBe(false);
|
|
127
|
+
expect(r.error).toBe("network_id_required");
|
|
128
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox")!.n).toBe(before);
|
|
129
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE network_id IS NULL")!.n).toBe(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("🔴 解析不出 network 时**拒发**,而不是写一行 network_id=NULL 的孤儿", async () => {
|
|
133
|
+
const { db } = require("./db.js");
|
|
134
|
+
const { addNetworkMember } = require("./auth.js");
|
|
135
|
+
// 发件人进第二个网 ⇒ 不带 network_id 时 singleNetworkId 解析不出唯一网。
|
|
136
|
+
addNetworkMember(secondNet, senderId, "member");
|
|
137
|
+
const ambiguous = buildSend({ netId: null, userId: senderId, alias: "seam-sender" });
|
|
138
|
+
|
|
139
|
+
const before = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox")!.n;
|
|
140
|
+
const r = await ambiguous({ to_user_id: targetId, message: "无法解析网络" });
|
|
141
|
+
|
|
142
|
+
expect(r.ok).toBe(false);
|
|
143
|
+
expect(r.error).toBe("network_id_required");
|
|
144
|
+
// 承重:拒发意味着**一行都不写**。若把 tools.ts 的
|
|
145
|
+
// `if (!effectiveNetId) return writeDeniedReply(...)` 拿掉,这里会多出一行
|
|
146
|
+
// network_id=NULL 的孤儿 —— 写得进去、scoped 读不回来。
|
|
147
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox")!.n).toBe(before);
|
|
148
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE network_id IS NULL")!.n).toBe(0);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,368 @@
|
|
|
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
|
+
});
|
|
223
|
+
|
|
224
|
+
// #1459 —— 返回值必须反映真实投递态。
|
|
225
|
+
//
|
|
226
|
+
// send_desktop_message 是 fire-and-forget:只 INSERT audit_log(不写 inbox),
|
|
227
|
+
// 再 pushUserEvent 扇出;而 pushUserEvent 在没有订阅者时静默 return。
|
|
228
|
+
// 用户 dashboard 离线/重连时消息永久丢(无 DB 行 ⇒ 无从 reconcile,
|
|
229
|
+
// /api/messages 只 SELECT ... FROM inbox),而调用方拿到的是 ok:true。
|
|
230
|
+
//
|
|
231
|
+
// 本 PR 只做「让丢失可见」这一半:不碰 schema,返回值说实话。
|
|
232
|
+
// 持久化 + 重连补投(让丢失不发生)是另一条,方向已定为按 user_id 寻址的新表。
|
|
233
|
+
describe("#1459 send_desktop_message 返回值反映真实投递态", () => {
|
|
234
|
+
test("🔴 无活跃订阅者 ⇒ delivered:false + 可判读的 reason,而不是笼统 ok:true", async () => {
|
|
235
|
+
// 故意不建 SSE 流:这正是用户 dashboard 关着的情形
|
|
236
|
+
const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
|
|
237
|
+
const before = auditCount();
|
|
238
|
+
const result = await call(tools.send_desktop_message, {
|
|
239
|
+
to_user_id: TARGET_A,
|
|
240
|
+
message: "nobody is listening",
|
|
241
|
+
severity: "info",
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ok 仍为 true:调用被接受、审计已落库 —— 这一格没变
|
|
245
|
+
expect(result.ok).toBe(true);
|
|
246
|
+
expect(result.message_id).toStartWith("dm_");
|
|
247
|
+
expect(auditCount()).toBe(before + 1);
|
|
248
|
+
|
|
249
|
+
// 新增的那一格:投递态
|
|
250
|
+
expect(result.delivered).toBe(false);
|
|
251
|
+
expect(result.reason).toBe("no_live_subscriber");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("有活跃订阅者 ⇒ delivered:true 且不带 reason", async () => {
|
|
255
|
+
const stream = createUserEventStream(NET_A, TARGET_A);
|
|
256
|
+
const reader = stream.body!.getReader();
|
|
257
|
+
await readFrame(reader);
|
|
258
|
+
|
|
259
|
+
const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
|
|
260
|
+
const result = await call(tools.send_desktop_message, {
|
|
261
|
+
to_user_id: TARGET_A,
|
|
262
|
+
message: "someone is listening",
|
|
263
|
+
severity: "info",
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(result.delivered).toBe(true);
|
|
267
|
+
expect(result.reason).toBeUndefined();
|
|
268
|
+
// 前提断言:这一条真的送到了订阅者手上,否则 delivered:true 就是空话
|
|
269
|
+
const evt = await readFrame(reader);
|
|
270
|
+
expect(evt.message).toBe("someone is listening");
|
|
271
|
+
await reader.cancel();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("🔴 两种情形必须给出不同读数 —— 防止判据退化成恒真", async () => {
|
|
275
|
+
const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
|
|
276
|
+
const offline = await call(tools.send_desktop_message, { to_user_id: TARGET_A, message: "off", severity: "info" });
|
|
277
|
+
|
|
278
|
+
const stream = createUserEventStream(NET_A, TARGET_A);
|
|
279
|
+
const reader = stream.body!.getReader();
|
|
280
|
+
await readFrame(reader);
|
|
281
|
+
const online = await call(tools.send_desktop_message, { to_user_id: TARGET_A, message: "on", severity: "info" });
|
|
282
|
+
await reader.cancel();
|
|
283
|
+
|
|
284
|
+
expect(offline.delivered).not.toBe(online.delivered);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// #1459 ① P2 —— 写入持久化。
|
|
289
|
+
describe("#1459 ① P2 send_desktop_message 持久化", () => {
|
|
290
|
+
const rowsFor = (mid: string) =>
|
|
291
|
+
db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE message_id = ?1", mid)?.n ?? 0;
|
|
292
|
+
|
|
293
|
+
test("🔴 发一条消息 ⇒ user_inbox 落一行,字段与事件一致", async () => {
|
|
294
|
+
const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
|
|
295
|
+
const r = await call(tools.send_desktop_message, {
|
|
296
|
+
to_user_id: TARGET_A, message: "persist me", severity: "warning", title: "T", kind: "agent_message",
|
|
297
|
+
});
|
|
298
|
+
expect(r.ok).toBe(true);
|
|
299
|
+
expect(r.persisted).toBe(true);
|
|
300
|
+
const row = db.get<any>("SELECT * FROM user_inbox WHERE message_id = ?1", r.message_id);
|
|
301
|
+
expect(row).toBeTruthy();
|
|
302
|
+
expect(row.user_id).toBe(TARGET_A);
|
|
303
|
+
expect(row.content).toBe("persist me");
|
|
304
|
+
expect(row.severity).toBe("warning");
|
|
305
|
+
expect(row.title).toBe("T");
|
|
306
|
+
expect(row.from_session).toBe("node-a");
|
|
307
|
+
expect(row.network_id).toBe(NET_A);
|
|
308
|
+
expect(row.acked).toBe(0);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("🔴 没有活订阅者时也要落库 —— 「丢了」变成「待补投」的那一格", async () => {
|
|
312
|
+
const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
|
|
313
|
+
const r = await call(tools.send_desktop_message, { to_user_id: TARGET_A, message: "offline body" });
|
|
314
|
+
expect(r.delivered).toBe(false);
|
|
315
|
+
expect(r.reason).toBe("no_live_subscriber");
|
|
316
|
+
expect(r.persisted).toBe(true);
|
|
317
|
+
expect(rowsFor(r.message_id)).toBe(1);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("🔴 zod 默认值不可依赖:直接调 handler 不传 kind/severity 也必须落库", async () => {
|
|
321
|
+
// 测试与未来的内部调用方绕过 MCP 的 zod .default(),拿到的是 undefined。
|
|
322
|
+
// 列上虽有 DEFAULT,但显式传 NULL 会覆盖它 —— 所以写入侧要自己兜。
|
|
323
|
+
const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
|
|
324
|
+
const r = await call(tools.send_desktop_message, { to_user_id: TARGET_A, message: "no kind given" });
|
|
325
|
+
expect(rowsFor(r.message_id)).toBe(1);
|
|
326
|
+
const row = db.get<any>("SELECT kind, severity FROM user_inbox WHERE message_id = ?1", r.message_id);
|
|
327
|
+
expect(row.kind).toBe("agent_message");
|
|
328
|
+
expect(row.severity).toBe("info");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("audit_log 与 user_inbox 同生共死(同一事务,条数一致)", async () => {
|
|
332
|
+
const before = auditCount();
|
|
333
|
+
const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
|
|
334
|
+
const r = await call(tools.send_desktop_message, { to_user_id: TARGET_A, message: "paired" });
|
|
335
|
+
expect(auditCount()).toBe(before + 1);
|
|
336
|
+
expect(rowsFor(r.message_id)).toBe(1);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// #1459 ① P2 —— 幂等写法的选择本身要被钉住。
|
|
341
|
+
//
|
|
342
|
+
// 🔴 实现时踩到的:最初用 `INSERT OR IGNORE`,它对「重投同一 message_id」是对的,
|
|
343
|
+
// 但**连 NOT NULL 违规也一起吞掉** —— kind 为 undefined 时整行被静默丢弃,
|
|
344
|
+
// 而工具照样返回 persisted:true。**一条消息凭空消失且报告成功**,正是本
|
|
345
|
+
// issue 要消灭的那个形状。改成 ON CONFLICT(message_id) DO NOTHING:
|
|
346
|
+
// 只赦免主键冲突,别的约束照常抛。
|
|
347
|
+
describe("#1459 ① P2 幂等写法:赦免重复,但不吞约束错误", () => {
|
|
348
|
+
const stmt = `INSERT INTO user_inbox
|
|
349
|
+
(message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json)
|
|
350
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
351
|
+
ON CONFLICT(message_id) DO NOTHING`;
|
|
352
|
+
|
|
353
|
+
test("重复的 message_id ⇒ 不抛错、也不产生第二行", () => {
|
|
354
|
+
const mid = `dm_dup_${Date.now()}`;
|
|
355
|
+
const ins = () => db.run(stmt, [mid, NET_A, TARGET_A, "node-a", "info", null, "body", "info", null]);
|
|
356
|
+
ins();
|
|
357
|
+
expect(() => ins()).not.toThrow();
|
|
358
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE message_id = ?1", mid)?.n).toBe(1);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("🔴 NOT NULL 违规必须抛出来 —— 不能像 OR IGNORE 那样静默丢行", () => {
|
|
362
|
+
const mid = `dm_bad_${Date.now()}`;
|
|
363
|
+
expect(() =>
|
|
364
|
+
db.run(stmt, [mid, NET_A, TARGET_A, "node-a", null, null, "body", "info", null]),
|
|
365
|
+
).toThrow();
|
|
366
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE message_id = ?1", mid)?.n).toBe(0);
|
|
367
|
+
});
|
|
368
|
+
});
|