@sleep2agi/commhub-server 0.9.0-preview.8 → 0.9.0-preview.81

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 (203) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -11
  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 +5 -5
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access-batch.test.ts +210 -0
  9. package/src/agent-access.ts +478 -0
  10. package/src/agent-acl-groups-http.test.ts +247 -0
  11. package/src/agent-acl-http.test.ts +520 -0
  12. package/src/agent-acl-slice3-http.test.ts +267 -0
  13. package/src/agent-groups-perf.test.ts +190 -0
  14. package/src/alias-filter.test.ts +72 -0
  15. package/src/alias-filter.ts +32 -0
  16. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  17. package/src/api-host-supervisors-fallback.test.ts +271 -0
  18. package/src/api-nodes-shape.test.ts +26 -10
  19. package/src/auth-sessions-http.test.ts +250 -0
  20. package/src/auth-validate.test.ts +5 -1
  21. package/src/auth.ts +450 -31
  22. package/src/avatar-validate.test.ts +163 -0
  23. package/src/avatar-validate.ts +103 -0
  24. package/src/blocked-exit-on-reply.test.ts +122 -0
  25. package/src/boot-side-effect.test.ts +124 -0
  26. package/src/commhub-bin-args.test.ts +85 -0
  27. package/src/config-apply-sec1.test.ts +107 -0
  28. package/src/config-apply-validate.test.ts +92 -0
  29. package/src/config-apply-validate.ts +81 -3
  30. package/src/create-node-blocked-gate.test.ts +356 -0
  31. package/src/create-node-tool-schema.test.ts +39 -0
  32. package/src/create-node-validate.test.ts +89 -1
  33. package/src/create-node-validate.ts +89 -7
  34. package/src/create-node-workdir.test.ts +255 -0
  35. package/src/create-node.ts +46 -1
  36. package/src/dashboard-slash-routing-http.test.ts +107 -0
  37. package/src/db-adapter-guard.test.ts +148 -0
  38. package/src/db-adapter-translate.test.ts +128 -0
  39. package/src/db-adapter.ts +323 -87
  40. package/src/db-timestamp.test.ts +38 -0
  41. package/src/db-timestamp.ts +15 -0
  42. package/src/db.ts +805 -33
  43. package/src/delivered-without-push.test.ts +140 -0
  44. package/src/desktop-message-seam.test.ts +150 -0
  45. package/src/desktop-message.test.ts +368 -0
  46. package/src/dm-file-scope-http.test.ts +178 -0
  47. package/src/ext-token-shared.test.ts +60 -0
  48. package/src/ext-validation-regression.test.ts +131 -0
  49. package/src/external-schedule-edits-http.test.ts +321 -0
  50. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  51. package/src/external-schedule-edits.ts +327 -0
  52. package/src/file-download-authz-dev-open.test.ts +135 -0
  53. package/src/file-download-authz.test.ts +404 -0
  54. package/src/file-download-date-bucket.test.ts +436 -0
  55. package/src/file-network-scope-dev-open.test.ts +97 -0
  56. package/src/file-network-scope.test.ts +732 -0
  57. package/src/health-redaction.test.ts +642 -0
  58. package/src/http-gzip.test.ts +94 -0
  59. package/src/http-gzip.ts +110 -0
  60. package/src/hub-timestamp.test.ts +38 -0
  61. package/src/hub-timestamp.ts +27 -0
  62. package/src/human-dm.ts +215 -0
  63. package/src/inbox-count-sse.test.ts +310 -0
  64. package/src/inbox-count.ts +29 -0
  65. package/src/index.ts +15 -2331
  66. package/src/lifecycle-guard.ts +50 -0
  67. package/src/mcp-route-label-http.test.ts +120 -0
  68. package/src/mcp-write-network-resolution.test.ts +429 -0
  69. package/src/member-audit-network-id-http.test.ts +93 -0
  70. package/src/member-presence-http.test.ts +206 -0
  71. package/src/messages-ack-by-agent-http.test.ts +142 -0
  72. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  73. package/src/migrate-sqlite-to-pg.ts +263 -0
  74. package/src/network-name-http.test.ts +75 -0
  75. package/src/network-scope.ts +224 -0
  76. package/src/node-attrs-validate.ts +88 -0
  77. package/src/node-attrs.test.ts +217 -0
  78. package/src/node-create-request-status-http.test.ts +92 -0
  79. package/src/node-files-transport.test.ts +344 -0
  80. package/src/node-id-alias.test.ts +188 -0
  81. package/src/node-lifecycle-controllable.test.ts +64 -0
  82. package/src/node-lifecycle-controllable.ts +51 -0
  83. package/src/node-logs-transport.test.ts +263 -0
  84. package/src/node-request-retention.test.ts +281 -0
  85. package/src/node-request-retention.ts +120 -0
  86. package/src/node-skills-transport.test.ts +209 -0
  87. package/src/node-token-privilege-http.test.ts +149 -0
  88. package/src/observer-avatar-http.test.ts +429 -0
  89. package/src/observer-push.test.ts +252 -0
  90. package/src/package-version-consistency.test.ts +25 -0
  91. package/src/peer-reply-atomic.test.ts +455 -0
  92. package/src/peer-reply-capability.test.ts +70 -0
  93. package/src/pg-worker.ts +87 -0
  94. package/src/project-viewer-can-http.test.ts +114 -0
  95. package/src/push.ts +284 -7
  96. package/src/redact-tokens.test.ts +65 -0
  97. package/src/redact-tokens.ts +40 -0
  98. package/src/rename.ts +7 -2
  99. package/src/reply-target-derivation.test.ts +96 -0
  100. package/src/report-status-host-schema.test.ts +211 -0
  101. package/src/report-status-resume-id-handover.test.ts +150 -0
  102. package/src/require-explicit-test-db.ts +9 -0
  103. package/src/requirement-events-http.test.ts +257 -0
  104. package/src/requirement-events.test.ts +45 -0
  105. package/src/requirement-events.ts +143 -0
  106. package/src/requirement-notify.ts +134 -0
  107. package/src/requirement-participant-notify-http.test.ts +309 -0
  108. package/src/requirement-tag-ops-http.test.ts +266 -0
  109. package/src/requirement-tags.ts +89 -0
  110. package/src/requirements-completed-at-migration.test.ts +70 -0
  111. package/src/requirements-etag-http.test.ts +91 -0
  112. package/src/requirements-http.test.ts +709 -0
  113. package/src/requirements-list-slim-http.test.ts +315 -0
  114. package/src/requirements-mcp.test.ts +163 -0
  115. package/src/requirements-migrate.ts +225 -0
  116. package/src/requirements-people-http.test.ts +114 -0
  117. package/src/requirements-priority-lowest.test.ts +114 -0
  118. package/src/requirements-search-http.test.ts +182 -0
  119. package/src/requirements-search.test.ts +32 -0
  120. package/src/requirements-search.ts +138 -0
  121. package/src/requirements-seq-http.test.ts +193 -0
  122. package/src/requirements-seq-migration.test.ts +63 -0
  123. package/src/requirements-start-date.test.ts +125 -0
  124. package/src/requirements-stats-http.test.ts +288 -0
  125. package/src/requirements-stats.ts +164 -0
  126. package/src/requirements-upgrade-e2e.test.ts +126 -0
  127. package/src/requirements-upgrade-migration.test.ts +98 -0
  128. package/src/requirements.ts +1443 -0
  129. package/src/resolve-port.test.ts +57 -0
  130. package/src/resolve-port.ts +44 -0
  131. package/src/response-charset.test.ts +130 -0
  132. package/src/rest-explicit-columns-http.test.ts +216 -0
  133. package/src/rest-identity.test.ts +129 -0
  134. package/src/rest-identity.ts +100 -0
  135. package/src/rest-projections.ts +68 -0
  136. package/src/rest-write-network-resolution.test.ts +133 -0
  137. package/src/restricted-files.ts +73 -0
  138. package/src/retention.ts +15 -3
  139. package/src/route-callers-http.test.ts +120 -0
  140. package/src/route-timing.test.ts +211 -0
  141. package/src/route-timing.ts +258 -0
  142. package/src/rules-file-duplicate-alias.test.ts +234 -0
  143. package/src/rules-file-session-target.test.ts +207 -0
  144. package/src/rules-file-transport.test.ts +237 -0
  145. package/src/runtime-label.test.ts +22 -0
  146. package/src/runtime-label.ts +21 -0
  147. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  148. package/src/scheduled-reply-unread-http.test.ts +212 -0
  149. package/src/scheduled-run-terminal.test.ts +268 -0
  150. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  151. package/src/scheduled-tasks-http.test.ts +638 -0
  152. package/src/scheduled-tasks.ts +597 -0
  153. package/src/send-dedup-request-id.test.ts +229 -0
  154. package/src/send-reply-agent-warning.test.ts +202 -0
  155. package/src/send-reply-alias-optional.test.ts +131 -0
  156. package/src/send-reply-attachments.test.ts +458 -0
  157. package/src/send_dedup.test.ts +54 -0
  158. package/src/send_dedup.ts +52 -6
  159. package/src/serve-error.test.ts +38 -0
  160. package/src/serve-error.ts +27 -0
  161. package/src/server.ts +4522 -0
  162. package/src/shared/external-schedule-contract.ts +60 -0
  163. package/src/side-thread-command-transport.test.ts +312 -0
  164. package/src/side-thread-command-transport.ts +318 -0
  165. package/src/side-thread-http-integration.test.ts +233 -0
  166. package/src/side-thread-http.ts +504 -0
  167. package/src/side-thread-production.test.ts +72 -0
  168. package/src/side-thread-production.ts +118 -0
  169. package/src/side-thread.test.ts +1067 -0
  170. package/src/side-thread.ts +2499 -0
  171. package/src/skillhub-http.test.ts +108 -0
  172. package/src/sqlite-close-checkpoint.test.ts +78 -0
  173. package/src/start-node.test.ts +109 -0
  174. package/src/status-alias-filter-http.test.ts +106 -0
  175. package/src/status-node-id-filter-http.test.ts +161 -0
  176. package/src/stop-delete-node.test.ts +1164 -0
  177. package/src/task-access-http.test.ts +379 -0
  178. package/src/task-access.ts +317 -0
  179. package/src/task-auth-origin-http.test.ts +91 -0
  180. package/src/task-auth-origin.test.ts +24 -0
  181. package/src/task-auth-origin.ts +7 -0
  182. package/src/task-consumption.test.ts +482 -0
  183. package/src/task-diagnostic-http.test.ts +149 -0
  184. package/src/task-diagnostic.test.ts +115 -0
  185. package/src/task-diagnostic.ts +118 -0
  186. package/src/task-idempotency-mcp.test.ts +142 -0
  187. package/src/task-idempotency.test.ts +60 -0
  188. package/src/task-idempotency.ts +75 -0
  189. package/src/task-lifecycle-watcher.test.ts +204 -0
  190. package/src/task-lifecycle-watcher.ts +49 -0
  191. package/src/task-network-resolution-http.test.ts +141 -0
  192. package/src/tasks-pagination-http.test.ts +141 -0
  193. package/src/tools-list-json-schema.test.ts +66 -0
  194. package/src/tools.ts +3534 -343
  195. package/src/update-provider.test.ts +65 -0
  196. package/src/upload-early-reject-guard.test.ts +155 -0
  197. package/src/uploads-http.test.ts +97 -15
  198. package/src/uploads.test.ts +66 -0
  199. package/src/uploads.ts +138 -5
  200. package/src/user-inbox-by-agent-http.test.ts +161 -0
  201. package/src/user-inbox-netid-notnull.test.ts +122 -0
  202. package/src/user-inbox-read-http.test.ts +223 -0
  203. package/src/user-inbox-schema.test.ts +54 -0
