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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-networks-http.test.ts +88 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  10. package/src/api-host-supervisors-fallback.test.ts +271 -0
  11. package/src/api-nodes-shape.test.ts +26 -10
  12. package/src/auth-validate.test.ts +5 -1
  13. package/src/auth.ts +93 -14
  14. package/src/avatar-validate.test.ts +163 -0
  15. package/src/avatar-validate.ts +103 -0
  16. package/src/blocked-exit-on-reply.test.ts +122 -0
  17. package/src/boot-side-effect.test.ts +124 -0
  18. package/src/config-apply-sec1.test.ts +107 -0
  19. package/src/config-apply-validate.test.ts +92 -0
  20. package/src/config-apply-validate.ts +81 -3
  21. package/src/create-node-blocked-gate.test.ts +356 -0
  22. package/src/create-node-tool-schema.test.ts +39 -0
  23. package/src/create-node-validate.test.ts +89 -1
  24. package/src/create-node-validate.ts +55 -7
  25. package/src/create-node.ts +51 -1
  26. package/src/dashboard-slash-routing-http.test.ts +107 -0
  27. package/src/db-adapter-guard.test.ts +85 -0
  28. package/src/db-adapter.ts +64 -23
  29. package/src/db-timestamp.test.ts +38 -0
  30. package/src/db-timestamp.ts +15 -0
  31. package/src/db.ts +710 -33
  32. package/src/delivered-without-push.test.ts +140 -0
  33. package/src/desktop-message-seam.test.ts +150 -0
  34. package/src/desktop-message.test.ts +368 -0
  35. package/src/ext-token-shared.test.ts +60 -0
  36. package/src/ext-validation-regression.test.ts +131 -0
  37. package/src/external-schedule-edits-http.test.ts +320 -0
  38. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  39. package/src/external-schedule-edits.ts +327 -0
  40. package/src/file-download-authz-dev-open.test.ts +135 -0
  41. package/src/file-download-authz.test.ts +403 -0
  42. package/src/file-download-date-bucket.test.ts +436 -0
  43. package/src/file-network-scope-dev-open.test.ts +97 -0
  44. package/src/file-network-scope.test.ts +731 -0
  45. package/src/health-redaction.test.ts +641 -0
  46. package/src/http-gzip.test.ts +73 -0
  47. package/src/http-gzip.ts +60 -0
  48. package/src/hub-timestamp.test.ts +38 -0
  49. package/src/hub-timestamp.ts +27 -0
  50. package/src/inbox-count-sse.test.ts +310 -0
  51. package/src/inbox-count.ts +29 -0
  52. package/src/index.ts +15 -2227
  53. package/src/lifecycle-guard.ts +50 -0
  54. package/src/list-host-supervisors.test.ts +314 -0
  55. package/src/mcp-write-network-resolution.test.ts +429 -0
  56. package/src/messages-ack-by-agent-http.test.ts +142 -0
  57. package/src/network-name-http.test.ts +75 -0
  58. package/src/network-scope.ts +101 -0
  59. package/src/node-attrs-validate.ts +88 -0
  60. package/src/node-attrs.test.ts +217 -0
  61. package/src/node-create-request-status-http.test.ts +92 -0
  62. package/src/node-files-transport.test.ts +344 -0
  63. package/src/node-id-alias.test.ts +188 -0
  64. package/src/node-lifecycle-controllable.test.ts +64 -0
  65. package/src/node-lifecycle-controllable.ts +51 -0
  66. package/src/node-request-retention.test.ts +281 -0
  67. package/src/node-request-retention.ts +104 -0
  68. package/src/node-skills-transport.test.ts +209 -0
  69. package/src/observer-avatar-http.test.ts +428 -0
  70. package/src/observer-push.test.ts +252 -0
  71. package/src/package-version-consistency.test.ts +25 -0
  72. package/src/peer-reply-atomic.test.ts +455 -0
  73. package/src/peer-reply-capability.test.ts +70 -0
  74. package/src/push.ts +163 -7
  75. package/src/redact-tokens.test.ts +65 -0
  76. package/src/redact-tokens.ts +40 -0
  77. package/src/reply-target-derivation.test.ts +96 -0
  78. package/src/report-status-host-schema.test.ts +211 -0
  79. package/src/report-status-resume-id-handover.test.ts +150 -0
  80. package/src/require-explicit-test-db.ts +9 -0
  81. package/src/resolve-port.test.ts +57 -0
  82. package/src/resolve-port.ts +44 -0
  83. package/src/response-charset.test.ts +130 -0
  84. package/src/rest-explicit-columns-http.test.ts +216 -0
  85. package/src/rest-identity.test.ts +129 -0
  86. package/src/rest-identity.ts +100 -0
  87. package/src/rest-projections.ts +67 -0
  88. package/src/rest-write-network-resolution.test.ts +133 -0
  89. package/src/retention.ts +10 -1
  90. package/src/rules-file-duplicate-alias.test.ts +234 -0
  91. package/src/rules-file-session-target.test.ts +207 -0
  92. package/src/rules-file-transport.test.ts +237 -0
  93. package/src/runtime-label.test.ts +22 -0
  94. package/src/runtime-label.ts +21 -0
  95. package/src/scheduled-run-terminal.test.ts +268 -0
  96. package/src/scheduled-tasks-http.test.ts +483 -0
  97. package/src/scheduled-tasks.ts +549 -0
  98. package/src/send-reply-agent-warning.test.ts +202 -0
  99. package/src/send-reply-alias-optional.test.ts +131 -0
  100. package/src/send-reply-attachments.test.ts +458 -0
  101. package/src/serve-error.test.ts +38 -0
  102. package/src/serve-error.ts +27 -0
  103. package/src/server.ts +3842 -0
  104. package/src/shared/external-schedule-contract.ts +60 -0
  105. package/src/side-thread-command-transport.test.ts +302 -0
  106. package/src/side-thread-command-transport.ts +318 -0
  107. package/src/side-thread-http-integration.test.ts +233 -0
  108. package/src/side-thread-http.ts +504 -0
  109. package/src/side-thread-production.test.ts +72 -0
  110. package/src/side-thread-production.ts +118 -0
  111. package/src/side-thread.test.ts +1067 -0
  112. package/src/side-thread.ts +2495 -0
  113. package/src/skillhub-http.test.ts +92 -0
  114. package/src/start-node.test.ts +109 -0
  115. package/src/stop-delete-node.test.ts +952 -0
  116. package/src/task-auth-origin-http.test.ts +91 -0
  117. package/src/task-auth-origin.test.ts +24 -0
  118. package/src/task-auth-origin.ts +7 -0
  119. package/src/task-consumption.test.ts +482 -0
  120. package/src/task-diagnostic-http.test.ts +149 -0
  121. package/src/task-diagnostic.test.ts +115 -0
  122. package/src/task-diagnostic.ts +118 -0
  123. package/src/task-idempotency-mcp.test.ts +142 -0
  124. package/src/task-idempotency.test.ts +60 -0
  125. package/src/task-idempotency.ts +75 -0
  126. package/src/task-lifecycle-watcher.test.ts +204 -0
  127. package/src/task-lifecycle-watcher.ts +49 -0
  128. package/src/task-network-resolution-http.test.ts +141 -0
  129. package/src/tasks-pagination-http.test.ts +141 -0
  130. package/src/tools-list-json-schema.test.ts +66 -0
  131. package/src/tools.ts +3231 -204
  132. package/src/update-provider.test.ts +65 -0
  133. package/src/upload-early-reject-guard.test.ts +155 -0
  134. package/src/uploads-http.test.ts +97 -15
  135. package/src/uploads.test.ts +66 -0
  136. package/src/uploads.ts +130 -5
  137. package/src/user-inbox-by-agent-http.test.ts +161 -0
  138. package/src/user-inbox-netid-notnull.test.ts +122 -0
  139. package/src/user-inbox-read-http.test.ts +223 -0
  140. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,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
