@sleep2agi/commhub-server 0.9.0-preview.6 → 0.9.0-preview.60

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.
Files changed (140) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-networks-http.test.ts +88 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  10. package/src/api-host-supervisors-fallback.test.ts +271 -0
  11. package/src/api-nodes-shape.test.ts +26 -10
  12. package/src/auth-validate.test.ts +5 -1
  13. package/src/auth.ts +93 -14
  14. package/src/avatar-validate.test.ts +163 -0
  15. package/src/avatar-validate.ts +103 -0
  16. package/src/blocked-exit-on-reply.test.ts +122 -0
  17. package/src/boot-side-effect.test.ts +124 -0
  18. package/src/config-apply-sec1.test.ts +107 -0
  19. package/src/config-apply-validate.test.ts +92 -0
  20. package/src/config-apply-validate.ts +81 -3
  21. package/src/create-node-blocked-gate.test.ts +356 -0
  22. package/src/create-node-tool-schema.test.ts +39 -0
  23. package/src/create-node-validate.test.ts +89 -1
  24. package/src/create-node-validate.ts +55 -7
  25. package/src/create-node.ts +51 -1
  26. package/src/dashboard-slash-routing-http.test.ts +107 -0
  27. package/src/db-adapter-guard.test.ts +85 -0
  28. package/src/db-adapter.ts +64 -23
  29. package/src/db-timestamp.test.ts +38 -0
  30. package/src/db-timestamp.ts +15 -0
  31. package/src/db.ts +710 -33
  32. package/src/delivered-without-push.test.ts +140 -0
  33. package/src/desktop-message-seam.test.ts +150 -0
  34. package/src/desktop-message.test.ts +368 -0
  35. package/src/ext-token-shared.test.ts +60 -0
  36. package/src/ext-validation-regression.test.ts +131 -0
  37. package/src/external-schedule-edits-http.test.ts +320 -0
  38. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  39. package/src/external-schedule-edits.ts +327 -0
  40. package/src/file-download-authz-dev-open.test.ts +135 -0
  41. package/src/file-download-authz.test.ts +403 -0
  42. package/src/file-download-date-bucket.test.ts +436 -0
  43. package/src/file-network-scope-dev-open.test.ts +97 -0
  44. package/src/file-network-scope.test.ts +731 -0
  45. package/src/health-redaction.test.ts +641 -0
  46. package/src/http-gzip.test.ts +73 -0
  47. package/src/http-gzip.ts +60 -0
  48. package/src/hub-timestamp.test.ts +38 -0
  49. package/src/hub-timestamp.ts +27 -0
  50. package/src/inbox-count-sse.test.ts +310 -0
  51. package/src/inbox-count.ts +29 -0
  52. package/src/index.ts +15 -2227
  53. package/src/lifecycle-guard.ts +50 -0
  54. package/src/list-host-supervisors.test.ts +314 -0
  55. package/src/mcp-write-network-resolution.test.ts +429 -0
  56. package/src/messages-ack-by-agent-http.test.ts +142 -0
  57. package/src/network-name-http.test.ts +75 -0
  58. package/src/network-scope.ts +101 -0
  59. package/src/node-attrs-validate.ts +88 -0
  60. package/src/node-attrs.test.ts +217 -0
  61. package/src/node-create-request-status-http.test.ts +92 -0
  62. package/src/node-files-transport.test.ts +344 -0
  63. package/src/node-id-alias.test.ts +188 -0
  64. package/src/node-lifecycle-controllable.test.ts +64 -0
  65. package/src/node-lifecycle-controllable.ts +51 -0
  66. package/src/node-request-retention.test.ts +281 -0
  67. package/src/node-request-retention.ts +104 -0
  68. package/src/node-skills-transport.test.ts +209 -0
  69. package/src/observer-avatar-http.test.ts +428 -0
  70. package/src/observer-push.test.ts +252 -0
  71. package/src/package-version-consistency.test.ts +25 -0
  72. package/src/peer-reply-atomic.test.ts +455 -0
  73. package/src/peer-reply-capability.test.ts +70 -0
  74. package/src/push.ts +163 -7
  75. package/src/redact-tokens.test.ts +65 -0
  76. package/src/redact-tokens.ts +40 -0
  77. package/src/reply-target-derivation.test.ts +96 -0
  78. package/src/report-status-host-schema.test.ts +211 -0
  79. package/src/report-status-resume-id-handover.test.ts +150 -0
  80. package/src/require-explicit-test-db.ts +9 -0
  81. package/src/resolve-port.test.ts +57 -0
  82. package/src/resolve-port.ts +44 -0
  83. package/src/response-charset.test.ts +130 -0
  84. package/src/rest-explicit-columns-http.test.ts +216 -0
  85. package/src/rest-identity.test.ts +129 -0
  86. package/src/rest-identity.ts +100 -0
  87. package/src/rest-projections.ts +67 -0
  88. package/src/rest-write-network-resolution.test.ts +133 -0
  89. package/src/retention.ts +10 -1
  90. package/src/rules-file-duplicate-alias.test.ts +234 -0
  91. package/src/rules-file-session-target.test.ts +207 -0
  92. package/src/rules-file-transport.test.ts +237 -0
  93. package/src/runtime-label.test.ts +22 -0
  94. package/src/runtime-label.ts +21 -0
  95. package/src/scheduled-run-terminal.test.ts +268 -0
  96. package/src/scheduled-tasks-http.test.ts +483 -0
  97. package/src/scheduled-tasks.ts +549 -0
  98. package/src/send-reply-agent-warning.test.ts +202 -0
  99. package/src/send-reply-alias-optional.test.ts +131 -0
  100. package/src/send-reply-attachments.test.ts +458 -0
  101. package/src/serve-error.test.ts +38 -0
  102. package/src/serve-error.ts +27 -0
  103. package/src/server.ts +3842 -0
  104. package/src/shared/external-schedule-contract.ts +60 -0
  105. package/src/side-thread-command-transport.test.ts +302 -0
  106. package/src/side-thread-command-transport.ts +318 -0
  107. package/src/side-thread-http-integration.test.ts +233 -0
  108. package/src/side-thread-http.ts +504 -0
  109. package/src/side-thread-production.test.ts +72 -0
  110. package/src/side-thread-production.ts +118 -0
  111. package/src/side-thread.test.ts +1067 -0
  112. package/src/side-thread.ts +2495 -0
  113. package/src/skillhub-http.test.ts +92 -0
  114. package/src/start-node.test.ts +109 -0
  115. package/src/stop-delete-node.test.ts +952 -0
  116. package/src/task-auth-origin-http.test.ts +91 -0
  117. package/src/task-auth-origin.test.ts +24 -0
  118. package/src/task-auth-origin.ts +7 -0
  119. package/src/task-consumption.test.ts +482 -0
  120. package/src/task-diagnostic-http.test.ts +149 -0
  121. package/src/task-diagnostic.test.ts +115 -0
  122. package/src/task-diagnostic.ts +118 -0
  123. package/src/task-idempotency-mcp.test.ts +142 -0
  124. package/src/task-idempotency.test.ts +60 -0
  125. package/src/task-idempotency.ts +75 -0
  126. package/src/task-lifecycle-watcher.test.ts +204 -0
  127. package/src/task-lifecycle-watcher.ts +49 -0
  128. package/src/task-network-resolution-http.test.ts +141 -0
  129. package/src/tasks-pagination-http.test.ts +141 -0
  130. package/src/tools-list-json-schema.test.ts +66 -0
  131. package/src/tools.ts +3231 -204
  132. package/src/update-provider.test.ts +65 -0
  133. package/src/upload-early-reject-guard.test.ts +155 -0
  134. package/src/uploads-http.test.ts +97 -15
  135. package/src/uploads.test.ts +66 -0
  136. package/src/uploads.ts +130 -5
  137. package/src/user-inbox-by-agent-http.test.ts +161 -0
  138. package/src/user-inbox-netid-notnull.test.ts +122 -0
  139. package/src/user-inbox-read-http.test.ts +223 -0
  140. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,237 @@
