@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.
Files changed (170) hide show
  1. package/README.md +130 -10
  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-delete-network-http.test.ts +177 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access.ts +461 -0
  9. package/src/agent-acl-groups-http.test.ts +247 -0
  10. package/src/agent-acl-http.test.ts +510 -0
  11. package/src/agent-acl-slice3-http.test.ts +267 -0
  12. package/src/agent-groups-perf.test.ts +167 -0
  13. package/src/alias-filter.test.ts +72 -0
  14. package/src/alias-filter.ts +32 -0
  15. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  16. package/src/api-host-supervisors-fallback.test.ts +271 -0
  17. package/src/api-nodes-shape.test.ts +26 -10
  18. package/src/auth-sessions-http.test.ts +250 -0
  19. package/src/auth-validate.test.ts +5 -1
  20. package/src/auth.ts +423 -29
  21. package/src/avatar-validate.test.ts +163 -0
  22. package/src/avatar-validate.ts +103 -0
  23. package/src/blocked-exit-on-reply.test.ts +122 -0
  24. package/src/boot-side-effect.test.ts +124 -0
  25. package/src/config-apply-sec1.test.ts +107 -0
  26. package/src/config-apply-validate.test.ts +92 -0
  27. package/src/config-apply-validate.ts +81 -3
  28. package/src/create-node-blocked-gate.test.ts +356 -0
  29. package/src/create-node-tool-schema.test.ts +39 -0
  30. package/src/create-node-validate.test.ts +89 -1
  31. package/src/create-node-validate.ts +89 -7
  32. package/src/create-node-workdir.test.ts +255 -0
  33. package/src/create-node.ts +51 -1
  34. package/src/dashboard-slash-routing-http.test.ts +107 -0
  35. package/src/db-adapter-guard.test.ts +85 -0
  36. package/src/db-adapter-translate.test.ts +112 -0
  37. package/src/db-adapter.ts +246 -83
  38. package/src/db-timestamp.test.ts +38 -0
  39. package/src/db-timestamp.ts +15 -0
  40. package/src/db.ts +799 -33
  41. package/src/delivered-without-push.test.ts +140 -0
  42. package/src/desktop-message-seam.test.ts +150 -0
  43. package/src/desktop-message.test.ts +368 -0
  44. package/src/ext-token-shared.test.ts +60 -0
  45. package/src/ext-validation-regression.test.ts +131 -0
  46. package/src/external-schedule-edits-http.test.ts +321 -0
  47. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  48. package/src/external-schedule-edits.ts +327 -0
  49. package/src/file-download-authz-dev-open.test.ts +135 -0
  50. package/src/file-download-authz.test.ts +404 -0
  51. package/src/file-download-date-bucket.test.ts +436 -0
  52. package/src/file-network-scope-dev-open.test.ts +97 -0
  53. package/src/file-network-scope.test.ts +732 -0
  54. package/src/health-redaction.test.ts +642 -0
  55. package/src/http-gzip.test.ts +73 -0
  56. package/src/http-gzip.ts +60 -0
  57. package/src/hub-timestamp.test.ts +38 -0
  58. package/src/hub-timestamp.ts +27 -0
  59. package/src/human-dm.ts +149 -0
  60. package/src/inbox-count-sse.test.ts +310 -0
  61. package/src/inbox-count.ts +29 -0
  62. package/src/index.ts +15 -2331
  63. package/src/lifecycle-guard.ts +50 -0
  64. package/src/mcp-write-network-resolution.test.ts +429 -0
  65. package/src/messages-ack-by-agent-http.test.ts +142 -0
  66. package/src/network-name-http.test.ts +75 -0
  67. package/src/network-scope.ts +224 -0
  68. package/src/node-attrs-validate.ts +88 -0
  69. package/src/node-attrs.test.ts +217 -0
  70. package/src/node-create-request-status-http.test.ts +92 -0
  71. package/src/node-files-transport.test.ts +344 -0
  72. package/src/node-id-alias.test.ts +188 -0
  73. package/src/node-lifecycle-controllable.test.ts +64 -0
  74. package/src/node-lifecycle-controllable.ts +51 -0
  75. package/src/node-logs-transport.test.ts +263 -0
  76. package/src/node-request-retention.test.ts +281 -0
  77. package/src/node-request-retention.ts +120 -0
  78. package/src/node-skills-transport.test.ts +209 -0
  79. package/src/node-token-privilege-http.test.ts +149 -0
  80. package/src/observer-avatar-http.test.ts +429 -0
  81. package/src/observer-push.test.ts +252 -0
  82. package/src/package-version-consistency.test.ts +25 -0
  83. package/src/peer-reply-atomic.test.ts +455 -0
  84. package/src/peer-reply-capability.test.ts +70 -0
  85. package/src/pg-worker.ts +87 -0
  86. package/src/push.ts +204 -7
  87. package/src/redact-tokens.test.ts +65 -0
  88. package/src/redact-tokens.ts +40 -0
  89. package/src/rename.ts +7 -2
  90. package/src/reply-target-derivation.test.ts +96 -0
  91. package/src/report-status-host-schema.test.ts +211 -0
  92. package/src/report-status-resume-id-handover.test.ts +150 -0
  93. package/src/require-explicit-test-db.ts +9 -0
  94. package/src/requirement-tags.ts +16 -0
  95. package/src/requirements-etag-http.test.ts +91 -0
  96. package/src/requirements-http.test.ts +708 -0
  97. package/src/requirements-mcp.test.ts +142 -0
  98. package/src/requirements-migrate.ts +83 -0
  99. package/src/requirements-priority-lowest.test.ts +114 -0
  100. package/src/requirements-start-date.test.ts +125 -0
  101. package/src/requirements-upgrade-e2e.test.ts +126 -0
  102. package/src/requirements-upgrade-migration.test.ts +85 -0
  103. package/src/requirements.ts +899 -0
  104. package/src/resolve-port.test.ts +57 -0
  105. package/src/resolve-port.ts +44 -0
  106. package/src/response-charset.test.ts +130 -0
  107. package/src/rest-explicit-columns-http.test.ts +216 -0
  108. package/src/rest-identity.test.ts +129 -0
  109. package/src/rest-identity.ts +100 -0
  110. package/src/rest-projections.ts +68 -0
  111. package/src/rest-write-network-resolution.test.ts +133 -0
  112. package/src/restricted-files.ts +73 -0
  113. package/src/retention.ts +10 -1
  114. package/src/rules-file-duplicate-alias.test.ts +234 -0
  115. package/src/rules-file-session-target.test.ts +207 -0
  116. package/src/rules-file-transport.test.ts +237 -0
  117. package/src/runtime-label.test.ts +22 -0
  118. package/src/runtime-label.ts +21 -0
  119. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  120. package/src/scheduled-run-terminal.test.ts +268 -0
  121. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  122. package/src/scheduled-tasks-http.test.ts +625 -0
  123. package/src/scheduled-tasks.ts +597 -0
  124. package/src/send-dedup-request-id.test.ts +229 -0
  125. package/src/send-reply-agent-warning.test.ts +202 -0
  126. package/src/send-reply-alias-optional.test.ts +131 -0
  127. package/src/send-reply-attachments.test.ts +458 -0
  128. package/src/send_dedup.test.ts +54 -0
  129. package/src/send_dedup.ts +52 -6
  130. package/src/serve-error.test.ts +38 -0
  131. package/src/serve-error.ts +27 -0
  132. package/src/server.ts +4385 -0
  133. package/src/shared/external-schedule-contract.ts +60 -0
  134. package/src/side-thread-command-transport.test.ts +302 -0
  135. package/src/side-thread-command-transport.ts +318 -0
  136. package/src/side-thread-http-integration.test.ts +233 -0
  137. package/src/side-thread-http.ts +504 -0
  138. package/src/side-thread-production.test.ts +72 -0
  139. package/src/side-thread-production.ts +118 -0
  140. package/src/side-thread.test.ts +1067 -0
  141. package/src/side-thread.ts +2495 -0
  142. package/src/skillhub-http.test.ts +92 -0
  143. package/src/start-node.test.ts +109 -0
  144. package/src/status-alias-filter-http.test.ts +106 -0
  145. package/src/stop-delete-node.test.ts +1164 -0
  146. package/src/task-auth-origin-http.test.ts +91 -0
  147. package/src/task-auth-origin.test.ts +24 -0
  148. package/src/task-auth-origin.ts +7 -0
  149. package/src/task-consumption.test.ts +482 -0
  150. package/src/task-diagnostic-http.test.ts +149 -0
  151. package/src/task-diagnostic.test.ts +115 -0
  152. package/src/task-diagnostic.ts +118 -0
  153. package/src/task-idempotency-mcp.test.ts +142 -0
  154. package/src/task-idempotency.test.ts +60 -0
  155. package/src/task-idempotency.ts +75 -0
  156. package/src/task-lifecycle-watcher.test.ts +204 -0
  157. package/src/task-lifecycle-watcher.ts +49 -0
  158. package/src/task-network-resolution-http.test.ts +141 -0
  159. package/src/tasks-pagination-http.test.ts +141 -0
  160. package/src/tools-list-json-schema.test.ts +66 -0
  161. package/src/tools.ts +3446 -257
  162. package/src/update-provider.test.ts +65 -0
  163. package/src/upload-early-reject-guard.test.ts +155 -0
  164. package/src/uploads-http.test.ts +97 -15
  165. package/src/uploads.test.ts +66 -0
  166. package/src/uploads.ts +130 -5
  167. package/src/user-inbox-by-agent-http.test.ts +161 -0
  168. package/src/user-inbox-netid-notnull.test.ts +122 -0
  169. package/src/user-inbox-read-http.test.ts +223 -0
  170. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ childNodeIdForCreateRequest,
