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

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 (202) 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/push.ts +284 -7
  95. package/src/redact-tokens.test.ts +65 -0
  96. package/src/redact-tokens.ts +40 -0
  97. package/src/rename.ts +7 -2
  98. package/src/reply-target-derivation.test.ts +96 -0
  99. package/src/report-status-host-schema.test.ts +211 -0
  100. package/src/report-status-resume-id-handover.test.ts +150 -0
  101. package/src/require-explicit-test-db.ts +9 -0
  102. package/src/requirement-events-http.test.ts +257 -0
  103. package/src/requirement-events.test.ts +45 -0
  104. package/src/requirement-events.ts +143 -0
  105. package/src/requirement-notify.ts +134 -0
  106. package/src/requirement-participant-notify-http.test.ts +309 -0
  107. package/src/requirement-tag-ops-http.test.ts +266 -0
  108. package/src/requirement-tags.ts +89 -0
  109. package/src/requirements-completed-at-migration.test.ts +70 -0
  110. package/src/requirements-etag-http.test.ts +91 -0
  111. package/src/requirements-http.test.ts +709 -0
  112. package/src/requirements-list-slim-http.test.ts +315 -0
  113. package/src/requirements-mcp.test.ts +163 -0
  114. package/src/requirements-migrate.ts +225 -0
  115. package/src/requirements-people-http.test.ts +114 -0
  116. package/src/requirements-priority-lowest.test.ts +114 -0
  117. package/src/requirements-search-http.test.ts +182 -0
  118. package/src/requirements-search.test.ts +32 -0
  119. package/src/requirements-search.ts +138 -0
  120. package/src/requirements-seq-http.test.ts +193 -0
  121. package/src/requirements-seq-migration.test.ts +63 -0
  122. package/src/requirements-start-date.test.ts +125 -0
  123. package/src/requirements-stats-http.test.ts +288 -0
  124. package/src/requirements-stats.ts +164 -0
  125. package/src/requirements-upgrade-e2e.test.ts +126 -0
  126. package/src/requirements-upgrade-migration.test.ts +98 -0
  127. package/src/requirements.ts +1439 -0
  128. package/src/resolve-port.test.ts +57 -0
  129. package/src/resolve-port.ts +44 -0
  130. package/src/response-charset.test.ts +130 -0
  131. package/src/rest-explicit-columns-http.test.ts +216 -0
  132. package/src/rest-identity.test.ts +129 -0
  133. package/src/rest-identity.ts +100 -0
  134. package/src/rest-projections.ts +68 -0
  135. package/src/rest-write-network-resolution.test.ts +133 -0
  136. package/src/restricted-files.ts +73 -0
  137. package/src/retention.ts +15 -3
  138. package/src/route-callers-http.test.ts +120 -0
  139. package/src/route-timing.test.ts +211 -0
  140. package/src/route-timing.ts +258 -0
  141. package/src/rules-file-duplicate-alias.test.ts +234 -0
  142. package/src/rules-file-session-target.test.ts +207 -0
  143. package/src/rules-file-transport.test.ts +237 -0
  144. package/src/runtime-label.test.ts +22 -0
  145. package/src/runtime-label.ts +21 -0
  146. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  147. package/src/scheduled-reply-unread-http.test.ts +212 -0
  148. package/src/scheduled-run-terminal.test.ts +268 -0
  149. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  150. package/src/scheduled-tasks-http.test.ts +638 -0
  151. package/src/scheduled-tasks.ts +597 -0
  152. package/src/send-dedup-request-id.test.ts +229 -0
  153. package/src/send-reply-agent-warning.test.ts +202 -0
  154. package/src/send-reply-alias-optional.test.ts +131 -0
  155. package/src/send-reply-attachments.test.ts +458 -0
  156. package/src/send_dedup.test.ts +54 -0
  157. package/src/send_dedup.ts +52 -6
  158. package/src/serve-error.test.ts +38 -0
  159. package/src/serve-error.ts +27 -0
  160. package/src/server.ts +4522 -0
  161. package/src/shared/external-schedule-contract.ts +60 -0
  162. package/src/side-thread-command-transport.test.ts +312 -0
  163. package/src/side-thread-command-transport.ts +318 -0
  164. package/src/side-thread-http-integration.test.ts +233 -0
  165. package/src/side-thread-http.ts +504 -0
  166. package/src/side-thread-production.test.ts +72 -0
  167. package/src/side-thread-production.ts +118 -0
  168. package/src/side-thread.test.ts +1067 -0
  169. package/src/side-thread.ts +2499 -0
  170. package/src/skillhub-http.test.ts +108 -0
  171. package/src/sqlite-close-checkpoint.test.ts +78 -0
  172. package/src/start-node.test.ts +109 -0
  173. package/src/status-alias-filter-http.test.ts +106 -0
  174. package/src/status-node-id-filter-http.test.ts +161 -0
  175. package/src/stop-delete-node.test.ts +1164 -0
  176. package/src/task-access-http.test.ts +379 -0
  177. package/src/task-access.ts +307 -0
  178. package/src/task-auth-origin-http.test.ts +91 -0
  179. package/src/task-auth-origin.test.ts +24 -0
  180. package/src/task-auth-origin.ts +7 -0
  181. package/src/task-consumption.test.ts +482 -0
  182. package/src/task-diagnostic-http.test.ts +149 -0
  183. package/src/task-diagnostic.test.ts +115 -0
  184. package/src/task-diagnostic.ts +118 -0
  185. package/src/task-idempotency-mcp.test.ts +142 -0
  186. package/src/task-idempotency.test.ts +60 -0
  187. package/src/task-idempotency.ts +75 -0
  188. package/src/task-lifecycle-watcher.test.ts +204 -0
  189. package/src/task-lifecycle-watcher.ts +49 -0
  190. package/src/task-network-resolution-http.test.ts +141 -0
  191. package/src/tasks-pagination-http.test.ts +141 -0
  192. package/src/tools-list-json-schema.test.ts +66 -0
  193. package/src/tools.ts +3534 -343
  194. package/src/update-provider.test.ts +65 -0
  195. package/src/upload-early-reject-guard.test.ts +155 -0
  196. package/src/uploads-http.test.ts +97 -15
  197. package/src/uploads.test.ts +66 -0
  198. package/src/uploads.ts +138 -5
  199. package/src/user-inbox-by-agent-http.test.ts +161 -0
  200. package/src/user-inbox-netid-notnull.test.ts +122 -0
  201. package/src/user-inbox-read-http.test.ts +223 -0
  202. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,217 @@