+ });
@@ -0,0 +1,188 @@
1
+ // #1281 — 子节点生命周期工具的参数名统一(node_id ⇄ child_node_id)。
2
+ //
3
+ // 分叉现状(origin/main 2976adb2):stop_node / start_node / delete_node 用
4
+ // `child_node_id`,restart_node / update_node_config 用 `node_id`,指向同一个
5
+ // 子节点。调用方按一个工具的习惯给另一个传就吃 -32602。修复:5 个工具都同时
6
+ // 接受两个名字(canonical = node_id,child_node_id 兼容 alias),解析收敛到一个
7
+ // 模块级 helper resolveNodeIdArg。
8
+ //
9
+ // 两向 witnessed-red(本文件即判据):
10
+ // · schema 层:每个工具的**历史用名**始终 parse 成功(证明没破已发布契约),
11
+ // **新增别名**在修复前 parse 失败(缺必填字段)、修复后成功。
12
+ // · handler 层:resolveNodeIdArg 纯函数 —— 二选一至少一必填、都传须相等。
13
+ // · 真实 handler 路径:stop_node 用 node_id / child_node_id 都能穿到
14
+ // resolveDaemonForChild(不再被 node_id_required 挡),都传冲突 → node_id_conflict。
15
+ //
16
+ // 对 pre-fix 见红的证明方式(评审可复现):把 tools.ts 里任一工具的
17
+ // `...NODE_ID_ALIAS_FIELDS` 还原成原来的单名字段(如 stop_node 的
18
+ // `child_node_id: z.string()...regex(...)`),本文件对应工具的「新增别名 parse 成功」
19
+ // 断言立即变红 —— 证明该断言有判别力,不是恒真。
20
+
21
+ import { describe, expect, test, beforeAll } from "bun:test";
22
+ import { mkdtempSync } from "fs";
23
+ import { join } from "path";
24
+ import { tmpdir } from "os";
25
+
26
+ process.env.COMMHUB_DB = process.env.COMMHUB_DB || (mkdtempSync(join(tmpdir(), "anet-1281-")) + "/commhub.db");
27
+
28
+ import { z } from "zod/v4";
29
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
30
+ import { registerTools, resolveNodeIdArg } from "./tools.js";
31
+
32
+ type ToolHandler = (args: any) => Promise<{ content: { type: "text"; text: string }[] }>;
33
+
34
+ // Capture BOTH the raw zod shape and the handler for every tool, so the
35
+ // schema-layer assertions run against the exact object server.tool received.
36
+ function buildCaptured() {
37
+ const schemas: Record<string, any> = {};
38
+ const handlers: Record<string, ToolHandler> = {};
39
+ const server = new McpServer({ name: "t1281", version: "0" }) as any;
40
+ const origTool = server.tool.bind(server);
41
+ server.tool = (name: string, _desc: string, schema: any, handler: ToolHandler) => {
42
+ schemas[name] = schema; handlers[name] = handler;
43
+ return origTool(name, _desc, schema, handler);
44
+ };
45
+ const origRegisterTool = server.registerTool?.bind(server);
46
+ if (origRegisterTool) {
47
+ server.registerTool = (name: string, cfg: any, handler: ToolHandler) => {
48
+ schemas[name] = cfg?.inputSchema ?? cfg; handlers[name] = handler;
49
+ return origRegisterTool(name, cfg, handler);
50
+ };
51
+ }
52
+ registerTools(server, undefined, null, "user_1281", null, false, null);
53
+ return { schemas, handlers };
54
+ }
55
+
56
+ let SCHEMAS: Record<string, any> = {};
57
+ let HANDLERS: Record<string, ToolHandler> = {};
58
+ beforeAll(() => { const c = buildCaptured(); SCHEMAS = c.schemas; HANDLERS = c.handlers; });
59
+
60
+ async function call(name: string, args: any) {
61
+ const r = await HANDLERS[name](args);
62
+ return JSON.parse(r.content[0].text);
63
+ }
64
+
65
+ // Per tool: the historically-required id name, the newly-added alias, and
66
+ // the OTHER required fields the shape needs so a safeParse isolates the id
67
+ // field rather than tripping on an unrelated missing field.
68
+ const TOOLS = [
69
+ { name: "stop_node", historic: "child_node_id", added: "node_id", extra: {} },
70
+ { name: "start_node", historic: "child_node_id", added: "node_id", extra: {} },
71
+ { name: "delete_node", historic: "child_node_id", added: "node_id", extra: { confirm_alias: "some-alias" } },
72
+ { name: "restart_node", historic: "node_id", added: "child_node_id", extra: {} },
73
+ { name: "update_node_config", historic: "node_id", added: "child_node_id", extra: { base_revision: 0, patch: {} } },
74
+ ];
75
+
76
+ const VALID_ID = "node_test_1281";
77
+
78
+ describe("#1281 schema layer — every lifecycle tool accepts BOTH node_id and child_node_id", () => {
79
+ for (const t of TOOLS) {
80
+ test(`${t.name}: historic name '${t.historic}' still parses (published contract intact)`, () => {
81
+ const shape = z.object(SCHEMAS[t.name]);
82
+ const r = shape.safeParse({ [t.historic]: VALID_ID, ...t.extra });
83
+ expect(r.success).toBe(true);
84
+ });
85
+
86
+ test(`${t.name}: added alias '${t.added}' parses (was -32602 pre-#1281 → RED without the fix)`, () => {
87
+ const shape = z.object(SCHEMAS[t.name]);
88
+ const r = shape.safeParse({ [t.added]: VALID_ID, ...t.extra });
89
+ expect(r.success).toBe(true);
90
+ });
91
+
92
+ test(`${t.name}: both names present + equal parses (schema layer permissive; conflict caught in handler)`, () => {
93
+ const shape = z.object(SCHEMAS[t.name]);
94
+ const r = shape.safeParse({ node_id: VALID_ID, child_node_id: VALID_ID, ...t.extra });
95
+ expect(r.success).toBe(true);
96
+ });
97
+ }
98
+ });
99
+
100
+ describe("#1281 resolveNodeIdArg — the single resolution helper", () => {
101
+ test("node_id only → resolves to node_id", () => {
102
+ expect(resolveNodeIdArg({ node_id: "node_a" })).toEqual({ ok: true, node_id: "node_a" });
103
+ });
104
+ test("child_node_id only → resolves to it (back-compat)", () => {
105
+ expect(resolveNodeIdArg({ child_node_id: "node_b" })).toEqual({ ok: true, node_id: "node_b" });
106
+ });
107
+ test("both present + EQUAL → resolves (no conflict)", () => {
108
+ expect(resolveNodeIdArg({ node_id: "node_c", child_node_id: "node_c" })).toEqual({ ok: true, node_id: "node_c" });
109
+ });
110
+ test("both present + DIFFERENT → node_id_conflict (catches A/B fat-finger)", () => {
111
+ const r = resolveNodeIdArg({ node_id: "node_a", child_node_id: "node_b" });
112
+ expect(r.ok).toBe(false);
113
+ expect((r as any).error).toBe("node_id_conflict");
114
+ });
115
+ test("neither present → node_id_required", () => {
116
+ const r = resolveNodeIdArg({});
117
+ expect(r.ok).toBe(false);
118
+ expect((r as any).error).toBe("node_id_required");
119
+ });
120
+ });
121
+
122
+ describe("#1281 real handler path — stop_node routes both names to the same place", () => {
123
+ // stop_node calls resolveNodeIdArg FIRST, then resolveDaemonForChild. A
124
+ // fabricated id has no create-request row → daemon_not_resolvable. The
125
+ // point is that BOTH names get PAST the id gate to that same downstream
126
+ // error (proving the alias flows through), and the two error branches fire.
127
+ test("node_id (new name) flows through → daemon_not_resolvable, NOT node_id_required", async () => {
128
+ const r = await call("stop_node", { node_id: VALID_ID });
129
+ expect(r.ok).toBe(false);
130
+ expect(r.error).toBe("daemon_not_resolvable");
131
+ });
132
+ test("child_node_id (old name) flows through → daemon_not_resolvable (back-compat intact)", async () => {
133
+ const r = await call("stop_node", { child_node_id: VALID_ID });
134
+ expect(r.ok).toBe(false);
135
+ expect(r.error).toBe("daemon_not_resolvable");
136
+ });
137
+ test("neither → node_id_required (before any daemon resolution)", async () => {
138
+ const r = await call("stop_node", { force: true });
139
+ expect(r.ok).toBe(false);
140
+ expect(r.error).toBe("node_id_required");
141
+ });
142
+ test("both differ → node_id_conflict (before any daemon resolution)", async () => {
143
+ const r = await call("stop_node", { node_id: "node_a", child_node_id: "node_b" });
144
+ expect(r.ok).toBe(false);
145
+ expect(r.error).toBe("node_id_conflict");
146
+ });
147
+ });
148
+
149
+ describe("app#196 —— 「解析不到 daemon」的两种情况必须给不同的话", () => {
150
+ // 🔴 生产实测(2026-08-28):218 个节点里 207 个是 `n_…`(有人直接
151
+ // `anet node start` 起的),它们**根本没有 daemon**。原先这两种情况
152
+ // 打印同一句 `pass daemon_node_id explicitly` —— 对那 207 个来说,
153
+ // **这句建议的前提是假的**,照着做只会走进死路。
154
+ const HAND_STARTED = "n_73687f17"; // 手工起的节点,真实形状
155
+ const DAEMON_CHILD = "node_test_1281"; // daemon 建的子节点
156
+
157
+ test("手工起的节点 → not_daemon_managed,并且**不**建议传 daemon_node_id", async () => {
158
+ const r = await call("stop_node", { node_id: HAND_STARTED });
159
+ expect(r.ok).toBe(false);
160
+ expect(r.error).toBe("not_daemon_managed");
161
+ // 🔴 这一条是本组的核心:旧文案的那句建议**不能**再出现在这条路径上。
162
+ expect(r.message).not.toContain("pass daemon_node_id explicitly");
163
+ // 而且要告诉他真正能走的那条路。
164
+ expect(r.message).toContain("anet node stop");
165
+ });
166
+
167
+ test("daemon 建的子节点(无 create-request 行) → 仍是 daemon_not_resolvable", async () => {
168
+ const r = await call("stop_node", { node_id: DAEMON_CHILD });
169
+ expect(r.ok).toBe(false);
170
+ expect(r.error).toBe("daemon_not_resolvable");
171
+ expect(r.message).toContain("pass daemon_node_id explicitly");
172
+ });
173
+
174
+ test("delete_node 走同一条判别(两个调用点不能只改一个)", async () => {
175
+ const r = await call("delete_node", { node_id: HAND_STARTED, confirm_alias: "x" });
176
+ expect(r.ok).toBe(false);
177
+ expect(r.error).toBe("not_daemon_managed");
178
+ });
179
+
180
+ // 🔴 反向见证:两种情况必须**真的不同**。若有人把判别改成恒定一支,
181
+ // 上面三条里至少一条会红 —— 但那还不够明显,所以这里直接断言两者不等。
182
+ test("两种情况的 error 与 message 都不相同", async () => {
183
+ const a = await call("stop_node", { node_id: HAND_STARTED });
184
+ const b = await call("stop_node", { node_id: DAEMON_CHILD });
185
+ expect(a.error).not.toBe(b.error);
186
+ expect(a.message).not.toBe(b.message);
187
+ });
188
+ });
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ childNodeIdForCreateRequest,
4
+ buildControllableMap,
5
+ isLifecycleControllable,
6
+ } from "./node-lifecycle-controllable.js";
7
+
8
+ // app#196 —— 桌面端对 218 个节点里的 207 个(95%)显示了「停止/重启/删除」,
9
+ // 而那三个工具的 schema 是 regex(/^node_[a-z0-9_-]+$/),那 207 个是 n_ 开头,
10
+ // 点下去只会看到一段 zod 正则原文。
11
+ describe("childNodeIdForCreateRequest —— 推导规则", () => {
12
+ test("cr_ 前缀 → node_ 前缀,其余部分逐字保留", () => {
13
+ // 🔴 这一对是 2026-08-28 生产 hub 日志里真实的 create-node finalize 行,
14
+ // 不是我按注释造的。
15
+ expect(childNodeIdForCreateRequest("cr_59723b24-8372-4270-9b3c-1552e592a09f"))
16
+ .toBe("node_59723b24-8372-4270-9b3c-1552e592a09f");
17
+ });
18
+ test("不是 cr_ 开头的一律 null —— 不猜、不截断", () => {
19
+ expect(childNodeIdForCreateRequest("node_abc")).toBeNull();
20
+ expect(childNodeIdForCreateRequest("n_abc")).toBeNull();
21
+ expect(childNodeIdForCreateRequest("abc")).toBeNull();
22
+ expect(childNodeIdForCreateRequest("cr_")).toBeNull(); // 只有前缀,没有主体
23
+ });
24
+ test("非字符串输入不抛,返回 null", () => {
25
+ for (const v of [undefined, null, 123, {}, []]) {
26
+ expect(childNodeIdForCreateRequest(v as any)).toBeNull();
27
+ }
28
+ });
29
+ });
30
+
31
+ describe("isLifecycleControllable —— 判据不是 id 前缀", () => {
32
+ const map = buildControllableMap([
33
+ { request_id: "cr_aaa", daemon_node_id: "node_daemon_x" },
34
+ { request_id: "cr_bbb", daemon_node_id: "node_daemon_y" },
35
+ { request_id: "not-a-request", daemon_node_id: "node_daemon_z" }, // 应被丢弃
36
+ ]);
37
+
38
+ test("有创建记录的 → 可控", () => {
39
+ expect(isLifecycleControllable("node_aaa", null, map)).toBe(true);
40
+ expect(map.get("node_aaa")).toBe("node_daemon_x");
41
+ });
42
+
43
+ test("🔴 光有 node_ 前缀但没有创建记录 → 不可控", () => {
44
+ // 这条是本模块存在的理由:前缀是实现细节,不是判据。
45
+ expect(isLifecycleControllable("node_never_created", null, map)).toBe(false);
46
+ });
47
+
48
+ test("手工起的 n_ 节点 → 不可控(那 207 个)", () => {
49
+ expect(isLifecycleControllable("n_22b777bf", null, map)).toBe(false);
50
+ });
51
+
52
+ test("🔴 daemon 自己可控 —— 它在 node_create_requests 里没有记录", () => {
53
+ // 漏掉这条会把 3 个 daemon 从可控名单里误删。
54
+ expect(isLifecycleControllable("node_daemon_39bd1eeae3ee", "host_supervisor", map)).toBe(true);
55
+ // 正控:同一个 id 但 role 不是 host_supervisor ⇒ 仍不可控,
56
+ // 证明上面那条过是因为 role,不是因为 id 恰好带 node_ 前缀。
57
+ expect(isLifecycleControllable("node_daemon_39bd1eeae3ee", null, map)).toBe(false);
58
+ });
59
+
60
+ test("坏的 request_id 不会污染映射", () => {
61
+ expect(map.has("node_not-a-request")).toBe(false);
62
+ expect(map.size).toBe(2);
63
+ });
64
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * app#196 —— 哪些节点能走 stop_node / restart_node / delete_node。
3
+ *
4
+ * 抽成独立模块而不是写在 /api/nodes 的路由里,因为路由里的内联逻辑测不到 ——
5
+ * 现有的 api-nodes-shape.test.ts 是直跑 handler 的 SQL,拿不到映射步骤。
6
+ * 🔴 「不可测」本身就是一个设计信号,不是测试的问题。
7
+ */
8
+
9
+ /** 子节点 id 由创建请求 id 确定性推导。
10
+ *
11
+ * 🔴 这条规则我用真实一对验过,不是只读注释:
12
+ * cr_59723b24-8372-4270-9b3c-1552e592a09f → node_59723b24-8372-4270-9b3c-…
13
+ * (2026-08-28 生产 hub 日志的 `create-node finalize` 行)
14
+ *
15
+ * 非 `cr_` 开头的一律返回 null —— 不猜,不截断。 */
16
+ export function childNodeIdForCreateRequest(requestId: unknown): string | null {
17
+ if (typeof requestId !== "string") return null;
18
+ if (!requestId.startsWith("cr_")) return null;
19
+ const rest = requestId.slice(3);
20
+ if (!rest) return null;
21
+ return `node_${rest}`;
22
+ }
23
+
24
+ /** 由创建记录建出「child node_id → daemon node_id」的映射。 */
25
+ export function buildControllableMap(
26
+ createRequests: Array<{ request_id?: unknown; daemon_node_id?: unknown }>,
27
+ ): Map<string, string> {
28
+ const m = new Map<string, string>();
29
+ for (const cr of createRequests) {
30
+ const child = childNodeIdForCreateRequest(cr?.request_id);
31
+ if (!child) continue;
32
+ m.set(child, typeof cr?.daemon_node_id === "string" ? cr.daemon_node_id : "");
33
+ }
34
+ return m;
35
+ }
36
+
37
+ /** 一个节点能不能走生命周期操作。
38
+ *
39
+ * 🔴 判据**不是 id 前缀**。前缀是实现细节;判据是 node_create_requests 里真有创建记录。
40
+ *
41
+ * 例外:daemon 自己(role=host_supervisor)也能被 restart/stop,而它在
42
+ * node_create_requests 里**没有**记录(它不是被别的 daemon 创建的)—— 单独放行。
43
+ * 漏掉这一条会把可控的 daemon 判成不可控。 */
44
+ export function isLifecycleControllable(
45
+ nodeId: unknown,
46
+ role: unknown,
47
+ controllable: Map<string, string>,
48
+ ): boolean {
49
+ if (role === "host_supervisor") return true;
50
+ return typeof nodeId === "string" && controllable.has(nodeId);
51
+ }