4
+ buildControllableMap,
5
+ isLifecycleControllable,
6
+ } from "./node-lifecycle-controllable.js";
7
+
8
+ // app#196 —— 桌面端对 218 个节点里的 207 个(95%)显示了「停止/重启/删除」,
9
+ // 而那三个工具的 schema 是 regex(/^node_[a-z0-9_-]+$/),那 207 个是 n_ 开头,
10
+ // 点下去只会看到一段 zod 正则原文。
11
+ describe("childNodeIdForCreateRequest —— 推导规则", () => {
12
+ test("cr_ 前缀 → node_ 前缀,其余部分逐字保留", () => {
13
+ // 🔴 这一对是 2026-08-28 生产 hub 日志里真实的 create-node finalize 行,
14
+ // 不是我按注释造的。
15
+ expect(childNodeIdForCreateRequest("cr_59723b24-8372-4270-9b3c-1552e592a09f"))
16
+ .toBe("node_59723b24-8372-4270-9b3c-1552e592a09f");
17
+ });
18
+ test("不是 cr_ 开头的一律 null —— 不猜、不截断", () => {
19
+ expect(childNodeIdForCreateRequest("node_abc")).toBeNull();
20
+ expect(childNodeIdForCreateRequest("n_abc")).toBeNull();
21
+ expect(childNodeIdForCreateRequest("abc")).toBeNull();
22
+ expect(childNodeIdForCreateRequest("cr_")).toBeNull(); // 只有前缀,没有主体
23
+ });
24
+ test("非字符串输入不抛,返回 null", () => {
25
+ for (const v of [undefined, null, 123, {}, []]) {
26
+ expect(childNodeIdForCreateRequest(v as any)).toBeNull();
27
+ }
28
+ });
29
+ });
30
+
31
+ describe("isLifecycleControllable —— 判据不是 id 前缀", () => {
32
+ const map = buildControllableMap([
33
+ { request_id: "cr_aaa", daemon_node_id: "node_daemon_x" },
34
+ { request_id: "cr_bbb", daemon_node_id: "node_daemon_y" },
35
+ { request_id: "not-a-request", daemon_node_id: "node_daemon_z" }, // 应被丢弃
36
+ ]);
37
+
38
+ test("有创建记录的 → 可控", () => {
39
+ expect(isLifecycleControllable("node_aaa", null, map)).toBe(true);
40
+ expect(map.get("node_aaa")).toBe("node_daemon_x");
41
+ });
42
+
43
+ test("🔴 光有 node_ 前缀但没有创建记录 → 不可控", () => {
44
+ // 这条是本模块存在的理由:前缀是实现细节,不是判据。
45
+ expect(isLifecycleControllable("node_never_created", null, map)).toBe(false);
46
+ });
47
+
48
+ test("手工起的 n_ 节点 → 不可控(那 207 个)", () => {
49
+ expect(isLifecycleControllable("n_22b777bf", null, map)).toBe(false);
50
+ });
51
+
52
+ test("🔴 daemon 自己可控 —— 它在 node_create_requests 里没有记录", () => {
53
+ // 漏掉这条会把 3 个 daemon 从可控名单里误删。
54
+ expect(isLifecycleControllable("node_daemon_39bd1eeae3ee", "host_supervisor", map)).toBe(true);
55
+ // 正控:同一个 id 但 role 不是 host_supervisor ⇒ 仍不可控,
56
+ // 证明上面那条过是因为 role,不是因为 id 恰好带 node_ 前缀。
57
+ expect(isLifecycleControllable("node_daemon_39bd1eeae3ee", null, map)).toBe(false);
58
+ });
59
+
60
+ test("坏的 request_id 不会污染映射", () => {
61
+ expect(map.has("node_not-a-request")).toBe(false);
62
+ expect(map.size).toBe(2);
63
+ });
64
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * app#196 —— 哪些节点能走 stop_node / restart_node / delete_node。
3
+ *
4
+ * 抽成独立模块而不是写在 /api/nodes 的路由里,因为路由里的内联逻辑测不到 ——
5
+ * 现有的 api-nodes-shape.test.ts 是直跑 handler 的 SQL,拿不到映射步骤。
6
+ * 🔴 「不可测」本身就是一个设计信号,不是测试的问题。
7
+ */
8
+
9
+ /** 子节点 id 由创建请求 id 确定性推导。
10
+ *
11
+ * 🔴 这条规则我用真实一对验过,不是只读注释:
12
+ * cr_59723b24-8372-4270-9b3c-1552e592a09f → node_59723b24-8372-4270-9b3c-…
13
+ * (2026-08-28 生产 hub 日志的 `create-node finalize` 行)
14
+ *
15
+ * 非 `cr_` 开头的一律返回 null —— 不猜,不截断。 */
16
+ export function childNodeIdForCreateRequest(requestId: unknown): string | null {
17
+ if (typeof requestId !== "string") return null;
18
+ if (!requestId.startsWith("cr_")) return null;
19
+ const rest = requestId.slice(3);
20
+ if (!rest) return null;
21
+ return `node_${rest}`;
22
+ }
23
+
24
+ /** 由创建记录建出「child node_id → daemon node_id」的映射。 */
25
+ export function buildControllableMap(
26
+ createRequests: Array<{ request_id?: unknown; daemon_node_id?: unknown }>,
27
+ ): Map<string, string> {
28
+ const m = new Map<string, string>();
29
+ for (const cr of createRequests) {
30
+ const child = childNodeIdForCreateRequest(cr?.request_id);
31
+ if (!child) continue;
32
+ m.set(child, typeof cr?.daemon_node_id === "string" ? cr.daemon_node_id : "");
33
+ }
34
+ return m;
35
+ }
36
+
37
+ /** 一个节点能不能走生命周期操作。
38
+ *
39
+ * 🔴 判据**不是 id 前缀**。前缀是实现细节;判据是 node_create_requests 里真有创建记录。
40
+ *
41
+ * 例外:daemon 自己(role=host_supervisor)也能被 restart/stop,而它在
42
+ * node_create_requests 里**没有**记录(它不是被别的 daemon 创建的)—— 单独放行。
43
+ * 漏掉这一条会把可控的 daemon 判成不可控。 */
44
+ export function isLifecycleControllable(
45
+ nodeId: unknown,
46
+ role: unknown,
47
+ controllable: Map<string, string>,
48
+ ): boolean {
49
+ if (role === "host_supervisor") return true;
50
+ return typeof nodeId === "string" && controllable.has(nodeId);
51
+ }
@@ -0,0 +1,263 @@
1
+ // Node run-log view — tail_node_logs rides the rules-file queue + doorbell (op logs_tail).
2
+ //
3
+ // Hub-side contract verified here:
4
+ // 1. report_status { logs_capable: true } is sticky and set only by the node's
5
+ // own token; /api/status-shaped rows expose it.
6
+ // 2. The pulled request carries only the normalised filter JSON — no path field
7
+ // exists anywhere in the tool schema; unknown args are dropped.
8
+ // 3. Who may ask: user logins only (node tokens refused); the network owner/admin,
9
+ // or the node's owner (nodes.owner_user_id). A member who does not own the
10
+ // node, a viewer, and a user of another network are refused before any row.
11
+ // 4. Who may read: only the token that asked (like the project folder).
12
+ // 5. Storage: the result is handed out ONCE — the first terminal read purges
13
+ // result_content and the filter JSON; the second read says content_purged.
14
+ // An unread terminal result is purged by the sweep after LOGS_CONTENT_TTL_MS.
15
+ // 6. Own single-flight lane; logs acks may carry up to 1 MiB.
16
+ //
17
+ // 跑法:cd server && COMMHUB_DB=/tmp/logs-transport.db bun test src/node-logs-transport.test.ts
18
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
19
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
20
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
21
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
+ import { db } from "./db.js";
23
+ import { registerTools } from "./tools.js";
24
+ import { LOGS_CONTENT_TTL_MS, sweepNodeRequestContent } from "./node-request-retention.js";
25
+
26
+ const NET = "net_logs_t";
27
+ const OTHER_NET = "net_logs_t_other";
28
+ const OWNER = "u_logs_net_owner";
29
+ const MEMBER = "u_logs_member_owns_a";
30
+ const MEMBER2 = "u_logs_member_plain";
31
+ const VIEWER = "u_logs_viewer";
32
+ const OUTSIDER = "u_logs_outsider";
33
+ const NODE_A = "logs-node-a";
34
+ const NODE_B = "logs-node-b";
35
+ const NODE_A_ID = "node_logs_a";
36
+ const NODE_B_ID = "node_logs_b";
37
+ const T_A = "tok_logs_node_a";
38
+ const T_B = "tok_logs_node_b";
39
+
40
+ function cleanup() {
41
+ for (const net of [NET, OTHER_NET]) {
42
+ for (const t of ["node_rules_requests", "sessions", "nodes", "api_tokens", "network_members", "networks"]) {
43
+ try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [net]); } catch {}
44
+ }
45
+ }
46
+ for (const u of [OWNER, MEMBER, MEMBER2, VIEWER, OUTSIDER]) {
47
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [u]); } catch {}
48
+ }
49
+ }
50
+
51
+ function seedWorld() {
52
+ for (const u of [OWNER, MEMBER, MEMBER2, VIEWER, OUTSIDER]) {
53
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [u, u]);
54
+ }
55
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET, NET, OWNER]);
56
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [OTHER_NET, OTHER_NET, OUTSIDER]);
57
+ const member = (u: string, net: string, role: string) =>
58
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [u, net, role]);
59
+ member(OWNER, NET, "owner");
60
+ member(MEMBER, NET, "member");
61
+ member(MEMBER2, NET, "member");
62
+ member(VIEWER, NET, "viewer");
63
+ member(OUTSIDER, OTHER_NET, "owner");
64
+ const node = (id: string, alias: string, owner: string | null) => db.run(
65
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), datetime('now'))`,
66
+ [id, alias, alias, NET, owner],
67
+ );
68
+ node(NODE_A_ID, NODE_A, MEMBER);
69
+ node(NODE_B_ID, NODE_B, OWNER);
70
+ const tok = (id: string, alias: string, bound: string) => db.run(
71
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at, bound_node_id) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL, ?6)`,
72
+ [id, OWNER, NET, `node:${alias}`, `hash_${id}`, bound],
73
+ );
74
+ tok(T_A, NODE_A, NODE_A_ID);
75
+ tok(T_B, NODE_B, NODE_B_ID);
76
+ }
77
+
78
+ type Identity = { net: string | null; user: string; alias: string; isNetworkToken: boolean; tokenId: string };
79
+ const asOwner: Identity = { net: null, user: OWNER, alias: OWNER, isNetworkToken: false, tokenId: "tok_logs_owner_login" };
80
+ const asOwnerOtherLogin: Identity = { ...asOwner, tokenId: "tok_logs_owner_login_2" };
81
+ const asMember: Identity = { net: null, user: MEMBER, alias: MEMBER, isNetworkToken: false, tokenId: "tok_logs_member_login" };
82
+ const asMember2: Identity = { net: null, user: MEMBER2, alias: MEMBER2, isNetworkToken: false, tokenId: "tok_logs_member2_login" };
83
+ const asViewer: Identity = { net: null, user: VIEWER, alias: VIEWER, isNetworkToken: false, tokenId: "tok_logs_viewer_login" };
84
+ const asOutsider: Identity = { net: null, user: OUTSIDER, alias: OUTSIDER, isNetworkToken: false, tokenId: "tok_logs_outsider_login" };
85
+ const asA: Identity = { net: NET, user: OWNER, alias: NODE_A, isNetworkToken: true, tokenId: T_A };
86
+ const asB: Identity = { net: NET, user: OWNER, alias: NODE_B, isNetworkToken: true, tokenId: T_B };
87
+
88
+ async function connect(id: Identity) {
89
+ const server = new McpServer({ name: "logs-test", version: "1" });
90
+ registerTools(server, undefined, id.net, id.user, id.alias, id.isNetworkToken, id.tokenId);
91
+ const client = new Client({ name: "logs-client", version: "1" });
92
+ const [ct, st] = InMemoryTransport.createLinkedPair();
93
+ await server.connect(st);
94
+ await client.connect(ct);
95
+ const call = async (name: string, args: Record<string, unknown>) => {
96
+ const r: any = await client.callTool({ name, arguments: args });
97
+ const first = r.content?.[0];
98
+ if (!first || first.type !== "text") throw new Error(`no text result from ${name}: ${JSON.stringify(r).slice(0, 300)}`);
99
+ try { return JSON.parse(first.text); } catch { return { raw: first.text, isError: r.isError === true }; }
100
+ };
101
+ const close = async () => { await client.close(); await server.close(); };
102
+ return { call, close };
103
+ }
104
+
105
+ const rowCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM node_rules_requests WHERE network_id = ?1", NET)!.n;
106
+ const row = (id: string) => db.get<any>("SELECT op, content, result_content, content_purged_at FROM node_rules_requests WHERE request_id = ?1", id);
107
+ const SAMPLE = JSON.stringify({ files: ["2026-09-29.log"], lines: [{ ts: 1, level: "info", text: "[10:00:00] [INFO ] [x] hello", key: "2026-09-29.log:0" }], truncated: false, matched: 1, now_ts: 2 });
108
+
109
+ beforeEach(() => { cleanup(); seedWorld(); });
110
+ afterAll(cleanup);
111
+
112
+ describe("logs_capable", () => {
113
+ test("set by the node's own token, sticky, not settable by a user login", async () => {
114
+ const a = await connect(asA);
115
+ try {
116
+ await a.call("report_status", { resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID, logs_capable: true });
117
+ await a.call("report_status", { resume_id: `sdk-${NODE_A_ID}`, alias: NODE_A, status: "idle", node_id: NODE_A_ID });
118
+ } finally { await a.close(); }
119
+ expect(db.get<{ c: number }>("SELECT logs_capable AS c FROM sessions WHERE alias = ?1 AND network_id = ?2", NODE_A, NET)?.c).toBe(1);
120
+ const u = await connect(asOwner);
121
+ try {
122
+ await u.call("report_status", { resume_id: "user-logs-report", alias: NODE_B, status: "idle", network_id: NET, logs_capable: true });
123
+ } finally { await u.close(); }
124
+ expect(db.get<{ c: number }>("SELECT logs_capable AS c FROM sessions WHERE alias = ?1 AND network_id = ?2", NODE_B, NET)?.c ?? 0).toBe(0);
125
+ }, 20_000);
126
+ });
127
+
128
+ describe("round trip", () => {
129
+ test("the node pulls only the normalised filter JSON (no path), acks, the asker reads it once", async () => {
130
+ const u = await connect(asOwner);
131
+ const a = await connect(asA);
132
+ try {
133
+ const enq = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET, lines: 99999 > 2000 ? 2000 : 1, level: "warn", grep: "boom", since_ts: 5, path: "/etc/passwd" });
134
+ expect(enq).toMatchObject({ ok: true, op: "logs_tail" });
135
+ const pulled = (await a.call("get_rules_file_request", {})).request;
136
+ expect(pulled.op).toBe("logs_tail");
137
+ expect(JSON.parse(pulled.content)).toEqual({ lines: 2000, level: "warn", grep: "boom", since_ts: 5 });
138
+ expect(pulled.content).not.toContain("passwd");
139
+ expect(await a.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "logs", exists: true, content: SAMPLE })).toMatchObject({ ok: true, status: "done" });
140
+
141
+ const first = await u.call("get_rules_file_result", { request_id: enq.request_id, network_id: NET });
142
+ expect(first).toMatchObject({ ok: true, op: "logs_tail", status: "done", content: SAMPLE });
143
+ // Read-once: purged in the same call, including the filter JSON.
144
+ expect(row(enq.request_id)).toMatchObject({ result_content: null, content: null });
145
+ expect(row(enq.request_id).content_purged_at).toBeGreaterThan(0);
146
+ const second = await u.call("get_rules_file_result", { request_id: enq.request_id, network_id: NET });
147
+ expect(second.content).toBeUndefined();
148
+ expect(second.content_purged).toBe(true);
149
+ } finally { await u.close(); await a.close(); }
150
+ }, 20_000);
151
+
152
+ test("lines above 2000 are refused by the schema; bad level refused", async () => {
153
+ const u = await connect(asOwner);
154
+ try {
155
+ expect((await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET, lines: 2001 })).ok).not.toBe(true);
156
+ expect((await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET, level: "debug" })).ok).not.toBe(true);
157
+ expect(rowCount()).toBe(0);
158
+ } finally { await u.close(); }
159
+ }, 20_000);
160
+ });
161
+
162
+ describe("who may ask", () => {
163
+ test("node tokens are refused — for other nodes and for themselves", async () => {
164
+ const a = await connect(asA);
165
+ try {
166
+ expect(await a.call("tail_node_logs", { node_id: NODE_B_ID })).toMatchObject({ ok: false, error: "node_token_cannot_read_logs" });
167
+ expect(await a.call("tail_node_logs", { node_id: NODE_A_ID })).toMatchObject({ ok: false, error: "node_token_cannot_read_logs" });
168
+ expect(rowCount()).toBe(0);
169
+ } finally { await a.close(); }
170
+ }, 20_000);
171
+
172
+ test("node owner yes; plain member no; viewer no; network owner yes", async () => {
173
+ const m = await connect(asMember);
174
+ const m2 = await connect(asMember2);
175
+ const v = await connect(asViewer);
176
+ const o = await connect(asOwner);
177
+ try {
178
+ expect(await m.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET })).toMatchObject({ ok: true });
179
+ expect(await m.call("tail_node_logs", { node_id: NODE_B_ID, network_id: NET })).toMatchObject({ ok: false, error: "logs_permission_denied" });
180
+ expect(await m2.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET })).toMatchObject({ ok: false, error: "logs_permission_denied" });
181
+ expect((await v.call("tail_node_logs", { node_id: NODE_B_ID, network_id: NET })).ok).toBe(false);
182
+ expect(await o.call("tail_node_logs", { node_id: NODE_B_ID, network_id: NET })).toMatchObject({ ok: true });
183
+ expect(rowCount()).toBe(2);
184
+ } finally { await m.close(); await m2.close(); await v.close(); await o.close(); }
185
+ }, 20_000);
186
+
187
+ test("a user of another network is refused (cross network)", async () => {
188
+ const x = await connect(asOutsider);
189
+ try {
190
+ const r1 = await x.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
191
+ expect(r1.ok).toBe(false);
192
+ const r2 = await x.call("tail_node_logs", { node_id: NODE_A_ID, network_id: OTHER_NET });
193
+ expect(r2).toMatchObject({ ok: false, error: "cross_network_node" });
194
+ expect(rowCount()).toBe(0);
195
+ } finally { await x.close(); }
196
+ }, 20_000);
197
+ });
198
+
199
+ describe("who may read", () => {
200
+ test("only the login that asked; another login of the same user, node tokens and the serving node cannot", async () => {
201
+ const u = await connect(asOwner);
202
+ const other = await connect(asOwnerOtherLogin);
203
+ const b = await connect(asB);
204
+ const a = await connect(asA);
205
+ try {
206
+ const enq = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
207
+ await a.call("get_rules_file_request", {});
208
+ await a.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: SAMPLE });
209
+ expect(await other.call("get_rules_file_result", { request_id: enq.request_id, network_id: NET })).toMatchObject({ ok: false, error: "request_not_found" });
210
+ expect(await b.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: false, error: "request_not_found" });
211
+ expect(await a.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: false, error: "request_not_found" });
212
+ // Those refusals did not consume the read-once result.
213
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id, network_id: NET })).toMatchObject({ status: "done", content: SAMPLE });
214
+ } finally { await u.close(); await other.close(); await b.close(); await a.close(); }
215
+ }, 20_000);
216
+
217
+ test("node B never pulls or acks node A's logs request", async () => {
218
+ const u = await connect(asOwner);
219
+ const a = await connect(asA);
220
+ const b = await connect(asB);
221
+ try {
222
+ const enq = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
223
+ expect((await b.call("get_rules_file_request", {})).request).toBeNull();
224
+ expect(await b.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{\"forged\":true}" })).toMatchObject({ ignored: "unknown_or_foreign_request" });
225
+ expect((await a.call("get_rules_file_request", {})).request.request_id).toBe(enq.request_id);
226
+ } finally { await u.close(); await a.close(); await b.close(); }
227
+ }, 20_000);
228
+ });
229
+
230
+ describe("storage and lanes", () => {
231
+ test("an unread terminal result is purged by the sweep after the logs TTL; other ops keep theirs", async () => {
232
+ const u = await connect(asOwner);
233
+ const a = await connect(asA);
234
+ try {
235
+ const enq = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
236
+ const rules = await u.call("read_node_rules_file", { node_id: NODE_A_ID, network_id: NET });
237
+ await a.call("get_rules_file_request", {});
238
+ await a.call("get_rules_file_request", {});
239
+ await a.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: SAMPLE });
240
+ await a.call("ack_rules_file_request", { request_id: rules.request_id, status: "done", content: "# rules" });
241
+ sweepNodeRequestContent(Date.now() + LOGS_CONTENT_TTL_MS - 5_000);
242
+ expect(row(enq.request_id).result_content).toBe(SAMPLE);
243
+ sweepNodeRequestContent(Date.now() + LOGS_CONTENT_TTL_MS + 5_000);
244
+ expect(row(enq.request_id)).toMatchObject({ result_content: null, content: null });
245
+ expect(row(rules.request_id).result_content).toBe("# rules");
246
+ } finally { await u.close(); await a.close(); }
247
+ }, 20_000);
248
+
249
+ test("own single-flight lane; a 1 MiB-class ack is accepted", async () => {
250
+ const u = await connect(asOwner);
251
+ const a = await connect(asA);
252
+ try {
253
+ expect(await u.call("list_node_files", { node_id: NODE_A_ID, network_id: NET })).toMatchObject({ ok: true });
254
+ const first = await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET });
255
+ expect(first).toMatchObject({ ok: true });
256
+ expect(await u.call("tail_node_logs", { node_id: NODE_A_ID, network_id: NET })).toMatchObject({ ok: false, error: "request_in_flight", existing_request_id: first.request_id });
257
+ await a.call("get_rules_file_request", {});
258
+ await a.call("get_rules_file_request", {});
259
+ const big = "x".repeat(700 * 1024);
260
+ expect(await a.call("ack_rules_file_request", { request_id: first.request_id, status: "done", content: big })).toMatchObject({ ok: true, status: "done" });
261
+ } finally { await u.close(); await a.close(); }
262
+ }, 20_000);
263
+ });
@@ -0,0 +1,281 @@
1
+ // node_rules_requests content retention — privacy purge of node file bytes.
2
+ //
3
+ // Real MCP in-process transport, same harness as rules-file-transport.test.ts.
4
+ // Covers:
5
+ // 1. read result is handed out on the first terminal read, still handed out to
6
+ // a second reader inside the grace window (the desktop app's
7
+ // request_in_flight → follow path), then purged: status + metadata stay,
8
+ // `content_purged: true` replaces content.
9
+ // 2. a caller outside the row's network can neither read nor stamp/purge it.
10
+ // 3. TTL sweep: >24h content cleared (stale pending flipped to timeout first),
11
+ // >30d rows deleted, fresh unread rows untouched.
12
+ // 4. write payload (`content` column) is cleared; skill_read name is kept.
13
+ //
14
+ // 跑法:cd server && COMMHUB_DB=/tmp/nrr.db bun test src/node-request-retention.test.ts
15
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
16
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
17
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
18
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
+ import { db } from "./db.js";
20
+ import { registerTools } from "./tools.js";
21
+ import { CONTENT_GRACE_MS, CONTENT_TTL_MS, ROW_TTL_MS, sweepNodeRequestContent } from "./node-request-retention.js";
22
+ import { sweepRetention } from "./retention.js";
23
+
24
+ const NET = "net_nrr";
25
+ const OTHER_NET = "net_nrr_other";
26
+ const USER = "u_nrr_owner";
27
+ const NODE_ID = "node_nrr_claude";
28
+ const NODE_ALIAS = "nrr-claude";
29
+ const NODE_TOKEN = "tok_nrr_node";
30
+
31
+ function cleanup() {
32
+ for (const net of [NET, OTHER_NET]) {
33
+ try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [net]); } catch {}
34
+ try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [net]); } catch {}
35
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [net]); } catch {}
36
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [net]); } catch {}
37
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [net]); } catch {}
38
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [net]); } catch {}
39
+ }
40
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
41
+ }
42
+
43
+ function seedWorld() {
44
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER, USER]);
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 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)`,
55
+ [NODE_TOKEN, USER, NET, `node:${NODE_ALIAS}`, "hash_nrr_node"],
56
+ );
57
+ }
58
+
59
+ type Identity = { net: string; alias: string; isNetworkToken: boolean; tokenId: string };
60
+ const asUser: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_nrr_user" };
61
+ // Same human, scoped to the OTHER network: passes canWrite there, but the row
62
+ // lives in NET — SEC-1 must answer request_not_found and touch nothing.
63
+ const asUserOtherScope: Identity = { net: OTHER_NET, alias: USER, isNetworkToken: false, tokenId: "tok_nrr_user_other" };
64
+ const asNode: Identity = { net: NET, alias: NODE_ALIAS, isNetworkToken: true, tokenId: NODE_TOKEN };
65
+ // Same user, same network, a different login token: #1999 makes project-file
66
+ // results readable only by the token that asked.
67
+ const asUserOtherLogin: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_nrr_user_other_login" };
68
+
69
+ async function connect(id: Identity) {
70
+ const server = new McpServer({ name: "nrr-test", version: "1" });
71
+ registerTools(server, undefined, id.net, USER, id.alias, id.isNetworkToken, id.tokenId);
72
+ const client = new Client({ name: "nrr-client", version: "1" });
73
+ const [ct, st] = InMemoryTransport.createLinkedPair();
74
+ await server.connect(st);
75
+ await client.connect(ct);
76
+ const call = async (name: string, args: Record<string, unknown>) => {
77
+ const r: any = await client.callTool({ name, arguments: args });
78
+ const first = r.content?.[0];
79
+ if (!first || first.type !== "text") throw new Error(`no text result from ${name}`);
80
+ return JSON.parse(first.text);
81
+ };
82
+ const close = async () => { await client.close(); await server.close(); };
83
+ return { call, close };
84
+ }
85
+
86
+ type Row = { status: string; content: string | null; result_content: string | null; file_name: string | null; first_read_at: number | null; content_purged_at: number | null; op: string };
87
+ const rowOf = (id: string) => db.get<Row>(
88
+ "SELECT status, content, result_content, file_name, first_read_at, content_purged_at, op FROM node_rules_requests WHERE request_id = ?1", id,
89
+ );
90
+ const ageFirstRead = (id: string, ms: number) => db.run("UPDATE node_rules_requests SET first_read_at = first_read_at - ?1 WHERE request_id = ?2", [ms, id]);
91
+ const ageCreated = (id: string, ms: number) => db.run("UPDATE node_rules_requests SET created_at = created_at - ?1 WHERE request_id = ?2", [ms, id]);
92
+
93
+ async function readRoundTrip(u: Awaited<ReturnType<typeof connect>>, n: Awaited<ReturnType<typeof connect>>, body: string) {
94
+ const enq = await u.call("read_node_rules_file", { node_id: NODE_ID });
95
+ expect(enq.ok).toBe(true);
96
+ await n.call("get_rules_file_request", {});
97
+ await n.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "CLAUDE.md", exists: true, content: body });
98
+ return enq.request_id as string;
99
+ }
100
+
101
+ beforeEach(() => { cleanup(); seedWorld(); });
102
+ afterAll(cleanup);
103
+
104
+ describe("node_rules_requests — read once, purge after grace", () => {
105
+ test("content returned on first terminal read and to a follower inside the grace window, then purged", async () => {
106
+ const u = await connect(asUser);
107
+ const n = await connect(asNode);
108
+ try {
109
+ const body = "# secret rules\n";
110
+ const rid = await readRoundTrip(u, n, body);
111
+ expect(rowOf(rid)!.first_read_at).toBeNull();
112
+
113
+ const first = await u.call("get_rules_file_result", { request_id: rid });
114
+ expect(first).toMatchObject({ ok: true, status: "done", content: body, file_name: "CLAUDE.md", exists: true });
115
+ expect(first.content_purged).toBeUndefined();
116
+ const stamped = rowOf(rid)!.first_read_at;
117
+ expect(stamped).not.toBeNull();
118
+
119
+ // Second reader (app follow path) inside the grace window still gets it,
120
+ // and the stamp is NOT pushed forward (a poller can't keep it alive).
121
+ sweepNodeRequestContent();
122
+ const follower = await u.call("get_rules_file_result", { request_id: rid });
123
+ expect(follower.content).toBe(body);
124
+ expect(rowOf(rid)!.first_read_at).toBe(stamped);
125
+
126
+ ageFirstRead(rid, CONTENT_GRACE_MS + 1);
127
+ // Any enqueue runs the opportunistic sweep.
128
+ await u.call("list_node_skills", { node_id: NODE_ID });
129
+ const row = rowOf(rid)!;
130
+ expect(row.result_content).toBeNull();
131
+ expect(row.content_purged_at).not.toBeNull();
132
+ expect(row.status).toBe("done");
133
+ expect(row.file_name).toBe("CLAUDE.md");
134
+
135
+ const after = await u.call("get_rules_file_result", { request_id: rid });
136
+ expect(after).toMatchObject({ ok: true, status: "done", file_name: "CLAUDE.md", exists: true, content_purged: true });
137
+ expect("content" in after).toBe(false);
138
+ } finally { await u.close(); await n.close(); }
139
+ }, 20_000);
140
+
141
+ test("non-terminal polls do not stamp; a never-read result keeps its content until TTL", async () => {
142
+ const u = await connect(asUser);
143
+ const n = await connect(asNode);
144
+ try {
145
+ const enq = await u.call("read_node_rules_file", { node_id: NODE_ID });
146
+ expect((await u.call("get_rules_file_result", { request_id: enq.request_id })).status).toBe("pending");
147
+ expect(rowOf(enq.request_id)!.first_read_at).toBeNull();
148
+ await n.call("get_rules_file_request", {});
149
+ await n.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "CLAUDE.md", exists: true, content: "x" });
150
+ sweepNodeRequestContent(Date.now() + CONTENT_GRACE_MS * 10);
151
+ expect(rowOf(enq.request_id)!.result_content).toBe("x");
152
+ } finally { await u.close(); await n.close(); }
153
+ }, 20_000);
154
+ });
155
+
156
+ describe("node_rules_requests — authorization", () => {
157
+ test("a caller outside the row's network gets request_not_found and cannot stamp or purge it", async () => {
158
+ const u = await connect(asUser);
159
+ const n = await connect(asNode);
160
+ const o = await connect(asUserOtherScope);
161
+ try {
162
+ const rid = await readRoundTrip(u, n, "# mine\n");
163
+ for (let i = 0; i < 3; i++) {
164
+ expect(await o.call("get_rules_file_result", { request_id: rid })).toMatchObject({ ok: false, error: "request_not_found" });
165
+ }
166
+ // Also pass the owner's network_id explicitly — scope is the token's, not the arg's.
167
+ expect((await o.call("get_rules_file_result", { request_id: rid, network_id: NET })).content).toBeUndefined();
168
+ expect(rowOf(rid)!.first_read_at).toBeNull();
169
+ sweepNodeRequestContent(Date.now() + CONTENT_GRACE_MS * 10);
170
+ expect(rowOf(rid)!.result_content).toBe("# mine\n");
171
+ // The rightful requester still gets it.
172
+ expect((await u.call("get_rules_file_result", { request_id: rid })).content).toBe("# mine\n");
173
+ } finally { await u.close(); await n.close(); await o.close(); }
174
+ }, 20_000);
175
+ });
176
+
177
+ describe("node_rules_requests — project files (#1999)", () => {
178
+ test("another login token can't read or stamp a file_read row; the requester's read starts the grace clock; path is kept", async () => {
179
+ const u = await connect(asUser);
180
+ const n = await connect(asNode);
181
+ const other = await connect(asUserOtherLogin);
182
+ try {
183
+ const enq = await u.call("read_node_file", { node_id: NODE_ID, path: "docs/plan.md" });
184
+ expect(enq.ok).toBe(true);
185
+ await n.call("get_rules_file_request", {});
186
+ const body = JSON.stringify({ path: "docs/plan.md", kind: "text", content: "TOP SECRET PLAN" });
187
+ await n.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: body });
188
+
189
+ expect(await other.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: false, error: "request_not_found" });
190
+ expect(rowOf(enq.request_id)!.first_read_at).toBeNull();
191
+
192
+ expect((await u.call("get_rules_file_result", { request_id: enq.request_id })).content).toBe(body);
193
+ ageFirstRead(enq.request_id, CONTENT_GRACE_MS + 1);
194
+ sweepNodeRequestContent();
195
+ const row = rowOf(enq.request_id)!;
196
+ expect(row.result_content).toBeNull();
197
+ expect(row.content).toBe("docs/plan.md");
198
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "done", content_purged: true });
199
+ } finally { await u.close(); await n.close(); await other.close(); }
200
+ }, 20_000);
201
+ });
202
+
203
+ describe("node_rules_requests — write payload", () => {
204
+ test("write body in `content` is cleared after the grace window; skill_read name is kept", async () => {
205
+ const u = await connect(asUser);
206
+ const n = await connect(asNode);
207
+ try {
208
+ const body = "# new rules with a secret\n";
209
+ const w = await u.call("write_node_rules_file", { node_id: NODE_ID, content: body });
210
+ await n.call("get_rules_file_request", {});
211
+ await n.call("ack_rules_file_request", { request_id: w.request_id, status: "done", file_name: "CLAUDE.md", exists: true });
212
+ expect(rowOf(w.request_id)!.content).toBe(body);
213
+
214
+ const s = await u.call("read_node_skill", { node_id: NODE_ID, name: "my-skill" });
215
+ await n.call("get_rules_file_request", {});
216
+ await n.call("ack_rules_file_request", { request_id: s.request_id, status: "done", content: JSON.stringify({ name: "my-skill", content: "SKILL BODY" }) });
217
+
218
+ const wr = await u.call("get_rules_file_result", { request_id: w.request_id });
219
+ expect(wr).toMatchObject({ status: "done", op: "write" });
220
+ expect(wr.content).toBeUndefined();
221
+ expect((await u.call("get_rules_file_result", { request_id: s.request_id })).content).toContain("SKILL BODY");
222
+
223
+ ageFirstRead(w.request_id, CONTENT_GRACE_MS + 1);
224
+ ageFirstRead(s.request_id, CONTENT_GRACE_MS + 1);
225
+ sweepNodeRequestContent();
226
+
227
+ const wRow = rowOf(w.request_id)!;
228
+ expect(wRow.content).toBeNull();
229
+ expect(wRow.content_purged_at).not.toBeNull();
230
+ expect(wRow.status).toBe("done");
231
+ const sRow = rowOf(s.request_id)!;
232
+ expect(sRow.result_content).toBeNull();
233
+ expect(sRow.content).toBe("my-skill");
234
+ expect(await u.call("get_rules_file_result", { request_id: w.request_id })).toMatchObject({ status: "done", op: "write", content_purged: true });
235
+ } finally { await u.close(); await n.close(); }
236
+ }, 20_000);
237
+ });
238
+
239
+ describe("node_rules_requests — TTL sweep", () => {
240
+ test(">24h content cleared (stale pending write expired first, so no node can pull an emptied write); >30d rows deleted; fresh rows kept", async () => {
241
+ const u = await connect(asUser);
242
+ const n = await connect(asNode);
243
+ try {
244
+ // Create everything first, age afterwards: every enqueue runs the sweep.
245
+ const oldRead = await readRoundTrip(u, n, "# old, never fetched\n");
246
+ const ancient = await readRoundTrip(u, n, "# ancient\n");
247
+ const fresh = await readRoundTrip(u, n, "# fresh, never fetched\n");
248
+ // A write the node never pulled (offline for a day). Enqueued last so
249
+ // single-flight doesn't refuse it.
250
+ const w = await u.call("write_node_rules_file", { node_id: NODE_ID, content: "# stale write body\n" });
251
+ expect(w.ok).toBe(true);
252
+ ageCreated(oldRead, CONTENT_TTL_MS + 60_000);
253
+ ageCreated(ancient, ROW_TTL_MS + 60_000);
254
+ ageCreated(w.request_id, CONTENT_TTL_MS + 60_000);
255
+
256
+ const r = sweepRetention();
257
+ expect(r.nodeRequests.deleted).toBe(1);
258
+ expect(r.nodeRequests.timedOut).toBe(1);
259
+ expect(r.nodeRequests.purged).toBe(2);
260
+
261
+ expect(rowOf(ancient)).toBeNull();
262
+ const o = rowOf(oldRead)!;
263
+ expect(o.result_content).toBeNull();
264
+ expect(o.status).toBe("done");
265
+ expect(o.file_name).toBe("CLAUDE.md");
266
+ expect(rowOf(fresh)!.result_content).toBe("# fresh, never fetched\n");
267
+
268
+ const wr = rowOf(w.request_id)!;
269
+ expect(wr.status).toBe("timeout");
270
+ expect(wr.content).toBeNull();
271
+ // The node coming back must not receive the emptied write.
272
+ expect(await n.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
273
+
274
+ expect(await u.call("get_rules_file_result", { request_id: oldRead })).toMatchObject({ status: "done", content_purged: true });
275
+
276
+ // Idempotent.
277
+ const again = sweepNodeRequestContent();
278
+ expect(again).toEqual({ timedOut: 0, purged: 0, deleted: 0 });
279
+ } finally { await u.close(); await n.close(); }
280
+ }, 20_000);
281
+ });