1
+ // Node display attributes — team / tags / display_name (Vincent 目标第 3 条).
2
+ //
3
+ // These are HUB-SIDE DISPLAY attributes. Following the `avatar_url` (#462)
4
+ // precedent they are deliberately NOT part of the RFC-024 config-apply
5
+ // pipeline: agent-node has nothing to consume, so pushing them through
6
+ // config revision/ack would ring a doorbell for a field the node does not
7
+ // understand and could wedge the config revision.
8
+ //
9
+ // They DO need concurrency protection (two dashboards editing the same
10
+ // node), which `avatar_url` lacks. Hence a dedicated `attrs_revision` CAS
11
+ // that is independent of `config_revision` — editing a tag must never bump
12
+ // the node's *config* revision.
13
+ //
14
+ // Every test drives the REAL HTTP endpoint against a booted server, not a
15
+ // helper: the contract under test is the wire shape + status codes.
16
+ //
17
+ // NOTE (test isolation): this file boots its own Bun.serve, same pattern as
18
+ // uploads-http.test.ts / api-host-supervisors-fallback.test.ts. Those files
19
+ // are per-file runs by design (single-process module-singleton conflict,
20
+ // tracked in #434) — run this file on its own.
21
+
22
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
23
+ import { mkdtempSync } from "fs";
24
+ import { tmpdir } from "os";
25
+ import { join } from "path";
26
+
27
+ const tmpDb = join(mkdtempSync(join(tmpdir(), "anet-attrs-")), "commhub.db");
28
+ process.env.COMMHUB_DB ||= tmpDb;
29
+ const PORT = 23000 + Math.floor(Math.random() * 1500);
30
+ process.env.PORT = String(PORT);
31
+ process.env.HOST = "127.0.0.1";
32
+
33
+ const { db } = await import("./db.js");
34
+ const { issueUserToken } = await import("./auth.js");
35
+ const { bootServer } = await import("./server.js");
36
+
37
+ const BASE = `http://127.0.0.1:${PORT}`;
38
+ const NET = "net_attrs";
39
+ const USER = "u_attrs";
40
+ /** A node row seeded the way a PRE-MIGRATION row looks: none of the new
41
+ * columns are written, so they are NULL / default at read time. */
42
+ const LEGACY_NODE = "node_attrs_legacy";
43
+ const LEGACY_ALIAS = "attrs-legacy-node";
44
+
45
+ let token = "";
46
+ let server: { stop: (force?: boolean) => void } | null = null;
47
+
48
+ // 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
49
+ // 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
50
+ // (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
51
+ // 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
52
+ beforeAll(async () => {
53
+ db.run(`INSERT INTO users (user_id, username, password_hash, role) VALUES (?1, 'attrs-user', 'x', 'user')`, [USER]);
54
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id) VALUES (?1, 'attrs-net', ?2)`, [NET, USER]);
55
+ db.run(`INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')`, [NET, USER]);
56
+ // Legacy row: only the columns that existed before this change.
57
+ db.run(
58
+ `INSERT INTO nodes (node_id, node_name, alias, runtime, model, network_id)
59
+ VALUES (?1, 'attrs-legacy', ?2, 'claude-agent-sdk', 'claude-sonnet-4-6', ?3)`,
60
+ [LEGACY_NODE, LEGACY_ALIAS, NET],
61
+ );
62
+ token = issueUserToken(USER, "attrs-test").token;
63
+ server = bootServer({ port: PORT, hostname: "127.0.0.1" }) as never;
64
+ await new Promise((r) => setTimeout(r, 120));
65
+ }, 30_000);
66
+
67
+ afterAll(() => {
68
+ try { server?.stop(true); } catch { /* already down */ }
69
+ });
70
+
71
+ async function putAttrs(ref: string, body: unknown) {
72
+ const res = await fetch(`${BASE}/api/nodes/${encodeURIComponent(ref)}/attrs?network_id=${NET}`, {
73
+ method: "PUT",
74
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
75
+ body: JSON.stringify(body),
76
+ });
77
+ return { status: res.status, json: (await res.json()) as Record<string, unknown> };
78
+ }
79
+
80
+ async function listNodes() {
81
+ const res = await fetch(`${BASE}/api/nodes?network_id=${NET}`, {
82
+ headers: { Authorization: `Bearer ${token}` },
83
+ });
84
+ const j = (await res.json()) as { nodes?: Array<Record<string, unknown>> };
85
+ return j.nodes ?? [];
86
+ }
87
+
88
+ describe("node display attrs — backward compatibility with existing rows", () => {
89
+ test("a pre-migration node row still lists fine; new fields read as empty, not missing/throwing", async () => {
90
+ const rows = await listNodes();
91
+ const row = rows.find((r) => r.node_id === LEGACY_NODE);
92
+ expect(row).toBeDefined();
93
+ // Must not blow up and must not omit the keys — the dashboard reads
94
+ // them unconditionally.
95
+ expect(row!).toHaveProperty("display_name");
96
+ expect(row!).toHaveProperty("team");
97
+ expect(row!).toHaveProperty("tags");
98
+ expect(row!.display_name ?? null).toBeNull();
99
+ expect(row!.team ?? null).toBeNull();
100
+ // tags normalises to an empty array rather than null/undefined so the
101
+ // dashboard can `.map()` without a guard.
102
+ expect(row!.tags).toEqual([]);
103
+ expect(row!.attrs_revision).toBe(0);
104
+ });
105
+ });
106
+
107
+ describe("node display attrs — write then read back", () => {
108
+ test("PUT sets display_name/team/tags, bumps attrs_revision, and GET /api/nodes reflects it", async () => {
109
+ const w = await putAttrs(LEGACY_ALIAS, {
110
+ base_attrs_revision: 0,
111
+ display_name: "研发一号机",
112
+ team: "platform",
113
+ tags: ["prod", "gpu"],
114
+ });
115
+ expect(w.status).toBe(200);
116
+ expect(w.json.ok).toBe(true);
117
+ expect(w.json.attrs_revision).toBe(1);
118
+
119
+ const row = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
120
+ expect(row.display_name).toBe("研发一号机");
121
+ expect(row.team).toBe("platform");
122
+ expect(row.tags).toEqual(["prod", "gpu"]);
123
+ expect(row.attrs_revision).toBe(1);
124
+ });
125
+
126
+ test("editing attrs does NOT touch config_revision (display vs config are separate revisions)", async () => {
127
+ const before = db.get<{ config_revision: number }>(
128
+ "SELECT config_revision FROM nodes WHERE node_id = ?1", LEGACY_NODE)!.config_revision ?? 0;
129
+ const w = await putAttrs(LEGACY_ALIAS, { base_attrs_revision: 1, team: "infra" });
130
+ expect(w.status).toBe(200);
131
+ const after = db.get<{ config_revision: number }>(
132
+ "SELECT config_revision FROM nodes WHERE node_id = ?1", LEGACY_NODE)!.config_revision ?? 0;
133
+ expect(after).toBe(before);
134
+ });
135
+
136
+ test("partial patch leaves untouched fields alone", async () => {
137
+ const row = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
138
+ // team was just changed to 'infra'; display_name/tags must survive.
139
+ expect(row.team).toBe("infra");
140
+ expect(row.display_name).toBe("研发一号机");
141
+ expect(row.tags).toEqual(["prod", "gpu"]);
142
+ });
143
+ });
144
+
145
+ describe("node display attrs — CAS concurrency guard", () => {
146
+ test("stale base_attrs_revision → 409 with the current revision, and NO write happens", async () => {
147
+ const before = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
148
+ const stale = await putAttrs(LEGACY_ALIAS, {
149
+ base_attrs_revision: 0, // deliberately stale
150
+ team: "should-not-land",
151
+ });
152
+ expect(stale.status).toBe(409);
153
+ expect(stale.json.ok).toBe(false);
154
+ expect(stale.json.error).toBe("attrs_revision_conflict");
155
+ expect(stale.json.current_attrs_revision).toBe(before.attrs_revision);
156
+
157
+ const after = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
158
+ expect(after.team).toBe(before.team); // unchanged
159
+ expect(after.attrs_revision).toBe(before.attrs_revision);
160
+ });
161
+ });
162
+
163
+ describe("node display attrs — untrusted input is narrowed at the boundary", () => {
164
+ test("tags: non-strings dropped, blanks dropped, duplicates folded, count + length capped", async () => {
165
+ const cur = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.attrs_revision as number;
166
+ const w = await putAttrs(LEGACY_ALIAS, {
167
+ base_attrs_revision: cur,
168
+ tags: [
169
+ "keep",
170
+ "keep", // duplicate
171
+ " spaced ", // trimmed
172
+ "",
173
+ " ",
174
+ 42,
175
+ null,
176
+ { evil: true },
177
+ ["nested"],
178
+ "x".repeat(200), // over-long → dropped or truncated, never stored raw
179
+ ],
180
+ });
181
+ expect(w.status).toBe(200);
182
+ const tags = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.tags as string[];
183
+ expect(Array.isArray(tags)).toBe(true);
184
+ expect(tags).toContain("keep");
185
+ expect(tags).toContain("spaced");
186
+ // one "keep", no junk, nothing absurdly long
187
+ expect(tags.filter((t) => t === "keep")).toHaveLength(1);
188
+ expect(tags.every((t) => typeof t === "string" && t.length > 0 && t.length <= 64)).toBe(true);
189
+ expect(tags.some((t) => t.includes("evil") || t.includes("nested"))).toBe(false);
190
+ });
191
+
192
+ test("display_name/team: non-string values are rejected, not coerced", async () => {
193
+ const cur = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.attrs_revision as number;
194
+ const bad = await putAttrs(LEGACY_ALIAS, { base_attrs_revision: cur, team: { not: "a string" } });
195
+ expect(bad.status).toBe(400);
196
+ expect(bad.json.ok).toBe(false);
197
+ });
198
+ });
199
+
200
+ describe("node display attrs — auth + scope", () => {
201
+ test("no token → 401, and the row is untouched", async () => {
202
+ const before = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
203
+ const res = await fetch(`${BASE}/api/nodes/${LEGACY_ALIAS}/attrs?network_id=${NET}`, {
204
+ method: "PUT",
205
+ headers: { "Content-Type": "application/json" },
206
+ body: JSON.stringify({ base_attrs_revision: before.attrs_revision, team: "hijack" }),
207
+ });
208
+ expect(res.status).toBe(401);
209
+ const after = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
210
+ expect(after.team).toBe(before.team);
211
+ });
212
+
213
+ test("unknown node ref → 404", async () => {
214
+ const r = await putAttrs("no-such-node-ref", { base_attrs_revision: 0, team: "x" });
215
+ expect(r.status).toBe(404);
216
+ });
217
+ });
@@ -0,0 +1,92 @@
1
+ // 桌面向导要看得到 daemon 回的失败原因:GET /api/node-create-requests?request_id=…
2
+ // 承重:① 只在调用者的网络作用域里能读到(别的网络 → 404,与不存在同形);② 无用户上下文 401;③ 缺参 400;
3
+ // ④ 回的是 daemon ack 写进去的 status/error 原文。
4
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
5
+ import { mkdtempSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-create-request-status-"));
10
+ process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
11
+
12
+ let server: ReturnType<typeof Bun.serve>;
13
+ let base = "";
14
+ let aToken = "", bToken = "", aNet = "", bNet = "";
15
+ const MASTER_TOKEN = "legacy-master-token-for-test";
16
+
17
+ const get = async (path: string, token: string) => {
18
+ const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } });
19
+ return { status: r.status, body: await r.json() as any };
20
+ };
21
+
22
+ beforeAll(async () => {
23
+ process.env.COMMHUB_AUTH_TOKEN ||= MASTER_TOKEN;
24
+ const { db } = await import("./db.js");
25
+ const { register } = await import("./auth.js");
26
+ const stamp = Date.now();
27
+ const admin = register(`cr_admin_${stamp}`, "CrAdmin-Strong-1!");
28
+ expect(admin.ok).toBe(true);
29
+ const a = register(`cr_a_${stamp}`, "CrA-Strong-1!");
30
+ const b = register(`cr_b_${stamp}`, "CrB-Strong-1!");
31
+ expect(a.ok && b.ok).toBe(true);
32
+ aToken = a.token!; bToken = b.token!; aNet = a.network_id!; bNet = b.network_id!;
33
+ const uid = (name: string) => db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", name)!.user_id;
34
+ for (const id of [uid(`cr_a_${stamp}`), uid(`cr_b_${stamp}`)]) {
35
+ expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", id)?.role).toBe("user");
36
+ }
37
+ db.run(
38
+ `INSERT INTO node_create_requests
39
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token, acked_at, error)
40
+ VALUES ('cr_a_failed', 'node_daemon_a', 'vansin-go', ?1, 'opencode-cli', NULL, '{}', '[]', 'failed', NULL, ?2, 'tok', ?2,
41
+ 'opencode-cli package entrypoint verification currently requires Linux')`,
42
+ [aNet, Date.now()],
43
+ );
44
+ db.run(
45
+ `INSERT INTO node_create_requests
46
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
47
+ VALUES ('cr_b_pending', 'node_daemon_b', 'other', ?1, 'codex-app-server', NULL, '{}', '[]', 'pending', NULL, ?2, 'tok')`,
48
+ [bNet, Date.now()],
49
+ );
50
+ const mod = await import("./server.js");
51
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
52
+ base = `http://127.0.0.1:${server.port}`;
53
+ });
54
+
55
+ afterAll(() => {
56
+ try { server?.stop(true); } catch {}
57
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
58
+ });
59
+
60
+ describe("GET /api/node-create-requests?request_id=", () => {
61
+ test("A reads her own failed request with the daemon's error verbatim", async () => {
62
+ const { status, body } = await get("/api/node-create-requests?request_id=cr_a_failed", aToken);
63
+ expect(status).toBe(200);
64
+ expect(body.ok).toBe(true);
65
+ expect(body.request).toMatchObject({
66
+ request_id: "cr_a_failed", child_name: "vansin-go", runtime: "opencode-cli", status: "failed",
67
+ error: "opencode-cli package entrypoint verification currently requires Linux",
68
+ });
69
+ });
70
+ test("🔴 B cannot read A's request — 404, same shape as nonexistent", async () => {
71
+ const other = await get("/api/node-create-requests?request_id=cr_a_failed", bToken);
72
+ const missing = await get("/api/node-create-requests?request_id=cr_nope", bToken);
73
+ expect(other.status).toBe(404);
74
+ expect(missing.status).toBe(404);
75
+ expect(other.body).toEqual(missing.body);
76
+ });
77
+ test("pending request reads as pending with no error", async () => {
78
+ const { body } = await get("/api/node-create-requests?request_id=cr_b_pending", bToken);
79
+ expect(body.request.status).toBe("pending");
80
+ expect(body.request.error).toBeNull();
81
+ });
82
+ test("missing request_id → 400", async () => {
83
+ const { status, body } = await get("/api/node-create-requests", aToken);
84
+ expect(status).toBe(400);
85
+ expect(body.error).toBe("request_id_required");
86
+ });
87
+ test("legacy master token (no user context) → 401", async () => {
88
+ const { status, body } = await get("/api/node-create-requests?request_id=cr_a_failed", MASTER_TOKEN);
89
+ expect(status).toBe(401);
90
+ expect(body.error).toBe("auth_required");
91
+ });
92
+ });
@@ -0,0 +1,344 @@
1
+ // Project folder view — list_node_files / read_node_file ride the rules-file
2
+ // queue + doorbell (ops files_list / file_read).
3
+ //
4
+ // Hub-side contract verified here:
5
+ // 1. report_status { files_capable: true } is sticky and set only by the
6
+ // session's own node token (bound or legacy-unbound, same rule as skills).
7
+ // 2. Both tools reach a session with no nodes row by alias; the pulled
8
+ // request carries the normalized RELATIVE path; results come back through
9
+ // get_rules_file_result.
10
+ // 3. Absolute paths, `..`, `~`, drive letters, backslashes and NUL are refused
11
+ // before any request row exists (the node re-checks; this is the first wall).
12
+ // 4. A node token may not enqueue a files op (it would read another node's
13
+ // disk), and only the token that asked may read a files result.
14
+ // 5. A token bound to node A never pulls or acks node B's files request; an
15
+ // unbound legacy token falls back to its alias exactly as it does for skills.
16
+ // 6. Its own single-flight lane; rules-capable-only sessions are refused.
17
+ // 7. Files acks may carry up to 1 MiB (escaped JSON of a 256 KiB file); rules
18
+ // acks keep their 256 KiB cap.
19
+ //
20
+ // 跑法:cd server && COMMHUB_DB=/tmp/files-transport.db bun test src/node-files-transport.test.ts
21
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
22
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
23
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
24
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
25
+ import { db } from "./db.js";
26
+ import { registerTools } from "./tools.js";
27
+
28
+ const NET = "net_files_session";
29
+ const OTHER_NET = "net_files_session_other";
30
+ const USER = "u_files_session_owner";
31
+ const SESSION_ALIAS = "files-session-cc";
32
+ const SESSION_RESUME = "cc-files-session-1";
33
+ const PEER_ALIAS = "files-session-peer";
34
+ const NODE_A = "files-node-a";
35
+ const NODE_B = "files-node-b";
36
+ const NODE_A_ID = "node_files_a";
37
+ const NODE_B_ID = "node_files_b";
38
+ const LEGACY_ALIAS = "files-node-legacy";
39
+ const LEGACY_ID = "node_files_legacy";
40
+ const T_SESSION = "tok_files_session_cc";
41
+ const T_PEER = "tok_files_session_peer";
42
+ const T_OTHER_NET_SAME_ALIAS = "tok_files_session_other_net";
43
+ const T_A = "tok_files_node_a";
44
+ const T_B = "tok_files_node_b";
45
+ const T_LEGACY = "tok_files_node_legacy";
46
+
47
+ function cleanup() {
48
+ for (const net of [NET, OTHER_NET]) {
49
+ try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [net]); } catch {}
50
+ try { db.run("DELETE FROM rename_txn WHERE network_id = ?1", [net]); } catch {}
51
+ try { db.run("DELETE FROM sessions WHERE network_id = ?1", [net]); } catch {}
52
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [net]); } catch {}
53
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [net]); } catch {}
54
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [net]); } catch {}
55
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [net]); } catch {}
56
+ }
57
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
58
+ }
59
+
60
+ function seedWorld() {
61
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER, USER]);
62
+ for (const net of [NET, OTHER_NET]) {
63
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [net, net, USER]);
64
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER, net]);
65
+ }
66
+ const tok = (id: string, net: string, alias: string, bound: string | null = null) => db.run(
67
+ `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)`,
68
+ [id, USER, net, `node:${alias}`, `hash_${id}`, bound],
69
+ );
70
+ const node = (id: string, alias: string) => db.run(
71
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
72
+ [id, alias, alias, NET],
73
+ );
74
+ node(NODE_A_ID, NODE_A);
75
+ node(NODE_B_ID, NODE_B);
76
+ node(LEGACY_ID, LEGACY_ALIAS);
77
+ tok(T_SESSION, NET, SESSION_ALIAS);
78
+ tok(T_PEER, NET, PEER_ALIAS);
79
+ tok(T_OTHER_NET_SAME_ALIAS, OTHER_NET, SESSION_ALIAS);
80
+ tok(T_A, NET, NODE_A, NODE_A_ID);
81
+ tok(T_B, NET, NODE_B, NODE_B_ID);
82
+ tok(T_LEGACY, NET, LEGACY_ALIAS, null);
83
+ }
84
+
85
+ type Identity = { net: string; alias: string; isNetworkToken: boolean; tokenId: string };
86
+ const asUser: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_files_session_user" };
87
+ const asUserOtherLogin: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_files_session_user_other_login" };
88
+ const asSession: Identity = { net: NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_SESSION };
89
+ const asPeer: Identity = { net: NET, alias: PEER_ALIAS, isNetworkToken: true, tokenId: T_PEER };
90
+ const asOtherNetSameAlias: Identity = { net: OTHER_NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_OTHER_NET_SAME_ALIAS };
91
+ const asA: Identity = { net: NET, alias: NODE_A, isNetworkToken: true, tokenId: T_A };
92
+ const asB: Identity = { net: NET, alias: NODE_B, isNetworkToken: true, tokenId: T_B };
93
+ const asLegacy: Identity = { net: NET, alias: LEGACY_ALIAS, isNetworkToken: true, tokenId: T_LEGACY };
94
+
95
+ async function connect(id: Identity) {
96
+ const server = new McpServer({ name: "files-session-test", version: "1" });
97
+ registerTools(server, undefined, id.net, USER, id.alias, id.isNetworkToken, id.tokenId);
98
+ const client = new Client({ name: "files-session-client", version: "1" });
99
+ const [ct, st] = InMemoryTransport.createLinkedPair();
100
+ await server.connect(st);
101
+ await client.connect(ct);
102
+ const call = async (name: string, args: Record<string, unknown>) => {
103
+ const r: any = await client.callTool({ name, arguments: args });
104
+ const first = r.content?.[0];
105
+ if (!first || first.type !== "text") throw new Error(`no text result from ${name}: ${JSON.stringify(r).slice(0, 300)}`);
106
+ try { return JSON.parse(first.text); } catch { return { raw: first.text, isError: r.isError === true }; }
107
+ };
108
+ const close = async () => { await client.close(); await server.close(); };
109
+ return { call, close };
110
+ }
111
+
112
+ const capable = (alias: string, net = NET) =>
113
+ db.get<{ c: number }>("SELECT files_capable AS c FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, net)?.c;
114
+ const rowCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM node_rules_requests WHERE network_id = ?1", NET)!.n;
115
+
116
+ async function report(id: Identity, alias: string, resume: string, flags: Record<string, true>) {
117
+ const c = await connect(id);
118
+ try { return await c.call("report_status", { resume_id: resume, alias, status: "idle", agent: "claude-code", ...flags }); }
119
+ finally { await c.close(); }
120
+ }
121
+ const ALL_FLAGS = { rules_file_capable: true, skills_capable: true, files_capable: true } as const;
122
+
123
+ beforeEach(() => { cleanup(); seedWorld(); });
124
+ afterAll(cleanup);
125
+
126
+ describe("files_capable is set only by the session's own node token", () => {
127
+ test("own token sets it; a later report without it does not clear it; a peer cannot set it", async () => {
128
+ await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
129
+ expect(capable(SESSION_ALIAS)).toBe(1);
130
+ await report(asSession, SESSION_ALIAS, SESSION_RESUME, {});
131
+ expect(capable(SESSION_ALIAS)).toBe(1);
132
+ const r = await report(asPeer, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
133
+ expect(r).toMatchObject({ ok: false });
134
+ // A user login cannot set it either.
135
+ await report(asUser, PEER_ALIAS, "cc-files-user-report", ALL_FLAGS);
136
+ expect(capable(PEER_ALIAS) ?? 0).toBe(0);
137
+ }, 20_000);
138
+
139
+ test("a stale process whose report is rewritten to a renamed alias does not grant that alias the flag", async () => {
140
+ const NEW_ALIAS = "files-session-renamed";
141
+ db.run(
142
+ `INSERT INTO rename_txn (txn_id, network_id, old_alias, new_alias, status, committed_at) VALUES ('txn_files_rename', ?1, ?2, ?3, 'committed', datetime('now'))`,
143
+ [NET, PEER_ALIAS, NEW_ALIAS],
144
+ );
145
+ // Token still carries the old alias; the hub rewrites the report to NEW_ALIAS.
146
+ const r = await report(asPeer, PEER_ALIAS, "cc-files-renamed", ALL_FLAGS);
147
+ expect(r.ok).toBe(true);
148
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM sessions WHERE alias = ?1 AND network_id = ?2", NEW_ALIAS, NET)!.n).toBe(1);
149
+ expect(capable(NEW_ALIAS)).toBe(0);
150
+ }, 20_000);
151
+ });
152
+
153
+ describe("list / read by alias reach a session with no nodes row", () => {
154
+ test("list: pulled request carries the normalized relative path; result comes back", async () => {
155
+ await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
156
+ const u = await connect(asUser);
157
+ const s = await connect(asSession);
158
+ try {
159
+ const enq = await u.call("list_node_files", { alias: SESSION_ALIAS, path: "./src//lib/" });
160
+ expect(enq).toMatchObject({ ok: true, op: "files_list" });
161
+ const pulled = await s.call("get_rules_file_request", {});
162
+ expect(pulled.request).toEqual({ request_id: enq.request_id, op: "files_list", content: "src/lib" });
163
+ const payload = JSON.stringify({ path: "src/lib", entries: [{ name: "a.ts", type: "file", size: 3, mtime: 1 }], truncated: false, total: 1 });
164
+ expect(await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "files", exists: true, content: payload })).toMatchObject({ ok: true, status: "done" });
165
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: true, op: "files_list", status: "done", content: payload });
166
+ } finally { await u.close(); await s.close(); }
167
+ }, 20_000);
168
+
169
+ test("list with no path targets the root (empty content)", async () => {
170
+ await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
171
+ const u = await connect(asUser);
172
+ const s = await connect(asSession);
173
+ try {
174
+ const enq = await u.call("list_node_files", { alias: SESSION_ALIAS });
175
+ expect((await s.call("get_rules_file_request", {})).request).toEqual({ request_id: enq.request_id, op: "files_list", content: "" });
176
+ } finally { await u.close(); await s.close(); }
177
+ }, 20_000);
178
+
179
+ test("read round trip", async () => {
180
+ await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
181
+ const u = await connect(asUser);
182
+ const s = await connect(asSession);
183
+ try {
184
+ const enq = await u.call("read_node_file", { alias: SESSION_ALIAS, path: "docs/README.md" });
185
+ expect(enq).toMatchObject({ ok: true, op: "file_read" });
186
+ expect((await s.call("get_rules_file_request", {})).request).toEqual({ request_id: enq.request_id, op: "file_read", content: "docs/README.md" });
187
+ const payload = JSON.stringify({ path: "docs/README.md", name: "README.md", kind: "text", size: 6, content: "# hi\n" });
188
+ await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "README.md", exists: true, content: payload });
189
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "done", content: payload });
190
+ } finally { await u.close(); await s.close(); }
191
+ }, 20_000);
192
+ });
193
+
194
+ describe("path validation happens before any row is written", () => {
195
+ test("absolute, .., ~, drive letters, backslashes, NUL, and an empty read path are refused", async () => {
196
+ await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
197
+ const u = await connect(asUser);
198
+ try {
199
+ const bad = ["/etc/passwd", "../outside", "a/../../b", "src/..", "~/.ssh/id_rsa", "C:/Windows", "a\\b", "..\\x", "a\u0000b", "x".repeat(1025)];
200
+ for (const path of bad) {
201
+ // Over-long paths are stopped by the zod schema (an MCP input error); the rest by invalid_path.
202
+ const r = await u.call("read_node_file", { alias: SESSION_ALIAS, path });
203
+ expect(r.ok).not.toBe(true);
204
+ if (path.length <= 1024) expect(r).toMatchObject({ ok: false, error: "invalid_path" });
205
+ const l = await u.call("list_node_files", { alias: SESSION_ALIAS, path });
206
+ expect(l.ok).not.toBe(true);
207
+ }
208
+ expect(await u.call("read_node_file", { alias: SESSION_ALIAS, path: "/" })).toMatchObject({ ok: false, error: "invalid_path" });
209
+ expect(await u.call("read_node_file", { alias: SESSION_ALIAS, path: "./" })).toMatchObject({ ok: false, error: "invalid_path" });
210
+ expect(rowCount()).toBe(0);
211
+ } finally { await u.close(); }
212
+ }, 20_000);
213
+ });
214
+
215
+ describe("who may ask, and who may read the answer", () => {
216
+ test("a node token cannot enqueue a files op on another node (or itself)", async () => {
217
+ await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
218
+ const a = await connect(asA);
219
+ try {
220
+ expect(await a.call("list_node_files", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "node_token_cannot_browse_files" });
221
+ expect(await a.call("read_node_file", { node_id: NODE_B_ID, path: ".env" })).toMatchObject({ ok: false, error: "node_token_cannot_browse_files" });
222
+ expect(await a.call("list_node_files", { node_id: NODE_A_ID })).toMatchObject({ ok: false, error: "node_token_cannot_browse_files" });
223
+ expect(rowCount()).toBe(0);
224
+ } finally { await a.close(); }
225
+ }, 20_000);
226
+
227
+ test("only the token that asked can read a files result; node tokens never can", async () => {
228
+ await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
229
+ const u = await connect(asUser);
230
+ const other = await connect(asUserOtherLogin);
231
+ const s = await connect(asSession);
232
+ const a = await connect(asA);
233
+ try {
234
+ const enq = await u.call("read_node_file", { alias: SESSION_ALIAS, path: "notes.txt" });
235
+ await s.call("get_rules_file_request", {});
236
+ await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: JSON.stringify({ path: "notes.txt", kind: "text", content: "x" }) });
237
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: true, status: "done" });
238
+ expect(await other.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: false, error: "request_not_found" });
239
+ expect(await a.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: false, error: "request_not_found" });
240
+ // The serving session itself cannot read the stored result back through the client tool either.
241
+ expect(await s.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: false, error: "request_not_found" });
242
+ } finally { await u.close(); await other.close(); await s.close(); await a.close(); }
243
+ }, 20_000);
244
+
245
+ test("rules/skills results stay readable by any writer of the network (unchanged)", async () => {
246
+ await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
247
+ const u = await connect(asUser);
248
+ const other = await connect(asUserOtherLogin);
249
+ try {
250
+ const enq = await u.call("list_node_skills", { alias: SESSION_ALIAS });
251
+ expect(await other.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: true, status: "pending" });
252
+ } finally { await u.close(); await other.close(); }
253
+ }, 20_000);
254
+ });
255
+
256
+ describe("a token bound to node A cannot touch node B's files request", () => {
257
+ test("B's request: A pulls nothing, A's ack is ignored, B pulls and acks it", async () => {
258
+ const u = await connect(asUser);
259
+ const a = await connect(asA);
260
+ const b = await connect(asB);
261
+ try {
262
+ const enq = await u.call("read_node_file", { node_id: NODE_B_ID, path: "secret-plan.md" });
263
+ expect(enq).toMatchObject({ ok: true });
264
+ expect((await a.call("get_rules_file_request", {})).request).toBeNull();
265
+ expect(await a.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{\"forged\":true}" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
266
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
267
+ expect((await b.call("get_rules_file_request", {})).request).toEqual({ request_id: enq.request_id, op: "file_read", content: "secret-plan.md" });
268
+ expect(await b.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{}" })).toMatchObject({ ok: true, status: "done" });
269
+ } finally { await u.close(); await a.close(); await b.close(); }
270
+ }, 20_000);
271
+
272
+ test("same alias in another network neither pulls nor acks; a peer's ack is ignored", async () => {
273
+ await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
274
+ const u = await connect(asUser);
275
+ const other = await connect(asOtherNetSameAlias);
276
+ const peer = await connect(asPeer);
277
+ try {
278
+ const enq = await u.call("list_node_files", { alias: SESSION_ALIAS });
279
+ expect((await other.call("get_rules_file_request", {})).request).toBeNull();
280
+ expect(await other.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{}" })).toMatchObject({ ignored: "unknown_or_foreign_request" });
281
+ expect(await peer.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{}" })).toMatchObject({ ignored: "unknown_or_foreign_request" });
282
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
283
+ } finally { await u.close(); await other.close(); await peer.close(); }
284
+ }, 20_000);
285
+
286
+ test("an unbound legacy token gets what skills gives it: alias fallback pull + ack, and it can set its own flag", async () => {
287
+ const u = await connect(asUser);
288
+ const legacy = await connect(asLegacy);
289
+ try {
290
+ await report(asLegacy, LEGACY_ALIAS, `sdk-${LEGACY_ID}`, ALL_FLAGS);
291
+ expect(capable(LEGACY_ALIAS)).toBe(1);
292
+ const skills = await u.call("list_node_skills", { node_id: LEGACY_ID });
293
+ const files = await u.call("list_node_files", { node_id: LEGACY_ID, path: "src" });
294
+ expect(skills).toMatchObject({ ok: true });
295
+ expect(files).toMatchObject({ ok: true });
296
+ const first = (await legacy.call("get_rules_file_request", {})).request;
297
+ const second = (await legacy.call("get_rules_file_request", {})).request;
298
+ expect([first.request_id, second.request_id].sort()).toEqual([skills.request_id, files.request_id].sort());
299
+ expect(await legacy.call("ack_rules_file_request", { request_id: files.request_id, status: "done", content: "{}" })).toMatchObject({ ok: true, status: "done" });
300
+ expect(await legacy.call("ack_rules_file_request", { request_id: skills.request_id, status: "done", content: "{}" })).toMatchObject({ ok: true, status: "done" });
301
+ } finally { await u.close(); await legacy.close(); }
302
+ }, 20_000);
303
+ });
304
+
305
+ describe("lanes, capability gate and ack size", () => {
306
+ test("files has its own single-flight lane", async () => {
307
+ await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
308
+ const u = await connect(asUser);
309
+ try {
310
+ expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: true });
311
+ expect(await u.call("list_node_skills", { alias: SESSION_ALIAS })).toMatchObject({ ok: true });
312
+ const list = await u.call("list_node_files", { alias: SESSION_ALIAS });
313
+ expect(list).toMatchObject({ ok: true });
314
+ expect(await u.call("read_node_file", { alias: SESSION_ALIAS, path: "a.txt" })).toMatchObject({ ok: false, error: "request_in_flight", existing_request_id: list.request_id });
315
+ } finally { await u.close(); }
316
+ }, 20_000);
317
+
318
+ test("rules+skills capable but not files capable → files_target_not_found, no row", async () => {
319
+ await report(asSession, SESSION_ALIAS, SESSION_RESUME, { rules_file_capable: true, skills_capable: true });
320
+ const u = await connect(asUser);
321
+ try {
322
+ expect(await u.call("list_node_files", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "files_target_not_found" });
323
+ expect(rowCount()).toBe(0);
324
+ } finally { await u.close(); }
325
+ }, 20_000);
326
+
327
+ test("a files ack may carry up to 1 MiB; a rules ack keeps its 256 KiB cap", async () => {
328
+ await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
329
+ const u = await connect(asUser);
330
+ const s = await connect(asSession);
331
+ try {
332
+ const big = JSON.stringify({ path: "big.txt", kind: "text", content: "\n".repeat(256 * 1024) });
333
+ expect(big.length).toBeGreaterThan(256 * 1024);
334
+ const f = await u.call("read_node_file", { alias: SESSION_ALIAS, path: "big.txt" });
335
+ const r = await u.call("read_node_rules_file", { alias: SESSION_ALIAS });
336
+ await s.call("get_rules_file_request", {});
337
+ await s.call("get_rules_file_request", {});
338
+ expect(await s.call("ack_rules_file_request", { request_id: f.request_id, status: "done", content: big })).toMatchObject({ ok: true, status: "done" });
339
+ expect(await s.call("ack_rules_file_request", { request_id: r.request_id, status: "done", content: "x".repeat(256 * 1024 + 1) })).toMatchObject({ ok: false, error: "content_too_large" });
340
+ const tooBig = await s.call("ack_rules_file_request", { request_id: r.request_id, status: "done", content: "x".repeat(1024 * 1024 + 1) });
341
+ expect(tooBig.ok).not.toBe(true);
342
+ } finally { await u.close(); await s.close(); }
343
+ }, 20_000);
344
+ });