@@ -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
+ });
@@ -0,0 +1,120 @@
1
+ // node_rules_requests content retention (privacy).
2
+ //
3
+ // The rules-file / skills (and project-folder) queue carries **node file
4
+ // contents** through the hub: `result_content` holds what the node sent back
5
+ // (CLAUDE.md / AGENTS.md, SKILL.md, directory listings, file bodies) and
6
+ // `content` holds write payloads (the full new rules file). Before this module
7
+ // nothing ever cleared or deleted those rows — every file anyone ever opened in
8
+ // the desktop app stayed in the hub DB forever.
9
+ //
10
+ // Policy (metadata — status, op, file_name, file_exists, error, timestamps,
11
+ // requester — is kept for audit; only file bytes are dropped):
12
+ //
13
+ // 1. Read-once + grace: the first time an authorized requester reads a
14
+ // TERMINAL result (done/failed/timeout) the row is stamped `first_read_at`.
15
+ // CONTENT_GRACE_MS later the content is purged. Not immediate, because the
16
+ // desktop app has a real second-reader path: when enqueue is rejected with
17
+ // `request_in_flight` the app FOLLOWS the existing request id
18
+ // (requestIdToFollow → waitForRulesFileResult, NodeRulesSection /
19
+ // NodeSkillsSection / NodeFilesSection). Two windows (or a remount) polling
20
+ // the same request reach terminal within one poll interval (≤ 3 s) of each
21
+ // other; an immediate purge would hand the second one `done` with no
22
+ // content and NodeRulesSection would load an EMPTY editor (`content ?? ''`)
23
+ // that a Save would then write back to the node. 60 s is 20× the slowest
24
+ // poll interval.
25
+ // 2. TTL: anything older than CONTENT_TTL_MS gets its content purged whether
26
+ // or not anyone ever read it (e.g. the app was closed mid-request). A
27
+ // still-pending/in_progress row that old is first flipped to `timeout`, so
28
+ // a node that comes back after a day can never pull a write whose payload
29
+ // was just nulled (it would otherwise receive `content: ""` and write an
30
+ // empty rules file).
31
+ // 3. Rows older than ROW_TTL_MS are deleted outright.
32
+ // 4. Run logs (op = logs_tail) are stricter: read-once with NO grace — the
33
+ // first authorized terminal read purges them in the same call
34
+ // (purgeLogsResultNow), because the app never follows another request's
35
+ // logs result (it simply asks again) and a process log is the most
36
+ // sensitive thing on this queue. Unread terminal results are purged after
37
+ // LOGS_CONTENT_TTL_MS (the requester closed the page). The filter JSON in
38
+ // `content` (it can hold a user-typed grep) goes with it.
39
+ //
40
+ // Every statement is bounded (LIMIT via `request_id IN (SELECT … LIMIT n)` —
41
+ // SQLite's UPDATE/DELETE … LIMIT needs a compile flag we don't control) and
42
+ // uses an index (idx_nrr_created / idx_nrr_first_read). It runs
43
+ // opportunistically on enqueue and on terminal reads, from a one-shot timer
44
+ // armed at the first terminal read, and from the hourly retention sweeper
45
+ // (retention.ts) — so the first sweep after deploy also clears every old row
46
+ // that predates this module.
47
+
48
+ import { db } from "./db.js";
49
+
50
+ export const CONTENT_GRACE_MS = 60_000;
51
+ export const CONTENT_TTL_MS = 24 * 60 * 60 * 1000;
52
+ export const ROW_TTL_MS = 30 * 24 * 60 * 60 * 1000;
53
+ export const SWEEP_BATCH = 500;
54
+ export const LOGS_CONTENT_TTL_MS = 5 * 60 * 1000;
55
+
56
+ export type NodeRequestSweepResult = { timedOut: number; purged: number; deleted: number };
57
+
58
+ // `content` is only a file body for op=write. For skill_read / file_read it is
59
+ // the skill name / relative path the requester asked for — audit metadata, kept.
60
+ // For logs_tail it is the filter JSON, which may carry a user-typed grep — purged.
61
+ const PURGE_SET =
62
+ "result_content = NULL, content = CASE WHEN op IN ('write', 'logs_tail') THEN NULL ELSE content END, content_purged_at = ?1";
63
+
64
+ function purgeWhere(where: string, params: (string | number)[], now: number): number {
65
+ const r = db.run(
66
+ `UPDATE node_rules_requests SET ${PURGE_SET} WHERE request_id IN (SELECT request_id FROM node_rules_requests WHERE content_purged_at IS NULL AND ${where} LIMIT ${SWEEP_BATCH})`,
67
+ [now, ...params],
68
+ );
69
+ return r.changes ?? 0;
70
+ }
71
+
72
+ export function sweepNodeRequestContent(now: number = Date.now()): NodeRequestSweepResult {
73
+ const out: NodeRequestSweepResult = { timedOut: 0, purged: 0, deleted: 0 };
74
+ try {
75
+ // Delete first so rows past the row TTL aren't pointlessly purged, then
76
+ // expire stale non-terminal rows, then purge.
77
+ out.deleted = db.run(
78
+ `DELETE FROM node_rules_requests WHERE request_id IN (SELECT request_id FROM node_rules_requests WHERE created_at < ?1 LIMIT ${SWEEP_BATCH})`,
79
+ [now - ROW_TTL_MS],
80
+ ).changes ?? 0;
81
+ const ttlCutoff = now - CONTENT_TTL_MS;
82
+ out.timedOut = db.run(
83
+ `UPDATE node_rules_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id IN (SELECT request_id FROM node_rules_requests WHERE created_at < ?3 AND status IN ('pending', 'in_progress') LIMIT ${SWEEP_BATCH})`,
84
+ [now, "expired by retention sweep (node never answered)", ttlCutoff],
85
+ ).changes ?? 0;
86
+ // ?1 = now (PURGE_SET), ?2… = where params.
87
+ out.purged += purgeWhere("first_read_at IS NOT NULL AND first_read_at <= ?2 AND status IN ('done', 'failed', 'timeout')", [now - CONTENT_GRACE_MS], now);
88
+ out.purged += purgeWhere("created_at < ?2 AND status IN ('done', 'failed', 'timeout')", [ttlCutoff], now);
89
+ out.purged += purgeWhere("op = 'logs_tail' AND created_at < ?2 AND status IN ('done', 'failed', 'timeout')", [now - LOGS_CONTENT_TTL_MS], now);
90
+ } catch (e: any) {
91
+ console.log(`[commhub retention] node_rules_requests sweep failed: ${e?.message ?? e}`);
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /** Called by get_rules_file_result AFTER the requester passed every auth check
97
+ * and only for a terminal status. Stamps the first terminal read, arms a
98
+ * one-shot purge for after the grace window, and reports whether this row's
99
+ * content is already gone. */
100
+ export function noteTerminalResultRead(requestId: string, now: number = Date.now()): { purged: boolean } {
101
+ const r = db.run(
102
+ "UPDATE node_rules_requests SET first_read_at = ?1 WHERE request_id = ?2 AND first_read_at IS NULL",
103
+ [now, requestId],
104
+ );
105
+ if ((r.changes ?? 0) > 0) {
106
+ const t = setTimeout(() => { sweepNodeRequestContent(); }, CONTENT_GRACE_MS + 1_000);
107
+ (t as any).unref?.();
108
+ }
109
+ const row = db.get<{ content_purged_at: number | null }>(
110
+ "SELECT content_purged_at FROM node_rules_requests WHERE request_id = ?1",
111
+ requestId,
112
+ );
113
+ return { purged: row?.content_purged_at != null };
114
+ }
115
+
116
+ /** Run-log results are read-once: called by get_rules_file_result right after it
117
+ * handed a terminal logs_tail result to its (authorized) requester. */
118
+ export function purgeLogsResultNow(requestId: string, now: number = Date.now()): void {
119
+ db.run(`UPDATE node_rules_requests SET ${PURGE_SET} WHERE request_id = ?2 AND op = 'logs_tail' AND content_purged_at IS NULL`, [now, requestId]);
120
+ }
@@ -0,0 +1,209 @@
1
+ // Node skills view — list_node_skills / read_node_skill ride the rules-file
2
+ // queue + doorbell (ops skills_list / skill_read).
3
+ //
4
+ // Hub-side contract verified here:
5
+ // 1. report_status { skills_capable: true } is sticky and set only by the
6
+ // session's own bound node token.
7
+ // 2. list_node_skills / read_node_skill reach a session with no nodes row by
8
+ // alias; the node pulls with its own token; read carries the skill NAME
9
+ // (never a path) in the pulled request; results come back through
10
+ // get_rules_file_result for both ops.
11
+ // 3. Bad skill names are refused before any request row exists.
12
+ // 4. A session that can serve rules files but did not report skills_capable is
13
+ // refused up front for skills ops (skills_target_not_found).
14
+ // 5. Separate single-flight lanes: a pending rules read does not block a skills
15
+ // list and vice versa; a second skills request while one is pending is refused.
16
+ // 6. Network isolation and foreign acks are ignored.
17
+ //
18
+ // 跑法:cd server && COMMHUB_DB=/tmp/skills-session.db bun test src/node-skills-transport.test.ts
19
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
20
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
21
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
22
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
23
+ import { db } from "./db.js";
24
+ import { registerTools } from "./tools.js";
25
+
26
+ const NET = "net_skills_session";
27
+ const OTHER_NET = "net_skills_session_other";
28
+ const USER = "u_skills_session_owner";
29
+ const SESSION_ALIAS = "skills-session-cc";
30
+ const SESSION_RESUME = "cc-skills-session-1";
31
+ const OTHER_ALIAS = "skills-session-peer";
32
+ const NODE_ROW_ALIAS = "skills-session-noderow";
33
+ const NODE_ROW_ID = "node_skills_session_noderow";
34
+ const T_SESSION = "tok_skills_session_cc";
35
+ const T_PEER = "tok_skills_session_peer";
36
+ const T_OTHER_NET_SAME_ALIAS = "tok_skills_session_other_net";
37
+ const T_NODE_ROW = "tok_skills_session_noderow";
38
+
39
+ function cleanup() {
40
+ for (const net of [NET, OTHER_NET]) {
41
+ try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [net]); } catch {}
42
+ try { db.run("DELETE FROM sessions WHERE network_id = ?1", [net]); } catch {}
43
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [net]); } catch {}
44
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [net]); } catch {}
45
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [net]); } catch {}
46
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [net]); } catch {}
47
+ }
48
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
49
+ }
50
+
51
+ function seedWorld() {
52
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER, USER]);
53
+ for (const net of [NET, OTHER_NET]) {
54
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [net, net, USER]);
55
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER, net]);
56
+ }
57
+ const tok = (id: string, net: string, alias: string) => 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
+ [id, USER, net, `node:${alias}`, `hash_${id}`],
60
+ );
61
+ tok(T_SESSION, NET, SESSION_ALIAS);
62
+ tok(T_PEER, NET, OTHER_ALIAS);
63
+ tok(T_OTHER_NET_SAME_ALIAS, OTHER_NET, SESSION_ALIAS);
64
+ tok(T_NODE_ROW, NET, NODE_ROW_ALIAS);
65
+ db.run(
66
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
67
+ [NODE_ROW_ID, NODE_ROW_ALIAS, NODE_ROW_ALIAS, NET],
68
+ );
69
+ }
70
+
71
+ type Identity = { net: string; alias: string; isNetworkToken: boolean; tokenId: string };
72
+ const asUser: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_skills_session_user" };
73
+ const asOtherNetUser: Identity = { net: OTHER_NET, alias: USER, isNetworkToken: false, tokenId: "tok_skills_session_user2" };
74
+ const asSession: Identity = { net: NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_SESSION };
75
+ const asPeer: Identity = { net: NET, alias: OTHER_ALIAS, isNetworkToken: true, tokenId: T_PEER };
76
+ const asOtherNetSameAlias: Identity = { net: OTHER_NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_OTHER_NET_SAME_ALIAS };
77
+ const asNodeRow: Identity = { net: NET, alias: NODE_ROW_ALIAS, isNetworkToken: true, tokenId: T_NODE_ROW };
78
+
79
+ async function connect(id: Identity) {
80
+ const server = new McpServer({ name: "skills-session-test", version: "1" });
81
+ registerTools(server, undefined, id.net, USER, id.alias, id.isNetworkToken, id.tokenId);
82
+ const client = new Client({ name: "skills-session-client", version: "1" });
83
+ const [ct, st] = InMemoryTransport.createLinkedPair();
84
+ await server.connect(st);
85
+ await client.connect(ct);
86
+ const call = async (name: string, args: Record<string, unknown>) => {
87
+ const r: any = await client.callTool({ name, arguments: args });
88
+ const first = r.content?.[0];
89
+ if (!first || first.type !== "text") throw new Error(`no text result from ${name}: ${JSON.stringify(r).slice(0, 300)}`);
90
+ return JSON.parse(first.text);
91
+ };
92
+ const close = async () => { await client.close(); await server.close(); };
93
+ return { call, close };
94
+ }
95
+
96
+ const capable = (alias: string, net = NET) =>
97
+ db.get<{ c: number }>("SELECT skills_capable AS c FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, net)?.c;
98
+ const rowCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM node_rules_requests WHERE network_id = ?1", NET)!.n;
99
+
100
+ async function registerSession(id: Identity, alias: string, resume: string, flag: boolean) {
101
+ const c = await connect(id);
102
+ try {
103
+ return await c.call("report_status", { resume_id: resume, alias, status: "idle", agent: "claude-code", ...(flag ? { rules_file_capable: true, skills_capable: true } : {}) });
104
+ } finally { await c.close(); }
105
+ }
106
+
107
+ beforeEach(() => { cleanup(); seedWorld(); });
108
+ afterAll(cleanup);
109
+
110
+ async function reportRulesOnly(id: Identity, alias: string, resume: string) {
111
+ const c = await connect(id);
112
+ try { return await c.call("report_status", { resume_id: resume, alias, status: "idle", agent: "claude-code", rules_file_capable: true }); }
113
+ finally { await c.close(); }
114
+ }
115
+
116
+ describe("skills_capable is set only by the session's own node token", () => {
117
+ test("bound token sets it; a report without it does not clear it; a peer cannot set it", async () => {
118
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
119
+ expect(capable(SESSION_ALIAS)).toBe(1);
120
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, false);
121
+ expect(capable(SESSION_ALIAS)).toBe(1);
122
+ const r = await registerSession(asPeer, SESSION_ALIAS, SESSION_RESUME, true);
123
+ expect(r).toMatchObject({ ok: false });
124
+ }, 20_000);
125
+ });
126
+
127
+ describe("list / read by alias reach a session with no nodes row", () => {
128
+ test("list round trip: enqueue → pull (no content) → ack JSON → result carries it", async () => {
129
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
130
+ const u = await connect(asUser);
131
+ const s = await connect(asSession);
132
+ try {
133
+ const enq = await u.call("list_node_skills", { alias: SESSION_ALIAS });
134
+ expect(enq).toMatchObject({ ok: true, op: "skills_list" });
135
+ const pulled = await s.call("get_rules_file_request", {});
136
+ expect(pulled.request).toEqual({ request_id: enq.request_id, op: "skills_list" });
137
+ const payload = JSON.stringify({ skills: [{ name: "ship", scope: "project", path_rel: ".claude/skills/ship/SKILL.md", description: "d" }] });
138
+ expect(await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "skills", exists: true, content: payload })).toMatchObject({ ok: true, status: "done" });
139
+ const res = await u.call("get_rules_file_result", { request_id: enq.request_id });
140
+ expect(res).toMatchObject({ ok: true, op: "skills_list", status: "done", content: payload });
141
+ } finally { await u.close(); await s.close(); }
142
+ }, 20_000);
143
+
144
+ test("read round trip: the pulled request carries the skill name only", async () => {
145
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
146
+ const u = await connect(asUser);
147
+ const s = await connect(asSession);
148
+ try {
149
+ const enq = await u.call("read_node_skill", { alias: SESSION_ALIAS, name: "ship" });
150
+ expect(enq).toMatchObject({ ok: true, op: "skill_read" });
151
+ const pulled = await s.call("get_rules_file_request", {});
152
+ expect(pulled.request).toEqual({ request_id: enq.request_id, op: "skill_read", content: "ship" });
153
+ const payload = JSON.stringify({ name: "ship", scope: "project", path_rel: ".claude/skills/ship/SKILL.md", description: "d", content: "# ship" });
154
+ await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "ship", exists: true, content: payload });
155
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "done", content: payload });
156
+ } finally { await u.close(); await s.close(); }
157
+ }, 20_000);
158
+
159
+ test("bad names are refused before any row is written", async () => {
160
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
161
+ const u = await connect(asUser);
162
+ try {
163
+ for (const name of ["..", ".", "../CLAUDE.md", "a/b", "a b"]) {
164
+ expect(await u.call("read_node_skill", { alias: SESSION_ALIAS, name })).toMatchObject({ ok: false, error: "invalid_skill_name" });
165
+ }
166
+ expect(rowCount()).toBe(0);
167
+ } finally { await u.close(); }
168
+ }, 20_000);
169
+
170
+ test("rules-capable but not skills-capable → skills_target_not_found, no row", async () => {
171
+ await reportRulesOnly(asSession, SESSION_ALIAS, SESSION_RESUME);
172
+ const u = await connect(asUser);
173
+ try {
174
+ expect(await u.call("list_node_skills", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "skills_target_not_found" });
175
+ expect(rowCount()).toBe(0);
176
+ expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: true });
177
+ } finally { await u.close(); }
178
+ }, 20_000);
179
+ });
180
+
181
+ describe("single-flight lanes", () => {
182
+ test("a pending rules read does not block a skills list; a second skills request is refused", async () => {
183
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
184
+ const u = await connect(asUser);
185
+ try {
186
+ expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: true });
187
+ const list = await u.call("list_node_skills", { alias: SESSION_ALIAS });
188
+ expect(list).toMatchObject({ ok: true });
189
+ expect(await u.call("read_node_skill", { alias: SESSION_ALIAS, name: "ship" })).toMatchObject({ ok: false, error: "request_in_flight", existing_request_id: list.request_id });
190
+ expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "request_in_flight" });
191
+ } finally { await u.close(); }
192
+ }, 20_000);
193
+ });
194
+
195
+ describe("isolation", () => {
196
+ test("same alias in another network neither pulls nor acks; a peer's ack is ignored", async () => {
197
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
198
+ const u = await connect(asUser);
199
+ const other = await connect(asOtherNetSameAlias);
200
+ const peer = await connect(asPeer);
201
+ try {
202
+ const enq = await u.call("list_node_skills", { alias: SESSION_ALIAS });
203
+ expect((await other.call("get_rules_file_request", {})).request).toBeNull();
204
+ expect(await other.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{}" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
205
+ expect(await peer.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{}" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
206
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
207
+ } finally { await u.close(); await other.close(); await peer.close(); }
208
+ }, 20_000);
209
+ });