1
+ // app#225 — 节点规则文件远程读写:走真 MCP in-process transport 的端到端。
2
+ //
3
+ // 验的是 #225 验收里 hub 能验的那几条:
4
+ // 1. 客户端只用 node_id 就能发起读/写;工具的 inputSchema 里**没有**任何
5
+ // 路径/文件名字段(验收第 5 条「不能借路径参数写任意文件」在 hub 侧的形状)。
6
+ // 2. 节点用网络 token + alias 拉到的请求里也没有路径字段;ack 后客户端轮询拿到内容。
7
+ // 3. SEC-1:跨网络节点发不了、别的节点 ack 不了、用户 token 拉不了。
8
+ // 4. 单飞 + 超时回收。
9
+ //
10
+ // 跑法:cd server && COMMHUB_DB=/tmp/rules.db bun test src/rules-file-transport.test.ts
11
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
12
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
13
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
14
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import { db } from "./db.js";
16
+ import { registerTools } from "./tools.js";
17
+
18
+ const NET = "net_rules_file";
19
+ const OTHER_NET = "net_rules_file_other";
20
+ const USER = "u_rules_file_owner";
21
+ const NODE_ID = "node_rules_file_claude";
22
+ const NODE_ALIAS = "rules-file-claude";
23
+ const OTHER_NODE_ID = "node_rules_file_other";
24
+ const OTHER_NODE_ALIAS = "rules-file-other";
25
+ const NODE_TOKEN = "tok_rules_file_node";
26
+ const OTHER_NODE_TOKEN = "tok_rules_file_other_node";
27
+
28
+ function cleanup() {
29
+ for (const net of [NET, OTHER_NET]) {
30
+ try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [net]); } catch {}
31
+ try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [net]); } catch {}
32
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [net]); } catch {}
33
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [net]); } catch {}
34
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [net]); } catch {}
35
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [net]); } catch {}
36
+ }
37
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
38
+ }
39
+
40
+ function seedWorld() {
41
+ db.run(
42
+ `INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
43
+ [USER, USER],
44
+ );
45
+ for (const net of [NET, OTHER_NET]) {
46
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [net, net, USER]);
47
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER, net]);
48
+ }
49
+ db.run(
50
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
51
+ [NODE_ID, NODE_ALIAS, NODE_ALIAS, NET],
52
+ );
53
+ db.run(
54
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
55
+ [OTHER_NODE_ID, OTHER_NODE_ALIAS, OTHER_NODE_ALIAS, OTHER_NET],
56
+ );
57
+ db.run(
58
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
59
+ [NODE_TOKEN, USER, NET, `node:${NODE_ALIAS}`, "hash_rules_file_node"],
60
+ );
61
+ db.run(
62
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
63
+ [OTHER_NODE_TOKEN, USER, OTHER_NET, `node:${OTHER_NODE_ALIAS}`, "hash_rules_file_other"],
64
+ );
65
+ }
66
+
67
+ type Identity = { net: string; alias: string; isNetworkToken: boolean; tokenId: string };
68
+ const asUser: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_rules_file_user" };
69
+ const asNode: Identity = { net: NET, alias: NODE_ALIAS, isNetworkToken: true, tokenId: NODE_TOKEN };
70
+ const asOtherNode: Identity = { net: OTHER_NET, alias: OTHER_NODE_ALIAS, isNetworkToken: true, tokenId: OTHER_NODE_TOKEN };
71
+
72
+ async function connect(id: Identity) {
73
+ const server = new McpServer({ name: "rules-file-test", version: "1" });
74
+ registerTools(server, undefined, id.net, USER, id.alias, id.isNetworkToken, id.tokenId);
75
+ const client = new Client({ name: "rules-file-client", version: "1" });
76
+ const [ct, st] = InMemoryTransport.createLinkedPair();
77
+ await server.connect(st);
78
+ await client.connect(ct);
79
+ const call = async (name: string, args: Record<string, unknown>) => {
80
+ const r: any = await client.callTool({ name, arguments: args });
81
+ const first = r.content?.[0];
82
+ if (!first || first.type !== "text") throw new Error(`no text result from ${name}`);
83
+ return JSON.parse(first.text);
84
+ };
85
+ const close = async () => { await client.close(); await server.close(); };
86
+ return { client, server, call, close };
87
+ }
88
+
89
+ beforeEach(() => { cleanup(); seedWorld(); });
90
+ afterAll(cleanup);
91
+
92
+ describe("app#225 rules file — schema has no path", () => {
93
+ test("read/write tool inputs are node id + content only; no path / file_name / dir field anywhere", async () => {
94
+ // 🔴 不走 client.listTools():main 上 report_status / send_desktop_message /
95
+ // update_node_config / create_node 四个存量工具的 zod 形状让 SDK 的
96
+ // toJSONSchema 抛 `schema._zod`,listTools 整个失败(与本 PR 无关,实验见
97
+ // PR 正文)。这里直接看 McpServer 登记表里的 zod shape —— 它就是 hub 校验
98
+ // 入参用的那份,比 JSON schema 更接近真相。
99
+ const u = await connect(asUser);
100
+ try {
101
+ const reg: Record<string, any> = (u.server as any)._registeredTools;
102
+ const shapeKeys = (name: string): string[] => {
103
+ expect(reg[name], name).toBeDefined();
104
+ const schema = reg[name].inputSchema;
105
+ const shape = schema?.shape ?? schema?._def?.shape?.() ?? schema ?? {};
106
+ return Object.keys(shape).sort();
107
+ };
108
+ // `alias` (app#225 follow-up) names a TARGET node/session, not a file —
109
+ // the no-path regex below still covers it.
110
+ expect(shapeKeys("read_node_rules_file")).toEqual(["alias", "child_node_id", "network_id", "node_id"]);
111
+ expect(shapeKeys("write_node_rules_file")).toEqual(["alias", "child_node_id", "content", "network_id", "node_id"]);
112
+ expect(shapeKeys("get_rules_file_request")).toEqual([]);
113
+ // ack 的 file_name 是节点**回报**它用了哪个文件,不是客户端**指定**用哪个文件。
114
+ expect(shapeKeys("ack_rules_file_request")).toEqual(["content", "error", "exists", "file_name", "request_id", "status"]);
115
+ expect(shapeKeys("get_rules_file_result")).toEqual(["network_id", "request_id"]);
116
+ for (const name of ["read_node_rules_file", "write_node_rules_file", "get_rules_file_request", "get_rules_file_result"]) {
117
+ for (const k of shapeKeys(name)) expect(k, `${name}.${k}`).not.toMatch(/path|dir|file|cwd/i);
118
+ }
119
+ } finally { await u.close(); }
120
+ }, 20_000);
121
+ });
122
+
123
+ describe("app#225 rules file — read round trip", () => {
124
+ test("user enqueues read → node pulls (no path in request) → acks content → user polls done", async () => {
125
+ const u = await connect(asUser);
126
+ const n = await connect(asNode);
127
+ try {
128
+ const enq = await u.call("read_node_rules_file", { node_id: NODE_ID });
129
+ expect(enq).toMatchObject({ ok: true, op: "read" });
130
+ expect(enq.request_id).toMatch(/^rf_/);
131
+
132
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: true, status: "pending", op: "read" });
133
+
134
+ const pulled = await n.call("get_rules_file_request", {});
135
+ expect(pulled.ok).toBe(true);
136
+ expect(Object.keys(pulled.request).sort()).toEqual(["op", "request_id"]);
137
+ expect(pulled.request).toEqual({ request_id: enq.request_id, op: "read" });
138
+ // 第二次拉:已经 in_progress,不再重复下发
139
+ expect(await n.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
140
+
141
+ expect(await n.call("ack_rules_file_request", {
142
+ request_id: enq.request_id, status: "done", file_name: "CLAUDE.md", exists: true, content: "# 规则\n",
143
+ })).toMatchObject({ ok: true, status: "done" });
144
+
145
+ const res = await u.call("get_rules_file_result", { request_id: enq.request_id });
146
+ expect(res).toMatchObject({ ok: true, status: "done", op: "read", file_name: "CLAUDE.md", exists: true, content: "# 规则\n", error: null });
147
+ } finally { await u.close(); await n.close(); }
148
+ }, 20_000);
149
+
150
+ test("write: content travels to the node verbatim; result carries no content back", async () => {
151
+ const u = await connect(asUser);
152
+ const n = await connect(asNode);
153
+ try {
154
+ const body = "# new rules\n\n- 不碰生产 DB\n";
155
+ const enq = await u.call("write_node_rules_file", { node_id: NODE_ID, content: body });
156
+ expect(enq).toMatchObject({ ok: true, op: "write" });
157
+ const pulled = await n.call("get_rules_file_request", {});
158
+ expect(pulled.request).toEqual({ request_id: enq.request_id, op: "write", content: body });
159
+ await n.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "CLAUDE.md", exists: true });
160
+ const res = await u.call("get_rules_file_result", { request_id: enq.request_id });
161
+ expect(res).toMatchObject({ ok: true, status: "done", op: "write", file_name: "CLAUDE.md" });
162
+ expect(res.content).toBeUndefined();
163
+ } finally { await u.close(); await n.close(); }
164
+ }, 20_000);
165
+
166
+ test("node failure is surfaced with its reason", async () => {
167
+ const u = await connect(asUser);
168
+ const n = await connect(asNode);
169
+ try {
170
+ const enq = await u.call("write_node_rules_file", { node_id: NODE_ID, content: "x" });
171
+ await n.call("get_rules_file_request", {});
172
+ await n.call("ack_rules_file_request", { request_id: enq.request_id, status: "failed", file_name: "CLAUDE.md", error: "EACCES: permission denied" });
173
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: true, status: "failed", error: "EACCES: permission denied" });
174
+ } finally { await u.close(); await n.close(); }
175
+ }, 20_000);
176
+ });
177
+
178
+ describe("app#225 rules file — SEC-1 and lifecycle", () => {
179
+ test("cross-network node cannot be targeted", async () => {
180
+ const u = await connect(asUser);
181
+ try {
182
+ expect(await u.call("read_node_rules_file", { node_id: OTHER_NODE_ID })).toMatchObject({ ok: false, error: "cross_network_node" });
183
+ expect(db.get<{ c: number }>("SELECT COUNT(*) AS c FROM node_rules_requests WHERE node_id = ?1", OTHER_NODE_ID)?.c).toBe(0);
184
+ } finally { await u.close(); }
185
+ }, 20_000);
186
+
187
+ test("a node in another network can neither pull nor ack this node's request", async () => {
188
+ const u = await connect(asUser);
189
+ const o = await connect(asOtherNode);
190
+ try {
191
+ const enq = await u.call("read_node_rules_file", { node_id: NODE_ID });
192
+ expect(await o.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
193
+ expect(await o.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "stolen" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
194
+ expect(db.get<{ status: string }>("SELECT status FROM node_rules_requests WHERE request_id = ?1", enq.request_id)?.status).toBe("pending");
195
+ } finally { await u.close(); await o.close(); }
196
+ }, 20_000);
197
+
198
+ test("user token (not a network token) cannot use the node-side pull/ack", async () => {
199
+ const u = await connect(asUser);
200
+ try {
201
+ expect(await u.call("get_rules_file_request", {})).toMatchObject({ ok: false, error: "network_token_required" });
202
+ expect(await u.call("ack_rules_file_request", { request_id: "rf_nope", status: "done" })).toMatchObject({ ok: false, error: "network_token_required" });
203
+ } finally { await u.close(); }
204
+ }, 20_000);
205
+
206
+ test("single flight: a second request while one is pending is refused; a stale one is superseded", async () => {
207
+ const u = await connect(asUser);
208
+ try {
209
+ const first = await u.call("read_node_rules_file", { node_id: NODE_ID });
210
+ expect(await u.call("read_node_rules_file", { node_id: NODE_ID })).toMatchObject({ ok: false, error: "request_in_flight", existing_request_id: first.request_id });
211
+ db.run("UPDATE node_rules_requests SET created_at = ?1 WHERE request_id = ?2", [Date.now() - 120_000, first.request_id]);
212
+ const second = await u.call("read_node_rules_file", { node_id: NODE_ID });
213
+ expect(second.ok).toBe(true);
214
+ expect(db.get<{ status: string }>("SELECT status FROM node_rules_requests WHERE request_id = ?1", first.request_id)?.status).toBe("timeout");
215
+ } finally { await u.close(); }
216
+ }, 20_000);
217
+
218
+ test("polling a request the node never answered flips it to timeout with a readable reason", async () => {
219
+ const u = await connect(asUser);
220
+ try {
221
+ const enq = await u.call("read_node_rules_file", { node_id: NODE_ID });
222
+ db.run("UPDATE node_rules_requests SET created_at = ?1 WHERE request_id = ?2", [Date.now() - 120_000, enq.request_id]);
223
+ const res = await u.call("get_rules_file_result", { request_id: enq.request_id });
224
+ expect(res).toMatchObject({ ok: true, status: "timeout" });
225
+ expect(res.error).toMatch(/did not answer/);
226
+ } finally { await u.close(); }
227
+ }, 20_000);
228
+
229
+ test("oversized content is refused before anything is queued", async () => {
230
+ const u = await connect(asUser);
231
+ try {
232
+ const r: any = await u.client.callTool({ name: "write_node_rules_file", arguments: { node_id: NODE_ID, content: "x".repeat(256 * 1024 + 1) } });
233
+ expect(r.isError).toBe(true);
234
+ expect(db.get<{ c: number }>("SELECT COUNT(*) AS c FROM node_rules_requests WHERE node_id = ?1", NODE_ID)?.c).toBe(0);
235
+ } finally { await u.close(); }
236
+ }, 20_000);
237
+ });
@@ -0,0 +1,22 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { normalizeSessionRuntime } from "./runtime-label";
3
+
4
+ describe("session runtime projection", () => {
5
+ test("preserves exact co-presence and CLI runtimes", () => {
6
+ expect(normalizeSessionRuntime("agent-node:codex-app-server")).toBe("codex-app-server");
7
+ expect(normalizeSessionRuntime("agent-node:grok-build-cli")).toBe("grok-build-cli");
8
+ });
9
+ test("keeps legacy labels backward-compatible", () => {
10
+ expect(normalizeSessionRuntime("agent-node:codex")).toBe("codex-sdk");
11
+ expect(normalizeSessionRuntime("agent-node:claude")).toBe("claude-agent-sdk");
12
+ expect(normalizeSessionRuntime("agent-node:grok")).toBe("grok-build-acp");
13
+ expect(normalizeSessionRuntime("agent-node:opencode")).toBe("opencode-cli");
14
+ expect(normalizeSessionRuntime("claude-code")).toBe("claude-code-cli");
15
+ });
16
+ test("does not invent unknown runtimes", () => {
17
+ expect(normalizeSessionRuntime("agent-node:future-runtime")).toBeNull();
18
+ expect(normalizeSessionRuntime("agent-node:codex-app-server-evil")).toBeNull();
19
+ expect(normalizeSessionRuntime("agent-node:grok-build-cli-preview")).toBeNull();
20
+ expect(normalizeSessionRuntime(null)).toBeNull();
21
+ });
22
+ });
@@ -0,0 +1,21 @@
1
+ /** Preserve exact agent-node runtimes before applying legacy family aliases. */
2
+ export function normalizeSessionRuntime(agent: unknown): string | null {
3
+ if (typeof agent !== "string" || agent.length === 0) return null;
4
+ if (agent === "claude-code") return "claude-code-cli";
5
+ const reported = agent.startsWith("agent-node:") ? agent.slice("agent-node:".length) : "";
6
+ const exact: Record<string, string> = {
7
+ "codex-app-server": "codex-app-server",
8
+ "grok-build-cli": "grok-build-cli",
9
+ opencode: "opencode-cli",
10
+ "opencode-cli": "opencode-cli",
11
+ codex: "codex-sdk",
12
+ "codex-sdk": "codex-sdk",
13
+ claude: "claude-agent-sdk",
14
+ "claude-agent-sdk": "claude-agent-sdk",
15
+ grok: "grok-build-acp",
16
+ "grok-build-acp": "grok-build-acp",
17
+ };
18
+ if (exact[reported]) return exact[reported];
19
+ if (agent === "http-api" || agent === "http" || agent === "api") return "http-api";
20
+ return null;
21
+ }
@@ -0,0 +1,268 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { db, chainReplyToParent } from "./db.js";
4
+ import { dispatchScheduledOccurrence } from "./scheduled-tasks.js";
5
+ import { registerTools } from "./tools.js";
6
+ import { patrolExpiredTasks } from "./server.js";
7
+
8
+ const NET = "net_690_scheduler_terminal";
9
+ const USER = "u_690_owner";
10
+ const NODE = "n_690_grok";
11
+ const ALIAS = "A站GrokTUI-690";
12
+
13
+ type Handler = (args: any, extra?: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
14
+
15
+ function cleanup(): void {
16
+ for (const table of ["task_events", "inbox", "scheduled_task_runs", "scheduled_tasks", "tasks", "sessions", "nodes", "network_members", "networks", "users"]) {
17
+ try { db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [NET]); } catch {}
18
+ }
19
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
20
+ }
21
+
22
+ function seedIdentity(): void {
23
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))", [USER, USER]);
24
+ db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
25
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
26
+ db.run(
27
+ `INSERT INTO nodes (node_id, node_name, alias, runtime, network_id, lifecycle_state)
28
+ VALUES (?1, ?2, ?2, 'grok-build-acp', ?3, 'active')`,
29
+ [NODE, ALIAS, NET],
30
+ );
31
+ db.run(
32
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
33
+ VALUES ('r_690', ?1, 'idle', ?2, ?3, datetime('now'), datetime('now'))`,
34
+ [ALIAS, NODE, NET],
35
+ );
36
+ }
37
+
38
+ function handlers(): Record<string, Handler> {
39
+ const server = new McpServer({ name: "test690", version: "0" }) as any;
40
+ const result: Record<string, Handler> = {};
41
+ const original = server.tool.bind(server);
42
+ server.tool = (name: string, ...args: any[]) => {
43
+ const handler = args.at(-1);
44
+ if (typeof handler === "function") result[name] = handler;
45
+ return original(name, ...args);
46
+ };
47
+ registerTools(server, undefined, NET, USER, USER, false, null);
48
+ return result;
49
+ }
50
+
51
+ async function call(handler: Handler, args: any): Promise<any> {
52
+ const reply = await handler(args);
53
+ return JSON.parse(reply.content[0].text);
54
+ }
55
+
56
+ function seedScheduledTask(status = "delivered", suffix = crypto.randomUUID()): { taskId: string; runId: string; scheduleId: string } {
57
+ const taskId = `task_${suffix}`;
58
+ const runId = `srun_${suffix}`;
59
+ const scheduleId = `sched_${suffix}`;
60
+ const meta = JSON.stringify({ scheduled_task_id: scheduleId, scheduled_run_id: runId, auth_origin: "hub_scheduler" });
61
+ db.run(
62
+ `INSERT INTO scheduled_tasks
63
+ (schedule_id, network_id, name, target_node_id, target_alias, task_content, priority,
64
+ schedule_type, schedule_json, timezone, status, next_run_at)
65
+ VALUES (?1, ?2, 'test690', ?3, ?4, 'test690 task', 'normal', 'interval',
66
+ '{"type":"interval","every_seconds":3600}', 'UTC', 'active', datetime('now', '+1 hour'))`,
67
+ [scheduleId, NET, NODE, ALIAS],
68
+ );
69
+ db.run(
70
+ `INSERT INTO tasks
71
+ (task_id, from_name, to_node_id, to_name, priority, status, content, requires_response,
72
+ created_at, delivered_at, expires_at, network_id, meta_json)
73
+ VALUES (?1, 'scheduler', ?2, ?3, 'normal', ?4, 'test690 task', 'reply',
74
+ datetime('now'), datetime('now'), datetime('now', '+1 hour'), ?5, ?6)`,
75
+ [taskId, NODE, ALIAS, status, NET, meta],
76
+ );
77
+ db.run(
78
+ `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
79
+ VALUES (?1, ?1, ?2, ?3, 'task', 'normal', 'test690 task', 'scheduler', 'reply', ?4, ?5)`,
80
+ [taskId, ALIAS, NODE, NET, meta],
81
+ );
82
+ db.run(
83
+ `INSERT INTO scheduled_task_runs (run_id, schedule_id, network_id, scheduled_for, task_id, status, completed_at)
84
+ VALUES (?1, ?2, ?3, datetime('now'), ?4, ?5, datetime('now'))`,
85
+ [runId, scheduleId, NET, taskId, status],
86
+ );
87
+ return { taskId, runId, scheduleId };
88
+ }
89
+
90
+ const readRun = (runId: string) => db.get<any>(
91
+ "SELECT run_id, task_id, network_id, status, error_code, completed_at FROM scheduled_task_runs WHERE run_id = ?1",
92
+ runId,
93
+ );
94
+
95
+ beforeEach(() => { cleanup(); seedIdentity(); });
96
+ afterAll(cleanup);
97
+
98
+ describe("#690 scheduled run follows the real task lifecycle", () => {
99
+ test("dispatch is open until the task reaches a terminal state", () => {
100
+ const scheduleId = `sched_dispatch_${crypto.randomUUID()}`;
101
+ const row: any = {
102
+ schedule_id: scheduleId,
103
+ network_id: NET,
104
+ name: "dispatch open",
105
+ target_node_id: NODE,
106
+ target_alias: ALIAS,
107
+ task_content: "dispatch open",
108
+ priority: "normal",
109
+ schedule_type: "interval",
110
+ schedule_json: JSON.stringify({ type: "interval", every_seconds: 3600 }),
111
+ timezone: "UTC",
112
+ overlap_policy: "skip",
113
+ misfire_policy: "catch_up_once",
114
+ status: "active",
115
+ next_run_at: new Date(Date.now() + 3600_000).toISOString(),
116
+ last_run_at: null,
117
+ revision: 1,
118
+ };
119
+ db.run(
120
+ `INSERT INTO scheduled_tasks
121
+ (schedule_id, network_id, name, target_node_id, target_alias, task_content, priority,
122
+ schedule_type, schedule_json, timezone, overlap_policy, misfire_policy, status, next_run_at)
123
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)`,
124
+ [scheduleId, NET, row.name, NODE, ALIAS, row.task_content, row.priority, row.schedule_type,
125
+ row.schedule_json, row.timezone, row.overlap_policy, row.misfire_policy, row.status, row.next_run_at],
126
+ );
127
+ const dispatched = dispatchScheduledOccurrence(row, new Date().toISOString(), false);
128
+ const run = readRun(dispatched.runId);
129
+ expect(["delivered", "queued"]).toContain(run.status);
130
+ expect(run.completed_at).toBeNull();
131
+ });
132
+
133
+ test("send_reply terminalizes the exact bound run", async () => {
134
+ const { taskId, runId } = seedScheduledTask();
135
+ const reply = await call(handlers().send_reply, {
136
+ alias: ALIAS,
137
+ text: "verified",
138
+ in_reply_to: taskId,
139
+ status: "replied",
140
+ network_id: NET,
141
+ attachments: [{ type: "file", file_id: "file_690_reply", name: "evidence.json", size: 12, mime: "application/json" }],
142
+ });
143
+ expect(reply.ok).toBe(true);
144
+ expect(readRun(runId).status).toBe("replied");
145
+ expect(readRun(runId).completed_at).toBeTruthy();
146
+ });
147
+
148
+ test("task and run terminal transition roll back together on an injected run-write failure", async () => {
149
+ const { taskId, runId } = seedScheduledTask();
150
+ db.exec(
151
+ `CREATE TRIGGER test690_fail_run_terminal
152
+ BEFORE UPDATE OF status ON scheduled_task_runs
153
+ WHEN NEW.run_id = '${runId}' AND NEW.status = 'replied'
154
+ BEGIN
155
+ SELECT RAISE(ABORT, 'test690_injected_run_failure');
156
+ END`,
157
+ );
158
+ let threw = false;
159
+ try {
160
+ await call(handlers().send_reply, {
161
+ alias: ALIAS,
162
+ text: "must roll back",
163
+ in_reply_to: taskId,
164
+ status: "replied",
165
+ network_id: NET,
166
+ });
167
+ } catch (error: any) {
168
+ threw = /test690_injected_run_failure/.test(String(error?.message || error));
169
+ } finally {
170
+ db.exec("DROP TRIGGER IF EXISTS test690_fail_run_terminal");
171
+ }
172
+ expect(threw).toBe(true);
173
+ expect(db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = ?1", taskId)?.status).toBe("delivered");
174
+ expect(readRun(runId).status).toBe("delivered");
175
+ expect(db.get<{ count: number }>("SELECT COUNT(*) AS count FROM inbox WHERE in_reply_to = ?1", taskId)?.count).toBe(0);
176
+ });
177
+
178
+ test("cancel terminalizes, retry reopens, and the retry result closes the same run", async () => {
179
+ const { taskId, runId } = seedScheduledTask();
180
+ const tools = handlers();
181
+ expect((await call(tools.cancel_task, { task_id: taskId, reason: "operator", network_id: NET })).ok).toBe(true);
182
+ expect(readRun(runId).status).toBe("cancelled");
183
+ expect(readRun(runId).completed_at).toBeTruthy();
184
+
185
+ expect((await call(tools.retry_task, { task_id: taskId, network_id: NET })).ok).toBe(true);
186
+ expect(readRun(runId).status).toBe("delivered");
187
+ expect(readRun(runId).completed_at).toBeNull();
188
+
189
+ expect((await call(tools.send_reply, {
190
+ alias: ALIAS,
191
+ text: "runtime failed",
192
+ in_reply_to: taskId,
193
+ status: "failed",
194
+ network_id: NET,
195
+ })).ok).toBe(true);
196
+ expect(readRun(runId).status).toBe("failed");
197
+ expect(readRun(runId).error_code).toBe("task_failed");
198
+ });
199
+
200
+ test("report_completion terminalizes a scheduler run", async () => {
201
+ const { taskId, runId } = seedScheduledTask();
202
+ const reply = await call(handlers().report_completion, {
203
+ alias: ALIAS,
204
+ task: taskId,
205
+ result: "done",
206
+ network_id: NET,
207
+ });
208
+ expect(reply.ok).toBe(true);
209
+ expect(readRun(runId).status).toBe("replied");
210
+ });
211
+
212
+ test("expiration patrol closes the exact run in the same lifecycle pass", () => {
213
+ const { taskId, runId } = seedScheduledTask();
214
+ db.run("UPDATE tasks SET expires_at = datetime('now', '-1 minute') WHERE task_id = ?1", [taskId]);
215
+ patrolExpiredTasks();
216
+ expect(db.get<{ status: string }>("SELECT status FROM tasks WHERE task_id = ?1", taskId)?.status).toBe("expired");
217
+ expect(readRun(runId).status).toBe("expired");
218
+ expect(readRun(runId).error_code).toBe("task_expired");
219
+ expect(readRun(runId).completed_at).toBeTruthy();
220
+ });
221
+
222
+ test("child auto-chain terminalizes a scheduled parent", () => {
223
+ const { taskId: parentId, runId } = seedScheduledTask();
224
+ const childId = `child_${crypto.randomUUID()}`;
225
+ db.run(
226
+ `INSERT INTO tasks
227
+ (task_id, parent_task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id)
228
+ VALUES (?1, ?2, ?3, 'A站负责人', 'normal', 'replied', 'verify candidate', 'reply', datetime('now'), ?4)`,
229
+ [childId, parentId, ALIAS, NET],
230
+ );
231
+ const result = chainReplyToParent(childId, "news_id=14519", "replied", 5, NET);
232
+ expect(result.chained).toBe(true);
233
+ expect(readRun(runId).status).toBe("replied");
234
+ });
235
+
236
+ test("forged metadata cannot cross-bind another task or network run", async () => {
237
+ const victim = seedScheduledTask("delivered", "victim");
238
+ const attackerTaskId = "task_attacker";
239
+ const forgedMeta = JSON.stringify({ scheduled_task_id: victim.scheduleId, scheduled_run_id: victim.runId, auth_origin: "hub_scheduler" });
240
+ db.run(
241
+ `INSERT INTO tasks
242
+ (task_id, from_name, to_node_id, to_name, priority, status, content, requires_response, created_at, network_id, meta_json)
243
+ VALUES (?1, 'scheduler', ?2, ?3, 'normal', 'delivered', 'forged', 'reply', datetime('now'), ?4, ?5)`,
244
+ [attackerTaskId, NODE, ALIAS, NET, forgedMeta],
245
+ );
246
+ await call(handlers().send_reply, {
247
+ alias: ALIAS,
248
+ text: "forged close",
249
+ in_reply_to: attackerTaskId,
250
+ status: "replied",
251
+ network_id: NET,
252
+ });
253
+ expect(readRun(victim.runId).status).toBe("delivered");
254
+ });
255
+
256
+ test("a task cannot close a run whose persisted network binding differs", async () => {
257
+ const victim = seedScheduledTask("delivered", "foreign_network");
258
+ db.run("UPDATE scheduled_task_runs SET network_id = 'net_690_foreign' WHERE run_id = ?1", [victim.runId]);
259
+ await call(handlers().send_reply, {
260
+ alias: ALIAS,
261
+ text: "must not cross network",
262
+ in_reply_to: victim.taskId,
263
+ status: "replied",
264
+ network_id: NET,
265
+ });
266
+ expect(readRun(victim.runId).status).toBe("delivered");
267
+ });
268
+ });