@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
package/src/tools.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { parseDbTimestampMs } from "./db-timestamp.js";
2
3
  import { z } from "zod/v4";
3
- import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken } from "./db.js";
4
- import { pushEvent } from "./push.js";
4
+ import { parseAliasFilter } from "./alias-filter.js";
5
+ import { createHash } from "node:crypto";
6
+ import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
7
+ import { getSSEStats, hasSubscribers, hasUserSubscribers, pushEvent, pushNetworkObserverEvent, pushUserEvent } from "./push.js";
8
+ import { assertNodeActive } from "./lifecycle-guard.js";
9
+ import { pendingInboxCount } from "./inbox-count.js";
5
10
  import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
11
+ import { canRestWriteNetwork, getUserNetworkIds, singleNetworkId } from "./network-scope.js";
6
12
  import {
7
13
  buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
8
14
  validateName as validateChildName,
@@ -22,12 +28,14 @@ import {
22
28
  startPendingEnvGcTimer,
23
29
  startSweeperTimer,
24
30
  auditCreateNode,
31
+ auditCreateNodeStrict,
25
32
  resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
26
33
  } from "./create-node.js";
27
34
  import {
28
35
  vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
29
36
  VaultError,
30
37
  } from "./vault.js";
38
+ import { stripHostLocalPathsForCrossHostSafe, validateAttachments } from "./uploads.js";
31
39
  import {
32
40
  validateBaseUrl as _validateBaseUrl,
33
41
  SUPPORTED_VENDORS,
@@ -44,11 +52,17 @@ import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias
44
52
  import {
45
53
  ALLOWED_FLAGS,
46
54
  SECURITY_SENSITIVE_FLAGS,
55
+ EDITABLE_CHANNELS,
47
56
  computeApplyMode,
48
57
  validatePatch,
58
+ narrowChannelsPatch,
49
59
  isAllowedToChangeFlag,
50
60
  } from "./config-apply-validate.js";
51
61
  import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
62
+ import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
63
+ import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
64
+ import { parseHubTimestamp } from "./hub-timestamp";
65
+ import { noteTerminalResultRead, sweepNodeRequestContent } from "./node-request-retention.js";
52
66
 
53
67
  function ts(): string {
54
68
  return new Date().toTimeString().slice(0, 8);
@@ -61,7 +75,55 @@ function parseMetaJson(value: unknown): unknown | null {
61
75
 
62
76
  function normalizeMetaJson(meta: unknown): string | null {
63
77
  if (!meta || typeof meta !== "object") return null;
64
- try { return JSON.stringify(meta); } catch { return null; }
78
+ // #222 cross-host safety — strip `path` from meta.attachments[] when
79
+ // `file_id` is also present. Shared helper lives in uploads.ts so the
80
+ // REST handler in index.ts can apply the identical sanitization
81
+ // (otherwise REST and MCP transports would have different meta_json
82
+ // shapes for the same send).
83
+ try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
84
+ }
85
+
86
+ // ── #1281 — 子节点生命周期工具的参数名统一 ────────────────────────────
87
+ //
88
+ // stop_node / start_node / delete_node 历史上用 `child_node_id`(RFC-027),
89
+ // restart_node / update_node_config 用 `node_id`。同一个对象(子节点)两个名字,
90
+ // 调用方按一个工具的习惯给另一个传就吃 -32602。两侧都已是**已发布契约**
91
+ // (start_node 随 #1273 合入),且 hub API 在 RFC-030 混版滚动期老客户端还在
92
+ // 发老名——所以不能硬改名,改为**两个名字都接受**:
93
+ // · canonical = `node_id`(= DB 列 nodes.node_id、= create_node 的输出、
94
+ // restart/update 历史用名;「建完节点→再操作它」用同一个名才是傻瓜式)。
95
+ // · `child_node_id` 作为**兼容 alias 保留** + deprecation 注释;不加运行时
96
+ // warning(老客户端是设计内兼容,不是错误,别刷屏)。
97
+ // create_node 的 `daemon_node_id` 指宿主 daemon,是另一层语义,不在本次统一内。
98
+ //
99
+ // server.tool 收的是 ZodRawShape(裸对象),挂不了 .refine(),所以「至少一个
100
+ // 必填」+「两个都传须相等」在 handler 侧用 resolveNodeIdArg 兜——返回结构化
101
+ // ok:false 反而比 schema 层的 -32602 更友好。两个名字在 schema 层都做成
102
+ // optional + 宽松 .min(1).max(200)(不带 regex),使命名与校验强度同时统一:
103
+ // 对 restart/update 不变(本就宽松),对 stop/start/delete 是放宽(原 regex 会
104
+ // 在 schema 层挡掉 alias/杂串,放宽后落到查表返回 node_not_found,对合法调用
105
+ // 方无影响,只是错误形态更一致)。
106
+ const NODE_ID_ALIAS_FIELDS = {
107
+ node_id: z.string().min(1).max(200).optional()
108
+ .describe("Target child node ID (canonical, #1281). The value create_node returns. Must be in caller's network."),
109
+ child_node_id: z.string().min(1).max(200).optional()
110
+ .describe("DEPRECATED alias for node_id (#1281): kept for back-compat with pre-unification clients; prefer node_id."),
111
+ } as const;
112
+
113
+ /** #1281 — 把 node_id / child_node_id 收敛成单一内部 id。两个都传且不等 ⇒
114
+ * node_id_conflict(挡「node_id 填 A、child_node_id 填 B」的手滑,比单纯二选一强);
115
+ * 都不传 ⇒ node_id_required。纯函数,模块级导出以便单测直接验解析逻辑。 */
116
+ export function resolveNodeIdArg(a: { node_id?: string; child_node_id?: string }):
117
+ { ok: true; node_id: string } | { ok: false; error: string; message: string } {
118
+ const n = a.node_id, c = a.child_node_id;
119
+ if (n && c && n !== c) {
120
+ return { ok: false, error: "node_id_conflict", message: "node_id and child_node_id both provided but differ; send only one (they are aliases for the same child node)" };
121
+ }
122
+ const id = n ?? c;
123
+ if (!id) {
124
+ return { ok: false, error: "node_id_required", message: "one of node_id / child_node_id is required (child_node_id is a deprecated alias; prefer node_id)" };
125
+ }
126
+ return { ok: true, node_id: id };
65
127
  }
66
128
 
67
129
  export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null) {
@@ -87,30 +149,213 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
87
149
  }],
88
150
  };
89
151
  };
90
- // If enforceNetworkId is set, override any client-supplied network_id
91
- const getNetworkId = (clientNetId?: string | null) => enforceNetworkId ?? clientNetId ?? null;
152
+ // MCP-side auth context in the shape network-scope.ts helpers take.
153
+ // null = legacy global-token / open-dev mode (unscoped, allow-all).
154
+ const mcpAuthCtx = enforceUserId ? { userId: enforceUserId, networkId: enforceNetworkId ?? null } : null;
155
+
156
+ // If enforceNetworkId is set (ntok_), override any client-supplied
157
+ // network_id. #517: for utok_ with no explicit network_id, fall back to
158
+ // the user's single membership via the SAME singleNetworkId used by REST
159
+ // POST /api/task (network-scope.ts) — utok_ rows carry network_id=null by
160
+ // design, so without this fallback single-network nodes could read
161
+ // everything but write nothing. Multi-network stays null (ambiguous) and
162
+ // canWrite rejects with network_id_required.
163
+ const getNetworkId = (clientNetId?: string | null) => {
164
+ const explicit = enforceNetworkId ?? clientNetId ?? null;
165
+ if (explicit !== null || !enforceUserId) return explicit;
166
+ return singleNetworkId({ networkId: null, networkIds: getUserNetworkIds(enforceUserId) });
167
+ };
92
168
 
93
- // Check write access. For ntok_ the network is enforced by the token.
94
- // For utok_ (no enforced network) we accept the network_id supplied in the
95
- // request and verify the user has a write role on it.
169
+ // Check write access — delegates to the shared canRestWriteNetwork so MCP
170
+ // and REST cannot drift again (#517). isAdmin=false: MCP has no admin
171
+ // bypass today; keep behavior identical to before the extraction.
96
172
  const canWrite = (effectiveNetworkId?: string | null): boolean => {
97
- if (!enforceUserId) return true; // legacy global token mode, allow
98
173
  const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
99
- if (!netId) return false; // no network resolvable
100
- const role = getUserNetworkRole(enforceUserId, netId);
101
- return !!role && role !== "viewer"; // owner/admin/member can write
174
+ return canRestWriteNetwork(mcpAuthCtx, netId, false);
102
175
  };
103
176
 
177
+ // #517: name the REAL cause. The old catch-all blamed permissions for
178
+ // what was actually an unresolvable network, sending operators down the
179
+ // wrong debugging path (roles/membership) for hours.
104
180
  const writeDeniedReply = (effectiveNetworkId?: string | null, action = "write") => {
105
181
  const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
106
- const message = !netId
107
- ? "network_id required (utok current_network is null; pass explicit network_id from /api/auth/me networks[0].network_id)"
108
- : action === "send_task"
182
+ let error: string;
183
+ let message: string;
184
+ if (!netId) {
185
+ const memberships = enforceUserId ? getUserNetworkIds(enforceUserId) : [];
186
+ error = "network_id_required";
187
+ message = memberships.length === 0
188
+ ? "user token has no network memberships; join or create a network first"
189
+ : `user token spans ${memberships.length} networks; pass network_id explicitly (see /api/auth/me networks[].network_id)`;
190
+ } else if (enforceUserId && !getUserNetworkRole(enforceUserId, netId)) {
191
+ error = "access_denied";
192
+ message = "access denied to requested network (not a member)";
193
+ } else {
194
+ error = "permission_denied";
195
+ message = action === "send_task"
109
196
  ? "Viewer role cannot send tasks"
110
197
  : "Viewer role cannot write to this network";
111
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied", message }) }] };
198
+ }
199
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error, message }) }] };
112
200
  };
113
201
 
202
+ const skillHubReply = (value: Record<string, unknown>) => ({
203
+ content: [{ type: "text" as const, text: JSON.stringify(value) }],
204
+ });
205
+
206
+ // SkillHub is a network registry, not a public filesystem. Content is an
207
+ // immutable SKILL.md snapshot; identity comes only from the authenticated
208
+ // MCP principal (never from request fields).
209
+ server.tool(
210
+ "submit_skill",
211
+ "Submit an immutable SKILL.md version to the caller's network SkillHub. Node identity is token-bound. New submissions require review.",
212
+ {
213
+ slug: z.string().min(2).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
214
+ name: z.string().min(1).max(120),
215
+ description: z.string().max(1000).optional(),
216
+ version: z.string().min(1).max(40).regex(/^[0-9A-Za-z]+(?:[._-][0-9A-Za-z]+)*$/),
217
+ content: z.string().min(1).max(128 * 1024).refine(value => !value.includes("\0"), "content must not contain NUL bytes"),
218
+ network_id: z.string().max(200).optional(),
219
+ },
220
+ async ({ slug, name, description, version, content, network_id: clientNetId }) => {
221
+ const effectiveNetId = getNetworkId(clientNetId);
222
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
223
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
224
+ const sourceType = callerTokenIsNetwork ? "node" : "user";
225
+ if (sourceType === "node" && !callerAlias) return skillHubReply({ ok: false, error: "node_identity_unbound" });
226
+ const contentHash = createHash("sha256").update(content, "utf8").digest("hex");
227
+ const existing = db.get<any>(
228
+ `SELECT skill_id, content_hash, status FROM skillhub_skills
229
+ WHERE network_id = ?1 AND slug = ?2 AND version = ?3`,
230
+ effectiveNetId, slug, version,
231
+ );
232
+ if (existing) {
233
+ if (existing.content_hash === contentHash) {
234
+ return skillHubReply({ ok: true, idempotent: true, skill_id: existing.skill_id, status: existing.status });
235
+ }
236
+ return skillHubReply({ ok: false, error: "skill_version_conflict", hint: "publish changed content under a new version" });
237
+ }
238
+ const skillId = `skill_${uuidv4()}`;
239
+ try {
240
+ db.run(
241
+ `INSERT INTO skillhub_skills
242
+ (skill_id, network_id, slug, name, description, version, content, content_hash, status, source_type, source_alias, created_by_user)
243
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
244
+ [skillId, effectiveNetId, slug, name.trim(), description?.trim() || "", version, content, contentHash, sourceType, callerAlias, enforceUserId || null],
245
+ );
246
+ } catch (error: any) {
247
+ // Two submitters may pass the preflight SELECT together. Resolve the
248
+ // UNIQUE race into the same deterministic idempotent/conflict contract
249
+ // instead of leaking a storage exception as HTTP/MCP 500.
250
+ if (!/unique|duplicate key/i.test(error?.message || "")) throw error;
251
+ const winner = db.get<any>(
252
+ `SELECT skill_id, content_hash, status FROM skillhub_skills
253
+ WHERE network_id = ?1 AND slug = ?2 AND version = ?3`,
254
+ effectiveNetId, slug, version,
255
+ );
256
+ if (!winner) throw error;
257
+ if (winner.content_hash === contentHash) {
258
+ return skillHubReply({ ok: true, idempotent: true, skill_id: winner.skill_id, status: winner.status });
259
+ }
260
+ return skillHubReply({ ok: false, error: "skill_version_conflict", hint: "publish changed content under a new version" });
261
+ }
262
+ db.run(
263
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
264
+ VALUES (?1, ?2, 'skill_submit', 'skill', ?3, ?4, ?5)`,
265
+ [enforceUserId || null, callerAlias || null, skillId, JSON.stringify({ slug, version, source_type: sourceType }), effectiveNetId],
266
+ );
267
+ return skillHubReply({ ok: true, skill_id: skillId, status: "pending", source_type: sourceType, source_alias: callerAlias });
268
+ },
269
+ );
270
+
271
+ server.tool(
272
+ "list_skills",
273
+ "List published skills in a network. Owners/admins may include pending review items.",
274
+ {
275
+ network_id: z.string().max(200).optional(),
276
+ include_pending: z.boolean().optional(),
277
+ query: z.string().max(120).optional(),
278
+ limit: z.number().int().min(1).max(200).optional(),
279
+ },
280
+ async ({ network_id: clientNetId, include_pending, query, limit }) => {
281
+ const effectiveNetId = getNetworkId(clientNetId);
282
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "read");
283
+ const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
284
+ if (enforceUserId && !role) return writeDeniedReply(effectiveNetId, "read");
285
+ // An ntok_ belongs to a node even when it was minted by the network
286
+ // owner. Never inherit the owner's review power through that token.
287
+ const reviewer = !callerTokenIsNetwork && (role === "owner" || role === "admin");
288
+ const showPending = !!include_pending && reviewer;
289
+ const params: unknown[] = [effectiveNetId];
290
+ let sql = `SELECT skill_id, slug, name, description, version, status, source_type, source_alias,
291
+ created_at, updated_at, reviewed_at, review_note
292
+ FROM skillhub_skills WHERE network_id = ?1`;
293
+ if (!showPending) sql += ` AND status = 'published'`;
294
+ if (query?.trim()) {
295
+ params.push(`%${query.trim()}%`);
296
+ sql += ` AND (slug LIKE ?${params.length} OR name LIKE ?${params.length} OR description LIKE ?${params.length})`;
297
+ }
298
+ sql += ` ORDER BY updated_at DESC LIMIT ${limit ?? 100}`;
299
+ return skillHubReply({ ok: true, reviewer, skills: db.all(sql, ...params) });
300
+ },
301
+ );
302
+
303
+ server.tool(
304
+ "get_skill",
305
+ "Read one SkillHub SKILL.md. Pending content is visible only to owners/admins.",
306
+ { skill_id: z.string().regex(/^skill_[A-Za-z0-9_-]+$/).max(200), network_id: z.string().max(200).optional() },
307
+ async ({ skill_id, network_id: clientNetId }) => {
308
+ const effectiveNetId = getNetworkId(clientNetId);
309
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "read");
310
+ const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
311
+ if (enforceUserId && !role) return writeDeniedReply(effectiveNetId, "read");
312
+ const row = db.get<any>(
313
+ `SELECT skill_id, slug, name, description, version, content, status,
314
+ source_type, source_alias, created_at, updated_at, reviewed_at, review_note
315
+ FROM skillhub_skills WHERE skill_id = ?1 AND network_id = ?2`,
316
+ skill_id, effectiveNetId,
317
+ );
318
+ if (!row) return skillHubReply({ ok: false, error: "skill_not_found" });
319
+ const reviewer = !callerTokenIsNetwork && (role === "owner" || role === "admin");
320
+ if (row.status !== "published" && !reviewer) {
321
+ return skillHubReply({ ok: false, error: "skill_not_found" });
322
+ }
323
+ return skillHubReply({ ok: true, skill: row });
324
+ },
325
+ );
326
+
327
+ server.tool(
328
+ "review_skill",
329
+ "Publish or reject a pending SkillHub submission. Network owner/admin only.",
330
+ {
331
+ skill_id: z.string().regex(/^skill_[A-Za-z0-9_-]+$/).max(200),
332
+ decision: z.enum(["published", "rejected"]),
333
+ note: z.string().max(1000).optional(),
334
+ network_id: z.string().max(200).optional(),
335
+ },
336
+ async ({ skill_id, decision, note, network_id: clientNetId }) => {
337
+ const effectiveNetId = getNetworkId(clientNetId);
338
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
339
+ const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
340
+ if (callerTokenIsNetwork || (role !== "owner" && role !== "admin")) return skillHubReply({ ok: false, error: "skill_review_admin_required" });
341
+ const row = db.get<any>(`SELECT status FROM skillhub_skills WHERE skill_id = ?1 AND network_id = ?2`, skill_id, effectiveNetId);
342
+ if (!row) return skillHubReply({ ok: false, error: "skill_not_found" });
343
+ if (row.status !== "pending") return skillHubReply({ ok: false, error: "skill_not_pending", status: row.status });
344
+ const updated = db.run(
345
+ `UPDATE skillhub_skills SET status = ?1, review_note = ?2, reviewed_by_user = ?3,
346
+ reviewed_at = datetime('now'), updated_at = datetime('now') WHERE skill_id = ?4 AND network_id = ?5 AND status = 'pending'`,
347
+ [decision, note?.trim() || null, enforceUserId || null, skill_id, effectiveNetId],
348
+ );
349
+ if (updated.changes !== 1) return skillHubReply({ ok: false, error: "skill_not_pending" });
350
+ db.run(
351
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
352
+ VALUES (?1, ?2, 'skill_review', 'skill', ?3, ?4, ?5)`,
353
+ [enforceUserId || null, callerAlias || null, skill_id, JSON.stringify({ decision }), effectiveNetId],
354
+ );
355
+ return skillHubReply({ ok: true, skill_id, status: decision });
356
+ },
357
+ );
358
+
114
359
  const addScope = (sql: string, params: any[], networkId?: string | null, column = "network_id"): string => {
115
360
  if (!networkId) return sql;
116
361
  sql += ` AND ${column} = ?${params.length + 1}`;
@@ -118,15 +363,25 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
118
363
  return sql;
119
364
  };
120
365
 
366
+ /** #1548 —— 把一个还标着 blocked 的会话拉回 idle。只在「它刚发出终态回复 / 刚派了任务」这两个活性证据处调用。 */
367
+ const releaseBlockedSession = (alias: string | null | undefined, networkId: string | null | undefined): void => {
368
+ if (!alias) return;
369
+ const params: any[] = [alias];
370
+ let sql = "UPDATE sessions SET status = 'idle', updated_at = datetime('now') WHERE alias = ?1 AND status = 'blocked'";
371
+ sql = addScope(sql, params, networkId ?? null);
372
+ try {
373
+ db.run(sql, params);
374
+ } catch {
375
+ // 展示层的一次修正失败不能影响主路径。
376
+ }
377
+ };
378
+
121
379
  type ReadScope = { networkId?: string | null; networkIds?: string[] | null; denied?: string };
122
380
 
123
- const getReadableNetworkIds = (): string[] => {
124
- if (!enforceUserId) return [];
125
- return db.all<{ network_id: string }>(
126
- "SELECT network_id FROM network_members WHERE user_id = ?1",
127
- enforceUserId
128
- ).map((row) => row.network_id);
129
- };
381
+ // Delegates to the shared membership query (network-scope.ts) — was a
382
+ // byte-for-byte duplicate of getUserNetworkIds before #517.
383
+ const getReadableNetworkIds = (): string[] =>
384
+ enforceUserId ? getUserNetworkIds(enforceUserId) : [];
130
385
 
131
386
  const resolveReadScope = (clientNetId?: string | null): ReadScope => {
132
387
  if (!enforceUserId) return { networkId: clientNetId ?? null, networkIds: null };
@@ -141,6 +396,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
141
396
  return { networkId: null, networkIds: getReadableNetworkIds() };
142
397
  };
143
398
 
399
+ // RFC-027 §2.3 race-free invariant — assertNodeActive lives in
400
+ // server/src/lifecycle-guard.ts so REST handlers in server/src/index.ts
401
+ // can use the SAME code path. PR1.1 had it inline here; PR1.2a
402
+ // (#346 review catch) extracted because the closure scope made it
403
+ // unreachable from REST and left the §2.3 race open on dashboard
404
+ // Dispatch (POST /api/task + /api/broadcast). Per
405
+ // per team rule: grep every write site (MCP tools.ts AND REST index.ts) before adding a guard, and extract the helper into a shared module so both transports import it.
406
+
144
407
  const addReadScope = (sql: string, params: any[], scope: ReadScope, column = "network_id"): string => {
145
408
  if (scope.networkId) {
146
409
  sql += ` AND ${column} = ?${params.length + 1}`;
@@ -161,6 +424,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
161
424
  | { state: "offline"; alias: string; session: any; message: string }
162
425
  | { state: "not_found"; alias: string; message: string };
163
426
 
427
+ // Only call after the authenticated, network-scoped lookup resolved a
428
+ // concrete target. Error paths intentionally omit this identity object.
429
+ const actualTo = (target: Exclude<DeliveryTarget, { state: "not_found" }>, networkId?: string | null) => ({
430
+ alias: target.alias,
431
+ to_node_id: target.session?.node_id ?? null,
432
+ network_id: networkId ?? null,
433
+ });
434
+
164
435
  const scopedSessionStatus = (alias: string, networkId?: string | null) => {
165
436
  const params: any[] = [alias];
166
437
  let sql = "SELECT status, updated_at, last_seen_at, node_id FROM sessions WHERE alias = ?1";
@@ -261,9 +532,39 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
261
532
  model: z.string().max(200).optional().describe("AI model name"),
262
533
  node_name: z.string().max(200).optional().describe("Stable node display name (may differ from alias)"),
263
534
  network_id: z.string().max(200).optional().describe("Network this agent belongs to"),
535
+ // ╭─ 🔴 往 report_status 里加字段之前先读这一段(#1545 实测,zod 4.3.6)─────────╮
536
+ // │ **这份 schema 的子对象一半严一半松,而后果完全不同。**
537
+ // │
538
+ // │ 非 strict:host / process_telemetry / config_snapshot / daemon_capabilities
539
+ // │ `.strict()`:external_schedules(含其 schedules[] 元素)
540
+ // │ side_thread_capability(含其 exactBoundary)
541
+ // │
542
+ // │ 往**非 strict** 的对象里加一个 hub 还不认识的键 ⇒ zod **静默丢弃**。
543
+ // │ 节点不会掉线,但字段人间蒸发 —— 现场表现为「daemon 明明发了、hub 上没有」,
544
+ // │ 排查起来和 daemon 侧 bug 一模一样。
545
+ // │ 往**strict** 的对象里加同一个键 ⇒ `unrecognized_keys`,**整份 report_status 被拒**。
546
+ // │ 而 agent-node 的 register() 是 `await` 且无人 catch ⇒ **节点进程当场死掉**
547
+ // │ (#1225 就是这么全网躺倒的,见下面 host.ip 那段)。
548
+ // │
549
+ // │ 所以「新字段能不能 daemon 先发」**没有统一答案,取决于你往哪个子对象里加**:
550
+ // │ 非 strict → 可以先发(会丢,不会死);strict → hub 必须先合,否则升级即失联。
551
+ // │
552
+ // │ 🔴 别把这条读成「strict 更危险、都改成非 strict」。两者各有其位:
553
+ // │ external_schedules / side_thread_capability 是**有界快照**,strict 挡的是
554
+ // │ 「节点往里塞任意键」;daemon_capabilities 是**能力自述**,天然要向前兼容
555
+ // │ (旧 hub 见到新能力应当忽略而不是拒绝整份上报)。**加字段前先看清你在哪一边。**
556
+ // ╰──────────────────────────────────────────────────────────────────────────────╯
264
557
  host: z.object({
265
- hostname: z.string().max(200).optional(),
266
- ip: z.string().max(200).optional(),
558
+ // 🔴 `.nullable()` 不是防御性冗余,是**发送方声明的类型**:agent-node 的
559
+ // HostTelemetry 是 `ip: string | null`,`firstNonInternalIPv4()` 在没有
560
+ // 非回环 IPv4 的机器上返回 null(--network none 的容器、断网的笔记本、
561
+ // 部分 CI runner)。此前这里只有 `.optional()`,于是那台机器上的
562
+ // agent-node 一启动就收到 MCP -32602,而 register() 是 await 且无人 catch
563
+ // —— **整个节点进程当场死掉**,和 runtime 无关。
564
+ // 同一个对象里四个数值字段本来就写着 `.nullable().optional()`,
565
+ // 两个字符串字段是唯一的例外;正确写法一直在隔壁那一行。
566
+ hostname: z.string().max(200).nullable().optional(),
567
+ ip: z.string().max(200).nullable().optional(),
267
568
  cpu_load_1min: z.number().nullable().optional(),
268
569
  cpu_cores: z.number().nullable().optional(),
269
570
  mem_total_gb: z.number().nullable().optional(),
@@ -281,6 +582,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
281
582
  uptime_seconds: z.number().nullable().optional(),
282
583
  in_flight_count: z.number().nullable().optional(),
283
584
  }).optional().describe("Per-agent process telemetry reported by agent-node"),
585
+ external_schedules: z.object({
586
+ observed_at: z.string().datetime({ offset: true }).max(64),
587
+ schedules: z.array(z.object({
588
+ id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/),
589
+ name: z.string().min(1).max(200),
590
+ kind: z.enum(["cron", "systemd", "tmux", "playwright", "custom"]),
591
+ frequency: z.string().min(1).max(120),
592
+ last_run_at: z.string().datetime({ offset: true }).max(64).nullable(),
593
+ last_status: z.enum(["success", "failed", "running", "unknown"]),
594
+ last_error: z.string().max(500).nullable(),
595
+ next_run_at: z.string().datetime({ offset: true }).max(64).nullable(),
596
+ log_ref: z.string().min(1).max(255).nullable(),
597
+ enabled: z.boolean(),
598
+ // RFC-036 — only agent-node managed cron markers advertise write
599
+ // capability. Legacy/other kinds omit these fields and stay read-only.
600
+ editable: z.boolean().optional(),
601
+ revision: z.number().int().min(0).optional(),
602
+ }).strict()).max(64),
603
+ error: z.enum(["invalid_manifest", "unsafe_manifest", "read_failed"]).optional(),
604
+ }).strict().optional().describe("Bounded node-reported external schedule snapshot; never includes host paths or commands"),
284
605
  // RFC-024 B6 — masked snapshot of the node's effective config
285
606
  // (model + 6 dashboard-editable flags). Secrets ARE NOT in this
286
607
  // shape (env._envRef stays on host); the dashboard reads this
@@ -291,16 +612,120 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
291
612
  // can grey out remote-restart for them.
292
613
  config_snapshot: z.object({
293
614
  model: z.string().max(200).optional().nullable(),
294
- flags: z.record(z.unknown()).optional(),
615
+ flags: z.record(z.string(), z.unknown()).optional(),
295
616
  config_revision: z.number().int().min(0).optional(),
296
617
  config_update_capable: z.boolean().optional(),
618
+ peer_reply_inbox_capable: z.literal(true).optional(),
619
+ side_thread_capability: z.object({
620
+ supported: z.boolean(),
621
+ runtime: z.string().max(64),
622
+ runtimeVersion: z.string().max(64),
623
+ topology: z.string().max(64),
624
+ evidenceRevision: z.string().max(100),
625
+ mode: z.literal("native-exact-fork").optional(),
626
+ exactBoundary: z.object({ through: z.boolean(), before: z.boolean() }).strict().optional(),
627
+ reason: z.enum(["runtime", "version", "topology", "experimental-api", "exact-boundary"]).optional(),
628
+ }).strict().optional(),
297
629
  // RFC-026 P2 / #338 — daemon role surfaced for hub /api/nodes
298
630
  // discovery (#337 extracts this field). "host_supervisor" =
299
631
  // anet daemon. Default-stripping zod would drop this otherwise.
300
632
  role: z.string().max(64).optional().nullable(),
633
+ // RFC-026 §9.3 / #338 PR3 — daemon self-declare nested under
634
+ // `daemon_capabilities` (canonical shape per existing hub reads
635
+ // at tools.ts:2010/2075 — PR1/PR2 placed these top-level, hub
636
+ // never saw them, max_concurrent_children stayed default + the
637
+ // allowlists stayed unenforced. PR3 nit ① per 通信龙).
638
+ // Soft caps avoid abuse via attacker daemon.
639
+ // 🔴 这个对象**故意不是 `.strict()`** —— 能力自述必须向前兼容:
640
+ // 旧 hub 见到新 daemon 报的新能力,应当忽略那一格,而不是拒掉整份 report
641
+ // 把节点踢下线。代价是新键在 hub 升级前会被**静默丢弃**(所以新字段仍然
642
+ // 应当 hub 先合)。同一份 schema 里 side_thread_capability / external_schedules
643
+ // 是 `.strict()` 的,后果不同 —— 见 `host:` 上方那段方框注释。
644
+ daemon_capabilities: z.object({
645
+ runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
646
+ allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
647
+ max_concurrent_children: z.number().int().min(1).max(1000).optional(),
648
+ // #1353 —— daemon **当下**能不能创建节点。与 runtimes_supported 不是一回事:
649
+ // 后者是声明(我支持哪些 runtime),这个是实际能力。一个 daemon 可以声明支持
650
+ // 三种 runtime,同时因为 ANET_BIN pin 解析不出来而一种也创建不了。
651
+ //
652
+ // 🔴 为什么 hub 需要看见它:pin 只存在于 /etc/anet-daemon/path.conf 或
653
+ // 显式开启的环境变量里,**重启不带环境就会丢**。丢了之后 daemon 照常注册、
654
+ // 在线、收 doorbell,hub 返回 ok:true + request_id —— 而节点永远不出现。
655
+ // 在此之前 hub 完全看不出区别,Dashboard 的「选服务器」还会把它列为可选。
656
+ can_create_nodes: z.boolean().optional(),
657
+ // 只收**代码**,不收原始报错文本 —— 上游 unsafePathHelp() 的消息里带完整机器路径,
658
+ // 而这个字段会走到 Dashboard。enum 而不是 string:一个自由文本字段等于给
659
+ // 「把路径塞进来」留了口子。
660
+ // #1353 —— 四类失败,修法完全不同:
661
+ // identity 重装 anet / unset ANET_BIN_ABS
662
+ // source 写 /etc/anet-daemon/path.conf(**要 sudo**)
663
+ // shape 换成 realpath
664
+ // permission **一行 chmod go-w**
665
+ // 混成一条会让人修错方向 —— 2026-08-28 我一天里撞到其中两类,看到的是同一条错误。
666
+ //
667
+ // 🔴 `anet_bin_pin_unresolved` **必须留着**:已经在跑的 agent-node@2.5.0-preview.40
668
+ // 发的就是这个值。zod 对象里任何一个字段验证失败会让**整个 report_status 被拒**,
669
+ // 不只是丢掉这一格 —— 那会让所有 .40 daemon 在 hub 上变成失联。
670
+ // 删一个 enum 值的代价,远大于多留一个。
671
+ // 🔴 仍然是 enum 不是 string:自由文本字段就是给机器路径留的口子,
672
+ // 而上游 e.message 里带的正是完整路径。
673
+ create_nodes_blocked_reason: z.enum([
674
+ "anet_bin_pin_unresolved", // 兼容:.40 及更早
675
+ "anet_bin_identity",
676
+ "anet_bin_source",
677
+ "anet_bin_shape",
678
+ "anet_bin_permission",
679
+ "anet_bin_unknown",
680
+ ]).optional(),
681
+ // #1545 —— 上面那一格**是什么时候测出来的**。
682
+ //
683
+ // 🔴 版本号一律带包名 + 完整版本:仓里三个包各自独立编号,裸的 `preview.NN`
684
+ // 同时是三个不同的时间点。此处曾写成「preview.67」——
685
+ // 那是 agent-network 的号,agent-node 根本没有 .67(npm 404,最高 .56)。
686
+ // 🔴 为什么必须有:**agent-node ≤ 2.5.0-preview.54** 的 `daemonCreateCapability()`
687
+ // 用一个进程级缓存(`_createCapCache`)只算一次。开机时 pin 坏 ⇒ 之后
688
+ // 永远上报 blocked(哪怕运维已经写好 path.conf);开机时好 ⇒ 之后把二进制
689
+ // chmod 掉也**永远上报 ready**。后者是朝「没问题」方向说谎。
690
+ // 而 hub 这边,一个 3 秒前测的 blocked 和一个三周前测的 blocked,
691
+ // 在 `last_seen_at` 上长得一模一样 —— 心跳是新的,那一格不是。
692
+ //
693
+ // 🔴 为什么是「多久以前」(时长)而不是绝对时间戳:这个值来自**节点自己的钟**。
694
+ // 时钟偏移下,绝对时间戳会算出一个既可能"永远新鲜"也可能"来自 1970"的年龄,
695
+ // 而且错的方向不可预测。时长对偏移免疫 —— hub 用自己的钟在收到时换算。
696
+ //
697
+ // 🔴 为什么不加 `.min()/.max()/.int()`:**这几个约束都会让整条 report_status 被拒**
698
+ // (zod 对象里任何一个已知字段验证失败 = 整份被拒,不是丢掉这一格),
699
+ // 而这一格是纯诊断信息,不值得拿一台节点的在线状态去换。
700
+ // 收得宽,**在读取处消毒**(见 /api/host-supervisors)。
701
+ // 这条不是我新立的:同一个 schema 上方 `anet_bin_pin_unresolved` 那条注释
702
+ // 记的就是同一次教训 —— 删一个 enum 值会让所有 .40 daemon 失联。
703
+ //
704
+ // 🔴 `.catch(null)` 不是装饰:光写 `z.number()` **仍然会拒 NaN**
705
+ // (实测:`z.number().nullable().optional()` 对 NaN 抛),而那意味着
706
+ // 一次算错的时长能让整台节点在 hub 上失联 —— 正是 #1225 里 host.ip
707
+ // 那个形状。`.catch(null)` 让这一格**在任何输入下都不可能拒掉整份 report**:
708
+ // 合法数字透传,其余一律变 null(读取侧当作「没报」)。
709
+ // 类型仍然是 number 而不是 unknown —— 别给一个 attacker daemon
710
+ // 留下"往快照里塞任意大对象"的口子(同 schema 上方 soft cap 的立场)。
711
+ //
712
+ // 不上报这一格 ≠ 0。旧 daemon 压根不发,读的人必须能把
713
+ // 「刚测的」「很久以前测的」「不知道」分成三件事说。
714
+ create_capability_observed_ms_ago: z.number().nullable().catch(null).optional(),
715
+ }).optional(),
301
716
  }).optional().describe("RFC-024 — masked node config snapshot"),
717
+ // app#225 follow-up — the reporting process answers the `rules_file`
718
+ // doorbell (agent-node, and the claude-code channel server node-server).
719
+ // Top-level instead of config_snapshot: claude-code sessions usually have
720
+ // no `nodes` row, and config_snapshot is persisted per node_id. Only a
721
+ // node token bound to this same alias can set it; see the UPDATE below.
722
+ rules_file_capable: z.literal(true).optional(),
723
+ // Node skills view — same doorbell, ops skills_list / skill_read.
724
+ skills_capable: z.literal(true).optional(),
725
+ // Project folder view — same doorbell, ops files_list / file_read.
726
+ files_capable: z.literal(true).optional(),
302
727
  },
303
- async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, config_snapshot: cfgSnap }) => {
728
+ async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap, rules_file_capable: rulesFileCapable, skills_capable: skillsCapable, files_capable: filesCapable }) => {
304
729
  const effectiveNetId = getNetworkId(netId);
305
730
  const sessionNetId = effectiveNetId ?? "default";
306
731
  if (!callerTokenIsNetwork || !enforceNetworkId) {
@@ -338,6 +763,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
338
763
  };
339
764
  }
340
765
  }
766
+ // #203 identity guard — network tokens must not silently rebind their
767
+ // own name via report_status. Without this, a runtime whose ALIAS
768
+ // drifted (env leak / wrong --alias / CurrentAliasResolver seeded from
769
+ // the wrong node_id) could rewrite api_tokens.name and cause every
770
+ // subsequent send_task from this token to be attributed to the drifted
771
+ // alias — the observed #203 symptom (grokB's send arriving as
772
+ // from=grokA). Only the legit rename path (rename.ts) may cross the
773
+ // token→alias binding. Symmetric to fromIdentityMismatchReply on the
774
+ // send side (test198).
775
+ if (callerTokenIsNetwork && callerAlias && !canonical.renamed && effectiveAlias !== callerAlias) {
776
+ return {
777
+ content: [{
778
+ type: "text" as const,
779
+ text: JSON.stringify({
780
+ ok: false,
781
+ error: "alias_identity_mismatch",
782
+ message: "report_status alias does not match the token-bound node alias; use anet node rename to change identity",
783
+ token_alias: callerAlias,
784
+ reported_alias: effectiveAlias,
785
+ }),
786
+ }],
787
+ };
788
+ }
341
789
  console.log(`[${ts()}] ${effectiveAlias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
342
790
  if (callerTokenIsNetwork && callerTokenId) {
343
791
  try {
@@ -362,6 +810,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
362
810
  const processCpuPct = typeof proc?.cpu_pct === "number" ? proc.cpu_pct : null;
363
811
  const processUptimeSeconds = typeof proc?.uptime_seconds === "number" ? proc.uptime_seconds : null;
364
812
  const processInFlightCount = typeof proc?.in_flight_count === "number" ? proc.in_flight_count : null;
813
+ const externalSchedulesJson = externalSchedules === undefined ? null : JSON.stringify(externalSchedules);
814
+ const trustedCurrentSnapshot = node_id
815
+ ? trustedConfigSnapshotForNode(cfgSnap ?? null, callerTokenId ?? null, node_id)
816
+ : null;
817
+ const peerReplyInboxCapable = !!(
818
+ trustedCurrentSnapshot
819
+ && typeof trustedCurrentSnapshot === "object"
820
+ && (trustedCurrentSnapshot as Record<string, unknown>).peer_reply_inbox_capable === true
821
+ );
365
822
  const statusHostTelemetry = host ? {
366
823
  hostname: hostHostname,
367
824
  ip: hostIp,
@@ -384,10 +841,30 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
384
841
 
385
842
  db.transaction(() => {
386
843
  // Only delete same-alias sessions within the same network
844
+ // 同一 alias 可以有两个上报者轮流说话(agent-node 自己 `sdk-<node>` +
845
+ // grok 共存里 TUI 的 MCP `grok-cli-<node>`)。下面这条 DELETE 会把「另一个
846
+ // resume_id」的那行整行删掉,再 INSERT 一行新的 —— ON CONFLICT 里那一串
847
+ // COALESCE 在这条路径上一次都不会触发,于是上报里没带的列(version / agent /
848
+ // hostname / 六个遥测字段 / registered_at …)全部变 NULL。2026-09-04 DEV 真机:
849
+ // grok-v1 working 时 version=.64,7 秒后 idle 就成了 NULL,registered_at 被改成
850
+ // 那一秒。先把要被删的那行读出来,INSERT 之后把仍为 NULL 的列从它接手 —— 让「换 resume_id」
851
+ // 和「同 resume_id 更新」对描述性列的语义一致:上报了就覆盖,没上报就保留。
852
+ // 状态类列(status / task / output / progress / score)不接手,那是本次上报的事实。
853
+ const handover = db.get<Record<string, unknown>>(
854
+ `SELECT tmux_name, server, ip, hostname, agent, project_dir, version, node_id, session_id, config_path,
855
+ channels, model, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb,
856
+ disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct,
857
+ process_uptime_seconds, process_in_flight_count, external_schedules, registered_at
858
+ FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3
859
+ ORDER BY updated_at DESC LIMIT 1`,
860
+ effectiveAlias, resume_id, sessionNetId,
861
+ ) ?? null;
862
+ const keep = <T,>(fresh: T | null | undefined, column: string): T | null =>
863
+ (fresh ?? (handover?.[column] as T | null | undefined) ?? null);
387
864
  db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [effectiveAlias, resume_id, sessionNetId]);
388
865
  db.run(
389
- `INSERT INTO sessions (resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version, status, task, output, progress, score, node_id, session_id, config_path, channels, network_id, model, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb, disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count, last_seen_at, updated_at)
390
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, datetime('now'), datetime('now'))
866
+ `INSERT INTO sessions (resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version, status, task, output, progress, score, node_id, session_id, config_path, channels, network_id, model, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb, disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count, external_schedules, peer_reply_inbox_capable, last_seen_at, updated_at)
867
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32, ?33, ?34, ?35, datetime('now'), datetime('now'))
391
868
  ON CONFLICT(resume_id) DO UPDATE SET
392
869
  alias = COALESCE(?2, sessions.alias), tmux_name = COALESCE(?3, sessions.tmux_name),
393
870
  server = COALESCE(?4, sessions.server), ip = COALESCE(?5, sessions.ip),
@@ -412,9 +889,54 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
412
889
  process_cpu_pct = COALESCE(?31, sessions.process_cpu_pct),
413
890
  process_uptime_seconds = COALESCE(?32, sessions.process_uptime_seconds),
414
891
  process_in_flight_count = COALESCE(?33, sessions.process_in_flight_count),
892
+ external_schedules = COALESCE(?34, sessions.external_schedules),
893
+ peer_reply_inbox_capable = ?35,
415
894
  last_seen_at = datetime('now'), updated_at = datetime('now')`,
416
- [resume_id, effectiveAlias, tmux ?? null, srv ?? null, hostIp, hostHostname, ag ?? null, pd ?? null, ver ?? null, status, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, sessionNetId, mdl ?? null, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processRssMb, processCpuPct, processUptimeSeconds, processInFlightCount]
895
+ [resume_id, effectiveAlias, tmux ?? null, srv ?? null, hostIp, hostHostname, ag ?? null, pd ?? null, ver ?? null, status, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, sessionNetId, mdl ?? null, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processRssMb, processCpuPct, processUptimeSeconds, processInFlightCount, externalSchedulesJson, peerReplyInboxCapable ? 1 : 0]
417
896
  );
897
+ if (handover) {
898
+ // 换 resume_id 那条路径上 ON CONFLICT 不会触发;INSERT 完再把没上报(仍为 NULL)的
899
+ // 描述性列从被替换的那行接手。INSERT 语句与参数列表保持原样 —— test698 的变异
900
+ // 用 sed 钉着它的字节形状。
901
+ db.run(
902
+ `UPDATE sessions SET
903
+ tmux_name = COALESCE(tmux_name, ?2), server = COALESCE(server, ?3), ip = COALESCE(ip, ?4),
904
+ hostname = COALESCE(hostname, ?5), agent = COALESCE(agent, ?6), project_dir = COALESCE(project_dir, ?7),
905
+ version = COALESCE(version, ?8), node_id = COALESCE(node_id, ?9), session_id = COALESCE(session_id, ?10),
906
+ config_path = COALESCE(config_path, ?11), channels = COALESCE(channels, ?12), model = COALESCE(model, ?13),
907
+ cpu_load_1min = COALESCE(cpu_load_1min, ?14), cpu_cores = COALESCE(cpu_cores, ?15),
908
+ mem_total_gb = COALESCE(mem_total_gb, ?16), mem_used_gb = COALESCE(mem_used_gb, ?17),
909
+ mem_avail_gb = COALESCE(mem_avail_gb, ?18), disk_total_gb = COALESCE(disk_total_gb, ?19),
910
+ disk_used_gb = COALESCE(disk_used_gb, ?20), disk_avail_gb = COALESCE(disk_avail_gb, ?21),
911
+ process_rss_bytes = COALESCE(process_rss_bytes, ?22), process_rss_mb = COALESCE(process_rss_mb, ?23),
912
+ process_cpu_pct = COALESCE(process_cpu_pct, ?24), process_uptime_seconds = COALESCE(process_uptime_seconds, ?25),
913
+ process_in_flight_count = COALESCE(process_in_flight_count, ?26),
914
+ external_schedules = COALESCE(external_schedules, ?27),
915
+ registered_at = COALESCE(?28, registered_at)
916
+ WHERE resume_id = ?1`,
917
+ [resume_id, handover.tmux_name ?? null, handover.server ?? null, handover.ip ?? null, handover.hostname ?? null,
918
+ handover.agent ?? null, handover.project_dir ?? null, handover.version ?? null, handover.node_id ?? null,
919
+ handover.session_id ?? null, handover.config_path ?? null, handover.channels ?? null, handover.model ?? null,
920
+ handover.cpu_load_1min ?? null, handover.cpu_cores ?? null, handover.mem_total_gb ?? null, handover.mem_used_gb ?? null,
921
+ handover.mem_avail_gb ?? null, handover.disk_total_gb ?? null, handover.disk_used_gb ?? null, handover.disk_avail_gb ?? null,
922
+ handover.process_rss_bytes ?? null, handover.process_rss_mb ?? null, handover.process_cpu_pct ?? null,
923
+ handover.process_uptime_seconds ?? null, handover.process_in_flight_count ?? null, handover.external_schedules ?? null,
924
+ handover.registered_at ?? null],
925
+ );
926
+ }
927
+ // app#225 follow-up — separate statement on purpose: the INSERT above is
928
+ // byte-pinned by test698's mutation harness. Sticky: only ever set to 1,
929
+ // and only by a node token bound to this alias (a user token or another
930
+ // node cannot mark someone else's session as able to serve its files).
931
+ if (rulesFileCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
932
+ db.run("UPDATE sessions SET rules_file_capable = 1 WHERE resume_id = ?1", [resume_id]);
933
+ }
934
+ if (skillsCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
935
+ db.run("UPDATE sessions SET skills_capable = 1 WHERE resume_id = ?1", [resume_id]);
936
+ }
937
+ if (filesCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
938
+ db.run("UPDATE sessions SET files_capable = 1 WHERE resume_id = ?1", [resume_id]);
939
+ }
418
940
  if (host || proc) {
419
941
  db.run(
420
942
  `INSERT INTO agent_telemetry (id, network_id, resume_id, alias, hostname, ip, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb, disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count, created_at)
@@ -463,6 +985,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
463
985
  upsertNodeWithSec1Guard({
464
986
  node_id,
465
987
  callerNetworkId: effectiveNetId ?? null,
988
+ callerUserId: enforceUserId ?? null,
989
+ callerTokenId: callerTokenId ?? null,
466
990
  node_name: nn || effectiveAlias,
467
991
  alias: effectiveAlias,
468
992
  runtime: nodeRuntime,
@@ -554,6 +1078,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
554
1078
  } else {
555
1079
  updatedTaskId = task;
556
1080
  }
1081
+ if (updatedTaskId) syncScheduledRunForTask(updatedTaskId, effectiveNetId);
557
1082
  });
558
1083
  // Log event after transaction
559
1084
  if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
@@ -614,7 +1139,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
614
1139
  const rows0 = db.get<{ cnt: number }>(countSql, ...countParams);
615
1140
  console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
616
1141
  const rowsParams: any[] = [alias];
617
- let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json
1142
+ let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json,
1143
+ CASE WHEN type = 'task' THEN COALESCE(task_id, id) ELSE task_id END AS task_id
618
1144
  FROM inbox WHERE session_name = ?1 AND acked = 0`;
619
1145
  rowsSql = addReadScope(rowsSql, rowsParams, readScope);
620
1146
  rowsSql += ` ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
@@ -638,13 +1164,32 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
638
1164
  alias: z.string().min(1).max(200).describe("Session alias"),
639
1165
  message_id: z.string().min(1).max(200),
640
1166
  response: z.string().max(10000).optional(),
1167
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
641
1168
  },
642
- async ({ alias, message_id, response }) => {
643
- const effectiveNetId = getNetworkId(null);
1169
+ async ({ alias, message_id, response, network_id: netId }) => {
1170
+ const effectiveNetId = getNetworkId(netId);
644
1171
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
645
1172
  console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
646
- const ackParams: any[] = [message_id, alias];
647
- let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2";
1173
+ // New task consumers ACK by the logical tasks.task_id exposed by
1174
+ // get_inbox. Keep exact inbox.id support for legacy consumers and for
1175
+ // non-task messages. Restrict the lookup to pending rows: after a retry
1176
+ // the original row can have id == task_id but is already ACKed, while
1177
+ // the current row has a fresh transport id and the same logical task_id.
1178
+ const inboxTaskParams: any[] = [message_id, alias];
1179
+ let inboxTaskSql = `SELECT id, type, COALESCE(task_id, id) AS task_id
1180
+ FROM inbox
1181
+ WHERE session_name = ?2 AND acked = 0
1182
+ AND (id = ?1 OR (type = 'task' AND task_id = ?1))`;
1183
+ inboxTaskSql = addScope(inboxTaskSql, inboxTaskParams, effectiveNetId);
1184
+ inboxTaskSql += " ORDER BY created_at DESC LIMIT 1";
1185
+ const inboxTask = db.get<{ id: string; type: string; task_id: string }>(inboxTaskSql, ...inboxTaskParams);
1186
+ if (!inboxTask) {
1187
+ return {
1188
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "message not found or already acknowledged" }) }],
1189
+ };
1190
+ }
1191
+ const ackParams: any[] = [inboxTask.id, alias];
1192
+ let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2 AND acked = 0";
648
1193
  ackSql = addScope(ackSql, ackParams, effectiveNetId);
649
1194
  const result = db.run(ackSql, ackParams);
650
1195
  if (result.changes === 0) {
@@ -654,11 +1199,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
654
1199
  }
655
1200
  // V2: sync tasks table — ack_inbox means delivered→acked
656
1201
  try {
657
- const taskParams: any[] = [message_id];
1202
+ if (inboxTask?.type !== "task") {
1203
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }] };
1204
+ }
1205
+ const taskParams: any[] = [inboxTask.task_id];
658
1206
  let taskSql = "UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'";
659
1207
  taskSql = addScope(taskSql, taskParams, effectiveNetId);
660
1208
  const ackResult = db.run(taskSql, taskParams);
661
- if (ackResult.changes > 0) logTaskEvent(message_id, "delivered", "acked", alias);
1209
+ if (ackResult.changes > 0) logTaskEvent(inboxTask.task_id, "delivered", "acked", alias);
662
1210
  } catch {}
663
1211
  return {
664
1212
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }],
@@ -666,22 +1214,208 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
666
1214
  }
667
1215
  );
668
1216
 
1217
+ // #520 — two monotonic runtime-evidence levels for exact tasks.
1218
+ //
1219
+ // runtime_submitted_at means agent-node handed the body to the vendor
1220
+ // runtime. consumed_at is stronger: an attributable turn-start or first
1221
+ // activity event came back. Merely fetching/acking an inbox row sets neither.
1222
+ // Node identity comes exclusively from the ntok; callers cannot self-report
1223
+ // an alias or node_id. A consumed mark also fills runtime_submitted_at because
1224
+ // that stronger fact logically implies submission.
1225
+ const markTaskRuntimeEvidence = (
1226
+ taskIds: string[],
1227
+ level: "submitted" | "consumed",
1228
+ taskContexts: Array<{ task_id: string; thread_id: string; turn_id: string }> = [],
1229
+ ) => {
1230
+ const task_ids = taskIds;
1231
+ if (!callerTokenIsNetwork || !callerAlias || !enforceNetworkId) {
1232
+ return {
1233
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_required" }) }],
1234
+ };
1235
+ }
1236
+ // PgAdapter.transaction currently opens a fresh subprocess/connection
1237
+ // per statement. The all-or-nothing batch promise cannot be made there;
1238
+ // refuse evidence rather than publish a partially stamped batch.
1239
+ if (db.dialect !== "sqlite") {
1240
+ return {
1241
+ content: [{ type: "text" as const, text: JSON.stringify({
1242
+ ok: false,
1243
+ error: "task_runtime_evidence_backend_unsupported",
1244
+ }) }],
1245
+ };
1246
+ }
1247
+
1248
+ const uniqueTaskIds = [...new Set(task_ids)];
1249
+ if (uniqueTaskIds.length !== task_ids.length) {
1250
+ return {
1251
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_id" }) }],
1252
+ };
1253
+ }
1254
+
1255
+ if (level !== "consumed" && taskContexts.length > 0) {
1256
+ return {
1257
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "runtime_context_requires_consumed" }) }],
1258
+ };
1259
+ }
1260
+ const contextByTaskId = new Map(taskContexts.map((context) => [context.task_id, context]));
1261
+ if (contextByTaskId.size !== taskContexts.length) {
1262
+ return {
1263
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_context" }) }],
1264
+ };
1265
+ }
1266
+ const foreignContext = taskContexts.find((context) => !uniqueTaskIds.includes(context.task_id));
1267
+ if (foreignContext) {
1268
+ return {
1269
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task_context_not_requested", task_id: foreignContext.task_id }) }],
1270
+ };
1271
+ }
1272
+
1273
+ const placeholders = uniqueTaskIds.map((_, i) => `?${i + 2}`).join(", ");
1274
+
1275
+ // Keep ownership preflight and every stamp in one SQLite transaction.
1276
+ // This closes the cross-process preflight→UPDATE race: no other writer
1277
+ // can reassign a task between the identity decision and the write.
1278
+ const outcome = db.transaction(() => {
1279
+ const canonicalCaller = resolveCanonicalAlias(enforceNetworkId, callerAlias).alias;
1280
+ const callerSession = db.get<{ node_id: string | null }>(
1281
+ `SELECT node_id FROM sessions
1282
+ WHERE network_id = ?1 AND alias = ?2
1283
+ ORDER BY updated_at DESC LIMIT 1`,
1284
+ enforceNetworkId,
1285
+ canonicalCaller,
1286
+ );
1287
+ const rows = db.all<{ task_id: string; to_node_id: string | null; to_name: string; thread_id: string | null; turn_id: string | null }>(
1288
+ `SELECT task_id, to_node_id, to_name, thread_id, turn_id FROM tasks
1289
+ WHERE network_id = ?1 AND task_id IN (${placeholders})`,
1290
+ enforceNetworkId,
1291
+ ...uniqueTaskIds,
1292
+ );
1293
+ const owned = new Map(rows.map((row) => [row.task_id, row]));
1294
+ const rejectedTaskId = uniqueTaskIds.find((taskId) => {
1295
+ const row = owned.get(taskId);
1296
+ if (!row) return true;
1297
+ // Prefer immutable node identity whenever the task has one. Direct
1298
+ // and legacy token-bound sessions legitimately have NULL node_id;
1299
+ // only that shape may fall back to the canonical token alias.
1300
+ if (row.to_node_id) return row.to_node_id !== callerSession?.node_id;
1301
+ return resolveCanonicalAlias(enforceNetworkId, row.to_name).alias !== canonicalCaller;
1302
+ });
1303
+ if (rejectedTaskId) {
1304
+ return { ok: false as const, error: "task_not_owned", task_id: rejectedTaskId };
1305
+ }
1306
+ const conflictingContext = uniqueTaskIds.find((taskId) => {
1307
+ const context = contextByTaskId.get(taskId);
1308
+ if (!context) return false;
1309
+ const row = owned.get(taskId)!;
1310
+ return (row.thread_id !== null && row.thread_id !== context.thread_id)
1311
+ || (row.turn_id !== null && row.turn_id !== context.turn_id);
1312
+ });
1313
+ if (conflictingContext) {
1314
+ return { ok: false as const, error: "task_runtime_context_conflict", task_id: conflictingContext };
1315
+ }
1316
+
1317
+ for (const taskId of uniqueTaskIds) {
1318
+ const row = owned.get(taskId)!;
1319
+ const ownershipSql = row.to_node_id
1320
+ ? "to_node_id = ?3"
1321
+ : "to_node_id IS NULL AND to_name = ?3";
1322
+ const ownerValue = row.to_node_id ?? row.to_name;
1323
+ let updateResult;
1324
+ if (level === "consumed") {
1325
+ const context = contextByTaskId.get(taskId);
1326
+ updateResult = db.run(
1327
+ `UPDATE tasks SET
1328
+ runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now')),
1329
+ consumed_at = COALESCE(consumed_at, datetime('now')),
1330
+ thread_id = COALESCE(thread_id, ?4),
1331
+ turn_id = COALESCE(turn_id, ?5)
1332
+ WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
1333
+ [enforceNetworkId, taskId, ownerValue, context?.thread_id ?? null, context?.turn_id ?? null],
1334
+ );
1335
+ } else {
1336
+ updateResult = db.run(
1337
+ `UPDATE tasks SET runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now'))
1338
+ WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
1339
+ [enforceNetworkId, taskId, ownerValue],
1340
+ );
1341
+ }
1342
+ if (updateResult.changes !== 1) {
1343
+ // A zero-row write after a successful preflight is an invariant
1344
+ // failure, never a successful evidence report.
1345
+ throw new Error(`task_runtime_evidence_write_race:${taskId}`);
1346
+ }
1347
+ }
1348
+ const evidenceRows = db.all<{
1349
+ task_id: string;
1350
+ runtime_submitted_at: string;
1351
+ consumed_at: string | null;
1352
+ thread_id: string | null;
1353
+ turn_id: string | null;
1354
+ }>(
1355
+ `SELECT task_id, runtime_submitted_at, consumed_at, thread_id, turn_id FROM tasks
1356
+ WHERE network_id = ?1 AND task_id IN (${placeholders})`,
1357
+ enforceNetworkId,
1358
+ ...uniqueTaskIds,
1359
+ );
1360
+ return { ok: true as const, tasks: evidenceRows };
1361
+ });
1362
+
1363
+ return {
1364
+ content: [{
1365
+ type: "text" as const,
1366
+ text: JSON.stringify(outcome),
1367
+ }],
1368
+ };
1369
+ };
1370
+
1371
+ const taskRuntimeEvidenceSchema = {
1372
+ task_ids: z.array(z.string().min(1).max(200)).min(1).max(100),
1373
+ task_contexts: z.array(z.object({
1374
+ task_id: z.string().min(1).max(200),
1375
+ thread_id: z.string().min(1).max(500),
1376
+ turn_id: z.string().min(1).max(500),
1377
+ }).strict()).max(100).optional(),
1378
+ };
1379
+
1380
+ server.tool(
1381
+ "mark_tasks_runtime_submitted",
1382
+ "Internal agent-node signal: exact task bodies were submitted to this token-bound node's vendor runtime.",
1383
+ taskRuntimeEvidenceSchema,
1384
+ async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "submitted", task_contexts),
1385
+ );
1386
+
1387
+ server.tool(
1388
+ "mark_tasks_consumed",
1389
+ "Internal agent-node signal: exact tasks produced attributable turn-start/activity evidence in this token-bound node's runtime.",
1390
+ taskRuntimeEvidenceSchema,
1391
+ async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "consumed", task_contexts),
1392
+ );
1393
+
669
1394
  // ═══════════════════════════════════════════
670
1395
  // Hub Tools (5)
671
1396
  // ═══════════════════════════════════════════
672
1397
 
673
1398
  server.tool(
674
1399
  "get_all_status",
675
- "Get status of all sessions. Hub uses this for the patrol loop.",
1400
+ "Get status of all sessions. Hub uses this for the patrol loop. " +
1401
+ "Pass filter_alias (comma-separated) when you only care about specific " +
1402
+ "nodes — the unfiltered result is one row per session with 31 columns and " +
1403
+ "is large enough on a real fleet that callers cannot read it.",
676
1404
  {
677
1405
  filter_status: z.string().max(50).optional(),
678
1406
  filter_server: z.string().max(200).optional(),
1407
+ // 2026-08-17: on a 222-session hub the unfiltered response is ~259 KB, past
1408
+ // what an MCP client can take in one result — so the caller that wanted the
1409
+ // status of THREE nodes could not get it from this tool at all. The patrol
1410
+ // loop still wants everything, hence optional rather than required.
1411
+ filter_alias: z.string().max(2000).optional()
1412
+ .describe("One alias, or several separated by commas. Exact matches only."),
679
1413
  network_id: z.string().max(200).optional().describe("Filter by network"),
680
1414
  },
681
- async ({ filter_status, filter_server, network_id: netId }) => {
1415
+ async ({ filter_status, filter_server, filter_alias, network_id: netId }) => {
682
1416
  const readScope = resolveReadScope(netId);
683
1417
  if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
684
- console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${readScope.networkId ? " net=" + readScope.networkId.slice(0, 12) : ""}`);
1418
+ console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${filter_alias ? " alias=" + filter_alias.slice(0, 80) : ""}${readScope.networkId ? " net=" + readScope.networkId.slice(0, 12) : ""}`);
685
1419
 
686
1420
  // Round-2/4 review ③: stale-marking moved to startStaleSessionSweeper()
687
1421
  // (background timer, ~60s cadence). Read path no longer fires UPDATE.
@@ -690,20 +1424,42 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
690
1424
  sql = addReadScope(sql, params, readScope);
691
1425
  if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
692
1426
  if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
1427
+ const aliasFilter = parseAliasFilter(filter_alias);
1428
+ const aliases = aliasFilter.aliases;
1429
+ if (aliasFilter.sql) {
1430
+ sql += aliasFilter.sql;
1431
+ params.push(...aliases);
1432
+ }
693
1433
  sql += " ORDER BY updated_at DESC";
694
1434
  const sessions = db.all(sql, ...params);
695
1435
 
1436
+ // `summary` has always counted every session in the read scope, ignoring
1437
+ // filter_status / filter_server — and now filter_alias. That is fine for
1438
+ // the patrol loop, but a caller who asked about three aliases and gets
1439
+ // back three rows plus "idle: 96" can easily read the 96 as being about
1440
+ // their three. A count that does not say what it counted invites exactly
1441
+ // that. So the response now says so, rather than the semantics changing
1442
+ // under existing callers.
696
1443
  const summaryParams: any[] = [];
697
1444
  let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
698
1445
  summarySql = addReadScope(summarySql, summaryParams, readScope);
699
1446
  summarySql += " GROUP BY status";
700
1447
  const summary = db.all(summarySql, ...summaryParams);
701
1448
 
1449
+ const filtered = !!(filter_status || filter_server || aliases.length > 0);
702
1450
  return {
703
1451
  content: [
704
1452
  {
705
1453
  type: "text" as const,
706
- text: JSON.stringify({ ok: true, sessions, summary }),
1454
+ text: JSON.stringify({
1455
+ ok: true,
1456
+ sessions,
1457
+ summary,
1458
+ summary_scope: filtered
1459
+ ? "every session in the read scope — NOT narrowed by the filters applied to `sessions`"
1460
+ : "every session in the read scope",
1461
+ sessions_returned: sessions.length,
1462
+ }),
707
1463
  },
708
1464
  ],
709
1465
  };
@@ -761,7 +1517,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
761
1517
  },
762
1518
  async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId, parent_task_id: parentIn, meta }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
763
1519
  const effectiveNetId = getNetworkId(netId);
764
- const metaJson = normalizeMetaJson(meta);
1520
+ const authOrigin: TaskAuthOrigin = callerTokenIsNetwork
1521
+ ? "node"
1522
+ : enforceUserId
1523
+ ? "user"
1524
+ : "legacy";
1525
+ const metaJson = normalizeMetaJson(stampTaskAuthOrigin(meta, authOrigin));
765
1526
 
766
1527
  // Role check FIRST — round5 follow-up (通信牛 oracle catch):
767
1528
  // the explicit-parent verification below distinguishes
@@ -836,6 +1597,45 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
836
1597
  const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
837
1598
  if (target.state === "not_found") return deliveryTargetReply(target)!;
838
1599
 
1600
+ // Dashboard sends carry a random client_request_id inside meta. Derive
1601
+ // the task primary key from authenticated scope + that request id so a
1602
+ // lost HTTP response can be retried safely, even after a hub restart.
1603
+ // The existing row must match the full request; key reuse with changed
1604
+ // content/target fails closed instead of silently returning another task.
1605
+ const clientRequestId = clientRequestIdFromMeta(meta);
1606
+ const id = clientRequestId
1607
+ ? idempotentTaskId(effectiveNetId ?? null, from_session, clientRequestId)
1608
+ : uuidv4();
1609
+ if (clientRequestId) {
1610
+ const existing = db.get<StoredIdempotentTask & { to_node_id: string | null }>(
1611
+ "SELECT task_id, from_name, to_node_id, to_name, priority, content, network_id, meta_json, status FROM tasks WHERE task_id = ?1",
1612
+ [id],
1613
+ );
1614
+ if (existing) {
1615
+ if (!idempotentTaskMatches(existing, {
1616
+ fromName: from_session, toName: targetAlias, priority, content: task,
1617
+ networkId: effectiveNetId ?? null, metaJson,
1618
+ })) {
1619
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1620
+ ok: false, error: "idempotency_conflict",
1621
+ message: "client_request_id was already used with a different task payload",
1622
+ }) }] };
1623
+ }
1624
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1625
+ ok: true, message_id: existing.task_id, task_id: existing.task_id,
1626
+ task_status: existing.status, idempotent_replay: true,
1627
+ // A replay acknowledges the already-persisted dispatch. Use that
1628
+ // row rather than today's session record so alias reuse or a
1629
+ // restarted process cannot rewrite historical recipient identity.
1630
+ actual_to: {
1631
+ alias: existing.to_name,
1632
+ to_node_id: existing.to_node_id ?? null,
1633
+ network_id: existing.network_id ?? null,
1634
+ },
1635
+ }) }] };
1636
+ }
1637
+ }
1638
+
839
1639
  // #212 dedup guardrail. If this exact (from, to, content) has already
840
1640
  // been delivered within COMMHUB_SEND_DEDUP_WINDOW_MS (default 5 min)
841
1641
  // we refuse the call and surface a structured `duplicate_send`
@@ -856,18 +1656,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
856
1656
  return { content: [{ type: "text" as const, text: JSON.stringify(payload) }] };
857
1657
  }
858
1658
 
1659
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 1/6).
1660
+ {
1661
+ const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
1662
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1663
+ }
1664
+
859
1665
  console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
860
- const id = uuidv4();
861
1666
  const fromNodeId = resolveNodeIdForAlias(from_session, effectiveNetId);
862
1667
  const targetNodeId = target.session?.node_id ?? null;
1668
+ const resolvedActualTo = actualTo(target, effectiveNetId);
863
1669
  // 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
864
1670
  // dashboard 在派任务一刻就反映出"任务已下发",不再等 agent 的
865
1671
  // report_status 心跳;status 字段交给 agent,避免与 working/idle
866
1672
  // 报告冲突)。
867
1673
  db.transaction(() => {
868
1674
  db.run(
869
- `INSERT INTO inbox (id, session_name, node_id, type, priority, content, context, from_session, requires_response, network_id, meta_json)
870
- VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, ?7, 'reply', ?8, ?9)`,
1675
+ `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, context, from_session, requires_response, network_id, meta_json)
1676
+ VALUES (?1, ?1, ?2, ?3, 'task', ?4, ?5, ?6, ?7, 'reply', ?8, ?9)`,
871
1677
  [id, targetAlias, targetNodeId, priority, task, context ?? null, from_session, effectiveNetId ?? null, metaJson]
872
1678
  );
873
1679
  db.run(
@@ -879,6 +1685,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
879
1685
  let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
880
1686
  touchSql = addScope(touchSql, touchParams, effectiveNetId);
881
1687
  db.run(touchSql, touchParams);
1688
+ // #1548 —— 能派活的发送方不可能真的 blocked;此处出 blocked → idle。
1689
+ releaseBlockedSession(from_session, effectiveNetId);
882
1690
  });
883
1691
  logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${targetAlias} (parent=${parentTaskId.slice(0,8)})` : `→ ${targetAlias}`);
884
1692
  // Only stamp the dedup index after the inbox/tasks transaction
@@ -897,9 +1705,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
897
1705
  let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
898
1706
  pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
899
1707
  const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
900
- if (target.state === "online") {
901
- pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
902
- }
1708
+ // 🔴 不要用 last_seen_at 的时间戳去猜「有没有人在听」——SSE 订阅者注册表
1709
+ // 就是那件事本身。pushEvent 在没有订阅者时已经是 no-op,所以这道闸不提供
1710
+ // 任何保护,只会把**连着但最近没心跳**的会话排除掉;而全网「停发心跳/状态类
1711
+ // 消息」的省额度规则让安静恰恰是常态。上面那段注释早就写着 "Push
1712
+ // unconditionally",同一类缺陷上次也是打在 Dashboard 任务上(f015d9d6:
1713
+ // 任务标了 delivered,会话却从未收到推送)。
1714
+ pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
1715
+ const actuallyDelivered = hasSubscribers(targetAlias, effectiveNetId);
1716
+ // #461 network observer summary — unconditional (task row exists
1717
+ // even when the target is offline/queued), metadata only.
1718
+ pushNetworkObserverEvent(effectiveNetId, { type: "new_task", task_id: id, from: from_session, to: targetAlias, status: actuallyDelivered ? "delivered" : "queued", priority });
903
1719
 
904
1720
  if (target.state === "offline") {
905
1721
  return {
@@ -914,6 +1730,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
914
1730
  task_id: id,
915
1731
  message_id: id,
916
1732
  session_status: target.session.status ?? "offline",
1733
+ actual_to: resolvedActualTo,
917
1734
  ...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
918
1735
  }),
919
1736
  }],
@@ -927,6 +1744,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
927
1744
  text: JSON.stringify({
928
1745
  ok: true,
929
1746
  message_id: id,
1747
+ actual_to: resolvedActualTo,
930
1748
  ...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
931
1749
  session_status: target.session?.status ?? "unknown",
932
1750
  }),
@@ -943,14 +1761,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
943
1761
  alias: z.string().min(1).max(200).describe("Target session alias"),
944
1762
  message: z.string().min(1).max(10000).describe("Message content"),
945
1763
  from_session: z.string().max(200).optional(),
1764
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
946
1765
  },
947
- async ({ alias, message, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
948
- const effectiveNetId = getNetworkId(null);
1766
+ async ({ alias, message, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1767
+ const effectiveNetId = getNetworkId(netId);
949
1768
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
950
1769
  const canonical = resolveCanonicalAlias(effectiveNetId, alias);
951
1770
  const targetAlias = canonical.alias;
952
1771
  const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
953
1772
  if (target.state === "not_found") return deliveryTargetReply(target)!;
1773
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 2/6).
1774
+ {
1775
+ const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
1776
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1777
+ }
954
1778
  console.log(`[${ts()}] ${from_session} → send_message → ${targetAlias}: ${message.slice(0, 60)}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
955
1779
  const id = uuidv4();
956
1780
  db.run(
@@ -959,9 +1783,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
959
1783
  [id, targetAlias, target.session?.node_id ?? null, message, from_session, effectiveNetId ?? null]
960
1784
  );
961
1785
 
962
- if (target.state === "online") {
963
- pushEvent(targetAlias, { type: "new_message", from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
964
- }
1786
+ // 同 send_task:可达性用订阅者注册表判,不用心跳时间戳猜。
1787
+ pushEvent(targetAlias, { type: "new_message", inbox_count: pendingInboxCount(targetAlias, effectiveNetId), from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
965
1788
 
966
1789
  const offlineReply = deliveryTargetReply(target, { message_id: id });
967
1790
  if (offlineReply) {
@@ -989,96 +1812,270 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
989
1812
  }
990
1813
  );
991
1814
 
992
- // ── V2: send_reply (关联 task_id,不触发 think) ──
993
- server.tool(
994
- "send_reply",
995
- "Send a reply to a task. Linked to task_id via in_reply_to. Does NOT trigger agent processing.",
996
- {
997
- alias: z.string().min(1).max(200).describe("Target session alias"),
1815
+ // ── V2/V3 reply primitives ──
1816
+ const replyToolSchema = {
1817
+ // #1085 — alias is OPTIONAL: when omitted (or empty) and `in_reply_to`
1818
+ // is set, the hub derives the target from the original task's
1819
+ // `from_name` (it already SELECTs that task by in_reply_to below).
1820
+ // The SDK-runtime bridge (agent-node/src/commhub-mcp.ts) exposes
1821
+ // send_reply as (task_id, text, status) with NO alias, so before this
1822
+ // it always sent alias=undefined → -32602 "expected string, received
1823
+ // undefined at alias". The target was already knowable from the task,
1824
+ // so requiring the caller to pass it was a redundant-param trap.
1825
+ alias: z.string().min(1).max(200).optional().describe("Target session alias — optional; derived from in_reply_to's task sender when omitted"),
998
1826
  text: z.string().min(1).max(10000).describe("Reply content"),
999
1827
  in_reply_to: z.string().max(200).optional().describe("Original task/message ID"),
1000
1828
  status: z.enum(["replied", "failed", "cancelled"]).optional().default("replied").describe("Task outcome"),
1001
1829
  from_session: z.string().max(200).optional(),
1002
- },
1003
- async ({ alias, text, in_reply_to, status: replyStatus, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1004
- const effectiveNetId = getNetworkId(null);
1830
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1831
+ // #507 — top-level attachments (parity with REST /api/task L506). MCP Zod
1832
+ // otherwise silently strips unknown fields, so a caller passing
1833
+ // `attachments` before this schema entry existed would see `ok:true`
1834
+ // and never learn the attachments were dropped. Validated by
1835
+ // validateAttachments (uploads.ts) — the same helper the REST path uses.
1836
+ attachments: z.any().optional().describe("Optional attachment array; parity with send_task's meta.attachments. Each item: {type:'file', file_id, name?, mime?, size?}. Persisted into tasks.meta_json + inbox.meta_json."),
1837
+ // #507 — optional structured metadata (parity with send_task L837). If
1838
+ // both `attachments` (top-level) and `meta.attachments` are supplied,
1839
+ // top-level wins (same rule as REST /api/task L2101).
1840
+ meta: z.any().optional().describe("Optional structured reply metadata, e.g. { attachments: [...] }."),
1841
+ };
1842
+ const handleReply = async (args: any, peerCapabilityRequired: boolean) => {
1843
+ const { alias, text, in_reply_to, status: replyStatus = "replied", from_session: _fromIn, network_id: netId, attachments, meta } = args;
1844
+ const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1845
+ const effectiveNetId = getNetworkId(netId);
1005
1846
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1006
- console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
1007
- const id = uuidv4();
1008
- let taskBefore: { status: string } | null = null;
1009
- if (in_reply_to) {
1010
- const taskParams: any[] = [in_reply_to];
1011
- let taskSql = "SELECT status FROM tasks WHERE task_id = ?1";
1012
- taskSql = addScope(taskSql, taskParams, effectiveNetId);
1013
- taskBefore = db.get<{ status: string }>(taskSql, ...taskParams) ?? null;
1014
- if (!taskBefore) {
1015
- return {
1016
- content: [{
1017
- type: "text" as const,
1018
- text: JSON.stringify({
1019
- ok: false,
1020
- error: "reply_task_not_found",
1021
- message: `cannot apply reply: task not found (${in_reply_to})`,
1022
- in_reply_to,
1023
- reply_queued: false,
1024
- }),
1025
- }],
1026
- };
1847
+ if (peerCapabilityRequired && !in_reply_to) {
1848
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "peer_reply_task_required" }) }] };
1849
+ }
1850
+
1851
+ // #507 — validate attachments BEFORE any DB write. Rejects malformed
1852
+ // input (bad file_id, >20 items, size > cap, non-object item, wrong
1853
+ // type field) with an explicit error the caller can act on. Empty /
1854
+ // absent attachments return { ok: true, attachments: [] } — the
1855
+ // reverse-(e) invariant (no attachments → behavior unchanged) is
1856
+ // pinned by tests that assert byte-identical response shape
1857
+ // before/after this validation runs.
1858
+ const attachmentsResult = validateAttachments(attachments ?? (meta && typeof meta === "object" ? (meta as any).attachments : undefined));
1859
+ if (!attachmentsResult.ok) {
1860
+ return {
1861
+ content: [{
1862
+ type: "text" as const,
1863
+ text: JSON.stringify({
1864
+ ok: false,
1865
+ error: "bad_attachments",
1866
+ message: attachmentsResult.error,
1867
+ }),
1868
+ }],
1869
+ };
1870
+ }
1871
+ const mergedMeta = attachmentsResult.attachments.length
1872
+ ? { ...(meta && typeof meta === "object" ? meta : {}), attachments: attachmentsResult.attachments }
1873
+ : meta;
1874
+ const metaJson = normalizeMetaJson(mergedMeta);
1875
+
1876
+ // #1085 — derive the reply target when the caller omitted `alias`.
1877
+ // The original task's `from_name` IS the reply target; the hub knows
1878
+ // it from `in_reply_to`. Without this, an SDK-runtime send_reply
1879
+ // (schema has no alias) died with -32602 before reaching this handler.
1880
+ let effectiveAlias: string | undefined =
1881
+ (typeof alias === "string" && alias.trim()) ? alias : undefined;
1882
+ if (!effectiveAlias) {
1883
+ if (!in_reply_to) {
1884
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "reply_target_required", message: "alias omitted and no in_reply_to to derive it from" }) }] };
1027
1885
  }
1028
- if (!["created", "delivered", "acked", "running"].includes(taskBefore.status)) {
1029
- return {
1030
- content: [{
1031
- type: "text" as const,
1032
- text: JSON.stringify({
1033
- ok: false,
1034
- error: "reply_task_terminal",
1035
- message: `cannot apply reply: task is already terminal (${taskBefore.status})`,
1036
- in_reply_to,
1037
- task_status: taskBefore.status,
1038
- reply_queued: false,
1039
- }),
1040
- }],
1041
- };
1886
+ const derivParams: any[] = [in_reply_to];
1887
+ let derivSql = "SELECT from_name FROM tasks WHERE task_id = ?1";
1888
+ derivSql = addScope(derivSql, derivParams, effectiveNetId);
1889
+ const derived = db.get<{ from_name: string }>(derivSql, ...derivParams);
1890
+ if (!derived?.from_name) {
1891
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "reply_target_unresolved", message: `alias omitted and could not derive target from in_reply_to (${in_reply_to})` }) }] };
1042
1892
  }
1893
+ effectiveAlias = derived.from_name;
1043
1894
  }
1044
- const replyTargetNodeId = resolveNodeIdForAlias(alias, effectiveNetId);
1045
- const replyLogged = db.transaction(() => {
1895
+
1896
+ const canonicalReplyTarget = resolveCanonicalAlias(effectiveNetId, effectiveAlias);
1897
+ const replyTargetAlias = canonicalReplyTarget.alias;
1898
+ console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${replyTargetAlias}: ${text.slice(0, 60)}${attachmentsResult.attachments.length ? ` [+${attachmentsResult.attachments.length} attachments]` : ""}${canonicalReplyTarget.renamed ? ` [renamed from ${effectiveAlias}]` : ""}`);
1899
+ const id = uuidv4();
1900
+ const replyTargetNodeId = resolveNodeIdForAlias(replyTargetAlias, effectiveNetId);
1901
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 3/6).
1902
+ {
1903
+ const lc = assertNodeActive(replyTargetAlias, effectiveNetId ?? null);
1904
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1905
+ }
1906
+ const replyOutcome = db.transaction(() => {
1907
+ type ReplyTask = {
1908
+ status: string;
1909
+ from_node_id: string | null;
1910
+ from_name: string;
1911
+ to_name: string;
1912
+ to_node_id: string | null;
1913
+ };
1914
+ let taskBefore: ReplyTask | null = null;
1915
+ let callerNodeId: string | null = null;
1916
+ if (in_reply_to) {
1917
+ const taskParams: any[] = [in_reply_to];
1918
+ let taskSql = "SELECT status, from_node_id, from_name, to_name, to_node_id FROM tasks WHERE task_id = ?1";
1919
+ taskSql = addScope(taskSql, taskParams, effectiveNetId);
1920
+ taskBefore = db.get<ReplyTask>(taskSql, ...taskParams) ?? null;
1921
+ if (!taskBefore) return { ok: false as const, error: "reply_task_not_found" as const };
1922
+ if (!["created", "delivered", "acked", "running"].includes(taskBefore.status)) {
1923
+ return { ok: false as const, error: "reply_task_terminal" as const, taskStatus: taskBefore.status };
1924
+ }
1925
+
1926
+ // V3 atomic peer replies are capability-negotiated. Both identity
1927
+ // bindings and the recipient capability are re-read inside this
1928
+ // transaction before any inbox/task/run write. Legacy/unbound rows
1929
+ // fail toward compatibility send_reply, which terminalizes the
1930
+ // original task instead of creating a second response-requiring row.
1931
+ if (peerCapabilityRequired) {
1932
+ if (!taskBefore.from_node_id) {
1933
+ // Origin type is an intrinsic property of the task and must be
1934
+ // decided before caller capability. A Dashboard/human task still
1935
+ // needs the established send_reply terminalization path when the
1936
+ // replying node uses a legacy/unbound token.
1937
+ return { ok: false as const, error: "peer_reply_origin_not_node" as const };
1938
+ }
1939
+ if (!callerTokenIsNetwork || !callerTokenId || !enforceNetworkId) {
1940
+ return { ok: false as const, error: "peer_reply_node_token_required" as const };
1941
+ }
1942
+ const token = db.get<{ bound_node_id: string | null }>(
1943
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
1944
+ callerTokenId,
1945
+ enforceNetworkId,
1946
+ );
1947
+ if (!token?.bound_node_id || !taskBefore.to_node_id) {
1948
+ return { ok: false as const, error: "peer_reply_unsupported" as const };
1949
+ }
1950
+ if (token.bound_node_id !== taskBefore.to_node_id) {
1951
+ return { ok: false as const, error: "reply_task_not_owned" as const };
1952
+ }
1953
+ callerNodeId = token.bound_node_id;
1954
+ const canonicalTarget = replyTargetAlias;
1955
+ const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, taskBefore.from_name).alias;
1956
+ if (canonicalTarget !== canonicalOrigin) {
1957
+ return { ok: false as const, error: "reply_target_mismatch" as const };
1958
+ }
1959
+ const recipient = db.get<{ peer_reply_inbox_capable: number }>(
1960
+ `SELECT peer_reply_inbox_capable FROM sessions
1961
+ WHERE node_id = ?1 AND network_id = ?2 AND alias = ?3
1962
+ ORDER BY updated_at DESC LIMIT 1`,
1963
+ taskBefore.from_node_id,
1964
+ enforceNetworkId,
1965
+ canonicalOrigin,
1966
+ );
1967
+ if (recipient?.peer_reply_inbox_capable !== 1) {
1968
+ return { ok: false as const, error: "peer_reply_unsupported" as const };
1969
+ }
1970
+ } else if (callerTokenIsNetwork && enforceNetworkId) {
1971
+ // Compatibility send_reply is still an exact task transition for
1972
+ // node callers. Bind it to the token-authenticated assignee alias
1973
+ // and immutable original sender. Alias binding survives a
1974
+ // legitimate node-id rotation without letting another node close
1975
+ // the task.
1976
+ const canonicalCaller = resolveCanonicalAlias(enforceNetworkId, from_session).alias;
1977
+ const canonicalAssignee = resolveCanonicalAlias(enforceNetworkId, taskBefore.to_name).alias;
1978
+ if (canonicalCaller !== canonicalAssignee) {
1979
+ return { ok: false as const, error: "reply_task_not_owned" as const };
1980
+ }
1981
+ const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, taskBefore.from_name).alias;
1982
+ if (replyTargetAlias !== canonicalOrigin) {
1983
+ return { ok: false as const, error: "reply_target_mismatch" as const };
1984
+ }
1985
+ }
1986
+
1987
+ }
1988
+
1989
+ // #507 — write meta_json on inbox insert (parity with send_task L952).
1990
+ // Prior to this the attachments field on a send_reply call was
1991
+ // silently stripped by MCP Zod, leaving `ok:true` with no persisted
1992
+ // meta.
1046
1993
  db.run(
1047
- `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
1048
- VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7)`,
1049
- [id, alias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null]
1994
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id, meta_json)
1995
+ VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7, ?8)`,
1996
+ [id, replyTargetAlias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null, metaJson]
1050
1997
  );
1051
1998
 
1052
1999
  // 更新 tasks 表
1053
2000
  if (in_reply_to) {
1054
- const updateParams: any[] = [replyStatus, text, in_reply_to];
1055
- let updateSql = `UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now')
1056
- WHERE task_id = ?3 AND status IN ('created', 'delivered', 'acked', 'running')`;
2001
+ // #507 — persist meta_json onto the tasks row too, so
2002
+ // dashboard's task view sees the reply's attachments alongside
2003
+ // the reply text (parity with send_task L957/959 which writes
2004
+ // meta_json into both inbox and tasks). The COALESCE guards
2005
+ // against clobbering pre-existing meta_json when this reply
2006
+ // brings no attachments (metaJson === null → keep the existing
2007
+ // task meta_json unchanged). Reverse-(e) invariant.
2008
+ // #1823 —— 回复的附件写到 tasks.meta_json.reply_attachments,**不碰** meta_json.attachments:
2009
+ // 那个键是提问者随任务带的附件,客户端把它画在提问者的气泡里。以前这里用回复的 metaJson
2010
+ // 整体 COALESCE 替换,于是 agent 回的图出现在提问者气泡下面、提问者自己带的附件被覆盖
2011
+ // (Vincent 2026-09-06 实测「好像没成功发送给我」)。inbox 行(收件方视角)照旧写 metaJson。
2012
+ const replyTaskMetaJson = (() => {
2013
+ if (!attachmentsResult.attachments.length) return null;
2014
+ const rowParams: any[] = [in_reply_to];
2015
+ let rowSql = "SELECT meta_json FROM tasks WHERE task_id = ?1";
2016
+ rowSql = addScope(rowSql, rowParams, effectiveNetId);
2017
+ const existing = db.get<{ meta_json: string | null }>(rowSql, ...rowParams)?.meta_json ?? null;
2018
+ let base: Record<string, unknown> = {};
2019
+ if (existing) { try { const parsed = JSON.parse(existing); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) base = parsed; } catch {} }
2020
+ return JSON.stringify({ ...base, reply_attachments: attachmentsResult.attachments });
2021
+ })();
2022
+ const updateParams: any[] = [replyStatus, text, replyTaskMetaJson, in_reply_to];
2023
+ let updateSql = `UPDATE tasks
2024
+ SET status = ?1,
2025
+ result = ?2,
2026
+ completed_at = datetime('now'),
2027
+ meta_json = COALESCE(?3, meta_json)
2028
+ WHERE task_id = ?4 AND status IN ('created', 'delivered', 'acked', 'running')`;
2029
+ if (peerCapabilityRequired && callerTokenIsNetwork && taskBefore) {
2030
+ if (taskBefore.to_node_id) {
2031
+ updateParams.push(callerNodeId);
2032
+ updateSql += ` AND to_node_id = ?${updateParams.length}`;
2033
+ } else {
2034
+ updateParams.push(taskBefore.to_name);
2035
+ updateSql += ` AND to_node_id IS NULL AND to_name = ?${updateParams.length}`;
2036
+ }
2037
+ }
1057
2038
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1058
2039
  const result = db.run(updateSql, updateParams);
1059
2040
  if (result.changes === 0) {
1060
- console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
1061
- return false;
2041
+ throw new Error(`reply_atomic_cas_failed:${in_reply_to}`);
1062
2042
  }
1063
- return true;
2043
+ syncScheduledRunForTask(in_reply_to, effectiveNetId);
2044
+ // #1548 —— 终态回复是最强的活性证据:一个还标着 blocked 的发送方在此回到 idle。
2045
+ // 只出 blocked;working 可能真在忙别的任务,不碰。
2046
+ releaseBlockedSession(from_session, effectiveNetId);
2047
+ return { ok: true as const, replyLogged: true };
1064
2048
  }
1065
- return false;
2049
+ return { ok: true as const, replyLogged: false };
1066
2050
  });
1067
2051
 
1068
- if (in_reply_to && !replyLogged) {
2052
+ if (!replyOutcome.ok) {
2053
+ const taskStatus = "taskStatus" in replyOutcome ? replyOutcome.taskStatus : undefined;
2054
+ const messages: Record<string, string> = {
2055
+ reply_task_not_found: `cannot apply reply: task not found (${in_reply_to})`,
2056
+ reply_task_terminal: `cannot apply reply: task is already terminal (${taskStatus})`,
2057
+ peer_reply_node_token_required: "atomic peer reply requires a node token",
2058
+ peer_reply_unsupported: "recipient or caller does not support atomic peer replies",
2059
+ peer_reply_origin_not_node: "original task sender is not a node; use send_reply",
2060
+ reply_node_identity_unbound: "cannot apply reply: node identity is not token-bound",
2061
+ reply_task_not_owned: "cannot apply reply: task is not owned by this node",
2062
+ reply_target_mismatch: "cannot apply reply: target is not the original task sender",
2063
+ };
1069
2064
  return {
1070
2065
  content: [{
1071
2066
  type: "text" as const,
1072
2067
  text: JSON.stringify({
1073
2068
  ok: false,
1074
- error: "reply_not_applied",
1075
- message: "reply was not applied to task",
2069
+ error: replyOutcome.error,
2070
+ message: messages[replyOutcome.error],
1076
2071
  in_reply_to,
2072
+ ...(taskStatus ? { task_status: taskStatus } : {}),
1077
2073
  reply_queued: false,
1078
2074
  }),
1079
2075
  }],
1080
2076
  };
1081
2077
  }
2078
+ const replyLogged = replyOutcome.replyLogged;
1082
2079
 
1083
2080
  // Log event after commit (outside transaction)
1084
2081
  if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
@@ -1115,16 +2112,71 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1115
2112
  }
1116
2113
  }
1117
2114
 
1118
- const session = scopedSessionStatus(alias, effectiveNetId);
1119
- pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
2115
+ const session = scopedSessionStatus(replyTargetAlias, effectiveNetId);
2116
+ pushEvent(replyTargetAlias, { type: "new_reply", inbox_count: pendingInboxCount(replyTargetAlias, effectiveNetId), from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
2117
+ // #461 network observer summary — ids + routing only, no reply text.
2118
+ pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to ?? null, message_id: id, from: from_session, to: replyTargetAlias, status: replyStatus });
2119
+
2120
+ // #498 compatibility tripwire. Legacy send_reply is intentionally kept
2121
+ // for old agents and Dashboard callers during rollout, but it is not the
2122
+ // capability-negotiated atomic peer primitive. Warn only for an actual
2123
+ // agent target; Dashboard hub/api replies remain quiet. New agents call
2124
+ // send_peer_reply and never see this warning.
2125
+ const targetIsAgent = replyTargetNodeId !== null
2126
+ && replyTargetAlias !== "hub"
2127
+ && replyTargetAlias !== "api";
2128
+ const warning = !peerCapabilityRequired && targetIsAgent
2129
+ ? `Target "${replyTargetAlias}" is an agent node. Legacy commhub_reply/send_reply terminalized the original task; upgraded peers should prefer commhub_send_peer_reply for capability-negotiated delivery. (RFC-030 mixed-version rollout.)`
2130
+ : undefined;
2131
+
2132
+ // #507 — echo attachments READ BACK FROM DB (lead 2b5f6634): the point
2133
+ // of the echo is to prove attachments actually landed in storage, not
2134
+ // to reflect the in-memory variable we tried to write. Reading
2135
+ // `attachmentsResult.attachments` here would still show `ok:true` +
2136
+ // full echo if the UPDATE failed with `changes=0` (e.g. task moved to
2137
+ // terminal between the pre-check and the transaction). SELECT from
2138
+ // inbox.meta_json (always written, uses the id we just generated) so
2139
+ // the echo means "these attachments are on disk now". Absent field
2140
+ // when the caller sent no attachments — reverse-(e) invariant: a
2141
+ // no-attachment call returns the exact same response shape as before
2142
+ // this PR. Persisted-attachments field is only added when the caller
2143
+ // actually asked for attachments.
2144
+ let attachmentsSaved: unknown[] | null = null;
2145
+ if (attachmentsResult.attachments.length > 0) {
2146
+ const persistedRow = db.get<{ meta_json: string | null }>(
2147
+ "SELECT meta_json FROM inbox WHERE id = ?1", [id]
2148
+ );
2149
+ const persistedMeta = parseMetaJson(persistedRow?.meta_json ?? null);
2150
+ attachmentsSaved = persistedMeta && typeof persistedMeta === "object" && Array.isArray((persistedMeta as any).attachments)
2151
+ ? (persistedMeta as any).attachments
2152
+ : [];
2153
+ }
1120
2154
 
1121
2155
  return {
1122
2156
  content: [{
1123
2157
  type: "text" as const,
1124
- text: JSON.stringify({ ok: true, message_id: id, session_status: session?.status ?? "unknown" }),
2158
+ text: JSON.stringify({
2159
+ ok: true,
2160
+ message_id: id,
2161
+ session_status: session?.status ?? "unknown",
2162
+ ...(warning ? { warning } : {}),
2163
+ ...(attachmentsSaved !== null ? { attachments_saved: attachmentsSaved } : {}),
2164
+ }),
1125
2165
  }],
1126
2166
  };
1127
- }
2167
+ };
2168
+
2169
+ server.tool(
2170
+ "send_reply",
2171
+ "Reply to a Dashboard/UI-originated task. Also serves as the terminal legacy fallback when atomic peer reply capability is unavailable.",
2172
+ replyToolSchema,
2173
+ (args) => handleReply(args, false),
2174
+ );
2175
+ server.tool(
2176
+ "send_peer_reply",
2177
+ "Atomically finalize one node-owned task and enqueue one no-response result when the exact recipient advertises peer_reply_inbox_capable. Stored capability survives transient SSE disconnects; legacy peers return peer_reply_unsupported with zero writes.",
2178
+ replyToolSchema,
2179
+ (args) => handleReply(args, true),
1128
2180
  );
1129
2181
 
1130
2182
  // ── V2: send_ack (不入 inbox,仅更新状态) ──
@@ -1134,9 +2186,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1134
2186
  {
1135
2187
  task_id: z.string().min(1).max(200).describe("Task ID to acknowledge"),
1136
2188
  from_session: z.string().max(200).optional(),
2189
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1137
2190
  },
1138
- async ({ task_id, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1139
- const effectiveNetId = getNetworkId(null);
2191
+ async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2192
+ const effectiveNetId = getNetworkId(netId);
1140
2193
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1141
2194
  console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
1142
2195
  const updateParams: any[] = [task_id];
@@ -1160,9 +2213,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1160
2213
  {
1161
2214
  task_id: z.string().min(1).max(200).describe("Task ID to retry"),
1162
2215
  from_session: z.string().max(200).optional(),
2216
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1163
2217
  },
1164
- async ({ task_id, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1165
- const effectiveNetId = getNetworkId(null);
2218
+ async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2219
+ const effectiveNetId = getNetworkId(netId);
1166
2220
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1167
2221
  console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
1168
2222
  // Find the original task
@@ -1176,6 +2230,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1176
2230
  if (!["failed", "expired", "cancelled"].includes(task.status)) {
1177
2231
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task status is ${task.status}, not retryable` }) }] };
1178
2232
  }
2233
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 4/6).
2234
+ {
2235
+ const lc = assertNodeActive(task.to_name, effectiveNetId ?? task.network_id ?? null);
2236
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
2237
+ }
1179
2238
  db.transaction(() => {
1180
2239
  // Reset task status
1181
2240
  const updateParams: any[] = [task_id];
@@ -1183,17 +2242,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1183
2242
  WHERE task_id = ?1`;
1184
2243
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1185
2244
  db.run(updateSql, updateParams);
2245
+ syncScheduledRunForTask(task_id, effectiveNetId ?? task.network_id ?? null);
1186
2246
  // Re-queue in inbox with new ID (original ID may already exist)
1187
2247
  const retryInboxId = uuidv4();
1188
2248
  db.run(
1189
- `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id)
1190
- VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7)`,
1191
- [retryInboxId, task.to_name, task.to_node_id ?? resolveNodeIdForAlias(task.to_name, effectiveNetId ?? task.network_id ?? null), task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]
2249
+ `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id)
2250
+ VALUES (?1, ?2, ?3, ?4, 'task', ?5, ?6, ?7, 'reply', ?8)`,
2251
+ [retryInboxId, task_id, task.to_name, task.to_node_id ?? resolveNodeIdForAlias(task.to_name, effectiveNetId ?? task.network_id ?? null), task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]
1192
2252
  );
1193
2253
  });
1194
2254
  logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
1195
2255
  // SSE push (unconditional — channel is keyed by alias, not network)
1196
- pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session }, effectiveNetId ?? task.network_id ?? null);
2256
+ pushEvent(task.to_name, { type: "new_task", inbox_count: pendingInboxCount(task.to_name, effectiveNetId ?? task.network_id ?? null), priority: task.priority, from: from_session }, effectiveNetId ?? task.network_id ?? null);
2257
+ pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: task.to_name, status: "delivered", priority: task.priority });
1197
2258
  return {
1198
2259
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
1199
2260
  };
@@ -1233,19 +2294,64 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1233
2294
  from_name: z.string().max(200).optional().describe("Filter by sender"),
1234
2295
  from_node_id: z.string().max(200).optional().describe("Filter by immutable sender node_id"),
1235
2296
  network_id: z.string().max(200).optional().describe("Filter by network"),
2297
+ before_created_at: z.string().max(64).optional().describe("Pagination cursor timestamp"),
2298
+ before_task_id: z.string().max(200).optional().describe("Pagination cursor task id"),
2299
+ durable_cursor: z.boolean().optional().describe("Require immutable node-scoped cursor protocol"),
2300
+ durable_terminal_cursor: z.boolean().optional().describe("Read terminal outbound journal in monotonic order"),
2301
+ after_terminal_seq: z.number().int().min(0).optional().describe("Exclusive terminal journal watermark"),
1236
2302
  limit: z.number().min(1).max(100).optional().default(20),
1237
2303
  },
1238
- async ({ alias, status, from_name, from_node_id, network_id: netId, limit }) => {
2304
+ async ({ alias, status, from_name, from_node_id, network_id: netId, before_created_at, before_task_id, durable_cursor, durable_terminal_cursor, after_terminal_seq, limit }) => {
1239
2305
  const readScope = resolveReadScope(netId);
1240
2306
  if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
1241
- let sql = "SELECT task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
2307
+ if (durable_cursor) {
2308
+ if (!callerTokenIsNetwork) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_required" }) }] };
2309
+ const boundNodeId = callerTokenId && enforceNetworkId
2310
+ ? db.get<{ bound_node_id: string | null }>(
2311
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
2312
+ callerTokenId,
2313
+ enforceNetworkId,
2314
+ )?.bound_node_id ?? null
2315
+ : null;
2316
+ if (!boundNodeId || from_node_id !== boundNodeId) {
2317
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "from_node_id_identity_mismatch" }) }] };
2318
+ }
2319
+ }
2320
+ if (durable_terminal_cursor) {
2321
+ if (!durable_cursor) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "durable_cursor_required" }) }] };
2322
+ const params: any[] = [enforceNetworkId, from_node_id, after_terminal_seq ?? 0, limit];
2323
+ const tasks = db.all(
2324
+ `SELECT t.task_id, t.from_node_id, t.from_name, t.to_node_id, t.to_name, t.priority,
2325
+ t.status, t.content, t.result, t.created_at, t.runtime_submitted_at,
2326
+ t.consumed_at, t.thread_id, t.turn_id, t.completed_at, e.terminal_seq
2327
+ FROM task_terminal_events e
2328
+ JOIN tasks t ON t.task_id = e.task_id
2329
+ WHERE COALESCE(e.network_id, 'default') = COALESCE(?1, 'default')
2330
+ AND e.from_node_id = ?2 AND e.terminal_seq > ?3
2331
+ ORDER BY e.terminal_seq ASC LIMIT ?4`,
2332
+ ...params,
2333
+ );
2334
+ return { content: [{ type: "text" as const, text: JSON.stringify({
2335
+ ok: true,
2336
+ capability: "list_tasks.immutable-terminal-sequence.v2",
2337
+ tasks,
2338
+ count: tasks.length,
2339
+ next_terminal_seq: tasks.length ? tasks[tasks.length - 1]?.terminal_seq : (after_terminal_seq ?? 0),
2340
+ has_more: tasks.length === limit,
2341
+ }) }] };
2342
+ }
2343
+ let sql = "SELECT task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, result, created_at, runtime_submitted_at, consumed_at, thread_id, turn_id, completed_at FROM tasks WHERE 1=1";
1242
2344
  const params: any[] = [];
1243
2345
  sql = addReadScope(sql, params, readScope);
1244
2346
  if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
1245
2347
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
1246
2348
  if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
1247
2349
  if (from_node_id) { sql += ` AND from_node_id = ?${params.length + 1}`; params.push(from_node_id); }
1248
- sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
2350
+ if (before_created_at && before_task_id) {
2351
+ sql += ` AND (created_at < ?${params.length + 1} OR (created_at = ?${params.length + 1} AND task_id < ?${params.length + 2}))`;
2352
+ params.push(before_created_at, before_task_id);
2353
+ }
2354
+ sql += ` ORDER BY created_at DESC, task_id DESC LIMIT ?${params.length + 1}`;
1249
2355
  params.push(limit);
1250
2356
  const tasks = db.all(sql, ...params);
1251
2357
 
@@ -1259,7 +2365,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1259
2365
  return {
1260
2366
  content: [{
1261
2367
  type: "text" as const,
1262
- text: JSON.stringify({ ok: true, tasks, count: tasks.length, stats }),
2368
+ text: JSON.stringify({
2369
+ ok: true,
2370
+ ...(durable_cursor ? { capability: "list_tasks.immutable-node-cursor.v1" } : {}),
2371
+ tasks,
2372
+ count: tasks.length,
2373
+ next_cursor: tasks.length === limit ? {
2374
+ before_created_at: tasks[tasks.length - 1]?.created_at,
2375
+ before_task_id: tasks[tasks.length - 1]?.task_id,
2376
+ } : null,
2377
+ stats,
2378
+ }),
1263
2379
  }],
1264
2380
  };
1265
2381
  }
@@ -1273,24 +2389,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1273
2389
  task_id: z.string().min(1).max(200).describe("Task ID to cancel"),
1274
2390
  reason: z.string().max(1000).optional().describe("Cancellation reason"),
1275
2391
  from_session: z.string().max(200).optional(),
2392
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1276
2393
  },
1277
- async ({ task_id, reason, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1278
- const effectiveNetId = getNetworkId(null);
2394
+ async ({ task_id, reason, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2395
+ const effectiveNetId = getNetworkId(netId);
1279
2396
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1280
2397
  console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
1281
2398
  const updateParams: any[] = [reason || "cancelled by " + from_session, task_id];
1282
2399
  let updateSql = `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
1283
2400
  WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`;
1284
2401
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1285
- const result = db.run(updateSql, updateParams);
1286
- // Also ack the inbox entry to prevent agent from picking it up
1287
- if (result.changes > 0) {
1288
- const inboxParams: any[] = [task_id];
1289
- let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
1290
- inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
1291
- db.run(inboxSql, inboxParams);
1292
- logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
1293
- }
2402
+ const result = db.transaction(() => {
2403
+ const updated = db.run(updateSql, updateParams);
2404
+ // Also ack the inbox entry to prevent agent from picking it up.
2405
+ if (updated.changes > 0) {
2406
+ const inboxParams: any[] = [task_id];
2407
+ let inboxSql = "UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0";
2408
+ inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
2409
+ db.run(inboxSql, inboxParams);
2410
+ syncScheduledRunForTask(task_id, effectiveNetId);
2411
+ }
2412
+ return updated;
2413
+ });
2414
+ if (result.changes > 0) logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
1294
2415
  return {
1295
2416
  content: [{ type: "text" as const, text: JSON.stringify({ ok: result.changes > 0, task_id, cancelled: result.changes > 0 }) }],
1296
2417
  };
@@ -1305,9 +2426,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1305
2426
  task_id: z.string().min(1).max(200).describe("Task ID to reassign"),
1306
2427
  new_alias: z.string().min(1).max(200).describe("Target agent alias"),
1307
2428
  from_session: z.string().max(200).optional(),
2429
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1308
2430
  },
1309
- async ({ task_id, new_alias, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1310
- const effectiveNetId = getNetworkId(null);
2431
+ async ({ task_id, new_alias, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2432
+ const effectiveNetId = getNetworkId(netId);
1311
2433
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1312
2434
  console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
1313
2435
  const taskParams: any[] = [task_id];
@@ -1323,10 +2445,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1323
2445
  const reassignedAlias = canonical.alias;
1324
2446
  const target = resolveDeliveryTarget(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
1325
2447
  const newNodeId = target.state === "not_found" ? null : (target.session?.node_id ?? null);
2448
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 5/6).
2449
+ {
2450
+ const lc = assertNodeActive(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
2451
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
2452
+ }
1326
2453
  db.transaction(() => {
1327
2454
  // Ack old inbox to prevent original agent from picking it up
1328
2455
  const inboxParams: any[] = [task_id];
1329
- let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
2456
+ let inboxSql = "UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0";
1330
2457
  inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
1331
2458
  db.run(inboxSql, inboxParams);
1332
2459
 
@@ -1336,18 +2463,176 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1336
2463
  db.run(updateSql, updateParams);
1337
2464
 
1338
2465
  const newInboxId = uuidv4();
1339
- db.run("INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id) VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7)",
1340
- [newInboxId, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
2466
+ db.run("INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id) VALUES (?1, ?2, ?3, ?4, 'task', ?5, ?6, ?7, 'reply', ?8)",
2467
+ [newInboxId, task_id, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
1341
2468
  });
1342
2469
  logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${reassignedAlias}`);
1343
- pushEvent(reassignedAlias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session, ...(canonical.renamed ? { renamed_from: new_alias } : {}) }, effectiveNetId ?? task.network_id ?? null);
2470
+ pushEvent(reassignedAlias, { type: "new_task", inbox_count: pendingInboxCount(reassignedAlias, effectiveNetId ?? task.network_id ?? null), priority: task.priority, from: from_session, ...(canonical.renamed ? { renamed_from: new_alias } : {}) }, effectiveNetId ?? task.network_id ?? null);
2471
+ pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: reassignedAlias, status: "delivered", priority: task.priority });
1344
2472
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: reassignedAlias, ...(canonical.renamed ? { renamed_from: new_alias, renamed_to: reassignedAlias } : {}) }) }] };
1345
2473
  }
1346
2474
  );
1347
2475
 
1348
2476
  server.tool(
1349
- "broadcast",
1350
- "Send a message to multiple sessions.",
2477
+ "send_desktop_message",
2478
+ "Push a message to a user's active Desktop/web clients in one network. Targets user identity, not node alias.",
2479
+ {
2480
+ to_user_id: z.string().min(1).max(200).optional().describe("Target user_id. Mutually checked with to_username when both are supplied."),
2481
+ to_username: z.string().min(1).max(50).optional().describe("Target username. Mutually checked with to_user_id when both are supplied."),
2482
+ title: z.string().max(200).optional(),
2483
+ message: z.string().min(1).max(10000),
2484
+ severity: z.enum(["info", "success", "warning", "error"]).optional().default("info"),
2485
+ kind: z.string().min(1).max(80).optional().default("agent_message"),
2486
+ meta: z.record(z.string(), z.unknown()).optional(),
2487
+ from_session: z.string().max(200).optional(),
2488
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens; ntok stays bound to its network)."),
2489
+ },
2490
+ async ({ to_user_id, to_username, title, message, severity, kind, meta, from_session: _fromIn, network_id: netId }) => {
2491
+ const fromMismatch = fromIdentityMismatchReply(_fromIn);
2492
+ if (fromMismatch) return fromMismatch;
2493
+ const from_session = defaultFrom(_fromIn);
2494
+ const effectiveNetId = getNetworkId(netId);
2495
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
2496
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
2497
+
2498
+ if (!to_user_id && !to_username) {
2499
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_required", message: "to_user_id or to_username is required" }) }] };
2500
+ }
2501
+
2502
+ const byId = to_user_id
2503
+ ? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE user_id = ?1", to_user_id)
2504
+ : null;
2505
+ const byName = to_username
2506
+ ? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE username = ?1", to_username)
2507
+ : null;
2508
+
2509
+ if (to_user_id && !byId) {
2510
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_user_id" }) }] };
2511
+ }
2512
+ if (to_username && !byName) {
2513
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_username" }) }] };
2514
+ }
2515
+ if (byId && byName && byId.user_id !== byName.user_id) {
2516
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_mismatch", to_user_id, to_username }) }] };
2517
+ }
2518
+
2519
+ const target = byId ?? byName;
2520
+ if (!target) {
2521
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found" }) }] };
2522
+ }
2523
+
2524
+ const targetRole = getUserNetworkRole(target.user_id, effectiveNetId);
2525
+ if (!targetRole) {
2526
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_in_network", network_id: effectiveNetId }) }] };
2527
+ }
2528
+
2529
+ const messageId = `dm_${uuidv4()}`;
2530
+ const event = {
2531
+ type: "desktop_message",
2532
+ message_id: messageId,
2533
+ kind,
2534
+ from: from_session,
2535
+ title: title || null,
2536
+ message,
2537
+ severity,
2538
+ created_at: new Date().toISOString(),
2539
+ ...(meta && Object.keys(meta).length ? { meta } : {}),
2540
+ };
2541
+
2542
+ // #1459 ① P2 —— 审计与收件持久化必须同生共死:只落其中一半,就会出现
2543
+ // 「审计说发过、而用户永远收不到」或者反过来。放进同一个事务。
2544
+ db.transaction(() => {
2545
+ db.run(
2546
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
2547
+ VALUES (?1, ?2, 'send_desktop_message', 'user', ?3, ?4, ?5)`,
2548
+ [
2549
+ enforceUserId || null,
2550
+ callerAlias || null,
2551
+ target.user_id,
2552
+ JSON.stringify({
2553
+ message_id: messageId,
2554
+ to_username: target.username,
2555
+ from: from_session,
2556
+ severity,
2557
+ kind,
2558
+ title: title || null,
2559
+ meta_keys: meta && typeof meta === "object" ? Object.keys(meta) : [],
2560
+ }),
2561
+ effectiveNetId,
2562
+ ],
2563
+ );
2564
+ // 🔴 `ON CONFLICT(message_id) DO NOTHING`,而不是 `INSERT OR IGNORE`:
2565
+ // 两者对"重投同一个 message_id"效果相同(幂等,不产生重复消息),
2566
+ // 但 OR IGNORE 会**连同其它约束错误一起吞掉** —— 我在实现本段时就
2567
+ // 撞上了:`kind` 为 NULL 触发 NOT NULL,OR IGNORE 静默丢弃整行,
2568
+ // 而工具照样返回 `persisted: true`。**一条消息凭空消失且报告成功**,
2569
+ // 正是本 issue 要消灭的那个形状。
2570
+ // DO NOTHING 只针对主键冲突,别的约束照常抛出、事务回滚。
2571
+ //
2572
+ // 另:普通 INSERT 会让重试抛主键冲突(无害重试变 500);
2573
+ // OR REPLACE 会覆盖旧行、把用户已 ack 的状态冲掉。都不取。
2574
+ db.run(
2575
+ `INSERT INTO user_inbox
2576
+ (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json)
2577
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
2578
+ ON CONFLICT(message_id) DO NOTHING`,
2579
+ [
2580
+ messageId,
2581
+ effectiveNetId ?? null,
2582
+ target.user_id,
2583
+ from_session,
2584
+ // 🔴 不依赖 zod 的 .default():直接调用 handler 的调用方(测试、
2585
+ // 未来的内部调用)拿不到 zod 默认值,会把 NULL 写进 NOT NULL 列。
2586
+ // 列上虽有 DEFAULT,但**显式传 NULL 会覆盖列默认值**。
2587
+ kind ?? "agent_message",
2588
+ title || null,
2589
+ message,
2590
+ severity ?? "info",
2591
+ // 存储侧走与 inbox 一致的跨主机脱敏;读取侧另有一道 token 形状
2592
+ // 脱敏(规格 ③ redact-at-read),两层分别防不同的东西。
2593
+ meta && typeof meta === "object" ? normalizeMetaJson(meta) : null,
2594
+ ],
2595
+ );
2596
+ });
2597
+ // #1459 / #1563 — pushUserEvent 在没有订阅者时静默 return。
2598
+ // 🔴 这段注释原先写「本工具既不写 inbox、也没有 user 级回读路径 ⇒ dashboard
2599
+ // 关着时消息永久没了」。**那半句已经不成立** —— 上面 20 行就在
2600
+ // `INSERT INTO user_inbox`,且 `/api/messages?scope=user` 能回读
2601
+ // (desktop-message-seam.test.ts 有真实 send → 真实 GET 的端到端断言)。
2602
+ // 留着它会把 debug 未读数的人直接带向错误的一层。
2603
+ //
2604
+ // 这里先把**丢失变可见**:`ok` 仍表示「请求被接受、审计已落库」,
2605
+ // 新增 `delivered` 表示「此刻真的有人收到」。判据取自订阅者注册表,
2606
+ // 不是猜的。持久化 + 重连补投是另一条(按 user_id 寻址的新收件表,#1459)。
2607
+ //
2608
+ // 🔴 `reason` 报的是**观测到的事实**(此刻没有活订阅者),不是它的后果。
2609
+ // 今天「没有活订阅者」确实等于「丢了」,但持久化落地之后同一个事实会
2610
+ // 变成「已入库、等重连补投」。把值取成 no_live_subscriber 而不是
2611
+ // "lost"/"dropped",就是为了那天不用改写历史含义 —— 届时按需细分
2612
+ // (如 lost vs queued)即可,现在不预先把语义焊死。
2613
+ // 🔴 push 必须在事务**提交之后**:放进事务里的话,活订阅者可能先收到
2614
+ // 事件、而行还没提交;一旦回滚,用户就看到了一条数据库里不存在的消息。
2615
+ const delivered = hasUserSubscribers(effectiveNetId, target.user_id);
2616
+ pushUserEvent(effectiveNetId, target.user_id, event);
2617
+
2618
+ return { content: [{ type: "text" as const, text: JSON.stringify({
2619
+ ok: true,
2620
+ message_id: messageId,
2621
+ delivered,
2622
+ // #1459 ① P2 —— 有了持久化之后,「此刻没人在听」不再等于「丢了」。
2623
+ // `delivered` 说的是实时投递、`persisted` 说的是它已经入库等重连补投。
2624
+ // `reason` 仍报**观测到的事实**而不是它的后果,所以这个中间态没有说错话。
2625
+ persisted: true,
2626
+ ...(delivered ? {} : { reason: "no_live_subscriber" }),
2627
+ delivered_to_user_id: target.user_id,
2628
+ network_id: effectiveNetId,
2629
+ }) }] };
2630
+ },
2631
+ );
2632
+
2633
+ server.tool(
2634
+ "broadcast",
2635
+ "Send a message to multiple sessions.",
1351
2636
  {
1352
2637
  message: z.string().min(1).max(10000),
1353
2638
  filter_server: z.string().max(200).optional(),
@@ -1366,26 +2651,39 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1366
2651
 
1367
2652
  const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
1368
2653
  const ids: string[] = [];
2654
+ // #1440 ① — the push loop must walk the recipients that actually
2655
+ // got an inbox row, not every candidate target. Walking `targets`
2656
+ // announced a broadcast to nodes the lifecycle guard had just
2657
+ // skipped, i.e. nodes with nothing to fetch.
2658
+ const delivered: Array<{ alias: string; netId: string | null }> = [];
1369
2659
 
1370
2660
  for (const t of targets) {
2661
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 6/6).
2662
+ // Broadcast skips non-active recipients silently rather than
2663
+ // failing the entire send — broadcast semantics are best-effort
2664
+ // per-recipient and a stopped node simply gets nothing.
2665
+ const netId = effectiveNetId ?? t.network_id ?? null;
2666
+ const lc = assertNodeActive(t.alias, netId);
2667
+ if (!lc.ok) continue;
1371
2668
  const id = uuidv4();
1372
2669
  db.run(
1373
2670
  `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
1374
2671
  VALUES (?1, ?2, ?3, 'broadcast', 'normal', ?4, 'hub', ?5)`,
1375
- [id, t.alias, t.node_id ?? null, message, effectiveNetId ?? t.network_id ?? null]
2672
+ [id, t.alias, t.node_id ?? null, message, netId]
1376
2673
  );
1377
2674
  ids.push(id);
2675
+ delivered.push({ alias: t.alias, netId });
1378
2676
  }
1379
2677
 
1380
- for (const t of targets) {
1381
- pushEvent(t.alias, { type: "broadcast", inbox_count: 1 }, effectiveNetId ?? t.network_id ?? null);
2678
+ for (const d of delivered) {
2679
+ pushEvent(d.alias, { type: "broadcast", inbox_count: pendingInboxCount(d.alias, d.netId) }, d.netId);
1382
2680
  }
1383
2681
 
1384
2682
  return {
1385
2683
  content: [
1386
2684
  {
1387
2685
  type: "text" as const,
1388
- text: JSON.stringify({ ok: true, recipients: targets.length, message_ids: ids }),
2686
+ text: JSON.stringify({ ok: true, recipients: ids.length, message_ids: ids }),
1389
2687
  },
1390
2688
  ],
1391
2689
  };
@@ -1478,15 +2776,34 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1478
2776
  "update_node_config",
1479
2777
  "Set the desired per-node config (model + flags) and push a doorbell to the node. The node pulls + validates + applies (hot or restart per field tier). RFC-024.",
1480
2778
  {
1481
- node_id: z.string().min(1).max(200).describe("Target node ID (not alias). Must be in caller's network."),
2779
+ ...NODE_ID_ALIAS_FIELDS,
1482
2780
  base_revision: z.number().int().min(0).describe("Current revision per the dashboard's last GET — 409 if hub's current revision differs."),
1483
2781
  patch: z.object({
1484
2782
  model: z.string().max(200).optional(),
1485
- flags: z.record(z.unknown()).optional(),
2783
+ flags: z.record(z.string(), z.unknown()).optional(),
2784
+ // #260 P5 — channel enable/disable. Restart-tier field (agent-node
2785
+ // reads config.channels once at boot to fork per-channel workers,
2786
+ // so the swap takes effect via process restart). Not
2787
+ // SECURITY_SENSITIVE — this is a lifecycle op, gated by SEC-1
2788
+ // (network scope) only.
2789
+ //
2790
+ // Deliberately `z.array(z.unknown()).max(16)` (mirrors flags's
2791
+ // `z.record(z.string(), z.unknown())`): trust nothing at the wire boundary,
2792
+ // narrow the same way `narrowChannelsPatch` narrows raw untrusted
2793
+ // JSON (typeof + allowlist + dedup + case-fold). A strict
2794
+ // `z.array(z.string())` would fail-fast on a single non-string
2795
+ // entry, but the wire contract wants junk silently dropped so a
2796
+ // dashboard fat-finger doesn't turn into a 400 the user has to
2797
+ // interpret. validatePatch then re-rejects if the caller bypassed
2798
+ // narrowing.
2799
+ channels: z.array(z.unknown()).max(16).optional(),
1486
2800
  }).describe("Fields to update. Empty patch → no-op (use restart_node for that)."),
1487
2801
  network_id: z.string().max(200).optional(),
1488
2802
  },
1489
- async ({ node_id: nodeId, base_revision: baseRev, patch, network_id: clientNetId }) => {
2803
+ async ({ node_id, child_node_id, base_revision: baseRev, patch, network_id: clientNetId }) => {
2804
+ const idArg = resolveNodeIdArg({ node_id, child_node_id });
2805
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
2806
+ const nodeId = idArg.node_id;
1490
2807
  const effectiveNetId = getNetworkId(clientNetId);
1491
2808
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
1492
2809
 
@@ -1500,6 +2817,50 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1500
2817
 
1501
2818
  const model = typeof patch.model === "string" ? patch.model : undefined;
1502
2819
  const flags = (patch.flags && typeof patch.flags === "object") ? patch.flags as Record<string, unknown> : {};
2820
+ // Narrow untrusted `channels` at the boundary (typeof + allowlist +
2821
+ // dedup + case-fold), and distinguish two very different cases
2822
+ // that both narrow to `[]`:
2823
+ // (a) `patch.channels === []` (explicit "disable all editable
2824
+ // channels"). Downstream must proceed and write channels=[]
2825
+ // so the node's next restart forks no workers.
2826
+ // (b) `patch.channels === ["commhub"]` or similar — the caller
2827
+ // sent items but every single one was invalid (dashboard PR
2828
+ // #31 still ships commhub, and a typo like "telegarm" would
2829
+ // hit the same path). Downstream MUST NOT treat this as
2830
+ // (a) — silently converting to disable-all would nuke the
2831
+ // user's existing telegram/feishu workers.
2832
+ //
2833
+ // The reject error is `channels_all_invalid` so the dashboard
2834
+ // can surface the mismatch instead of showing a false success.
2835
+ let channels: string[] | undefined = undefined;
2836
+ if (patch.channels !== undefined) {
2837
+ if (!Array.isArray(patch.channels)) {
2838
+ // Zod already permits arrays only; belt+braces if it drifts.
2839
+ return {
2840
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_patch", field: "channels", reason: "must be an array" }) }],
2841
+ };
2842
+ }
2843
+ const narrowed = narrowChannelsPatch(patch.channels) ?? [];
2844
+ if (patch.channels.length === 0) {
2845
+ channels = []; // (a) explicit disable-all
2846
+ } else if (narrowed.length === 0) {
2847
+ // (b) every entry was invalid — refuse to write. Distinct
2848
+ // error so the dashboard can surface the failure.
2849
+ return {
2850
+ content: [{
2851
+ type: "text" as const,
2852
+ text: JSON.stringify({
2853
+ ok: false,
2854
+ error: "channels_all_invalid",
2855
+ requested: patch.channels,
2856
+ message: "every requested channel is unknown or unsupported; use an explicit empty array to disable-all",
2857
+ }),
2858
+ }],
2859
+ };
2860
+ } else {
2861
+ channels = narrowed;
2862
+ }
2863
+ }
1503
2864
 
1504
2865
  // SEC-2 (final policy 2026-06-28) — security-sensitive flags
1505
2866
  // (permissionMode / dangerouslySkipPermissions / teammateMode)
@@ -1526,7 +2887,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1526
2887
  };
1527
2888
  }
1528
2889
 
1529
- const validationFail = validatePatch(model, flags);
2890
+ const validationFail = validatePatch(model, flags, channels);
1530
2891
  if (validationFail) {
1531
2892
  return {
1532
2893
  content: [{
@@ -1604,8 +2965,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1604
2965
 
1605
2966
  // Compute apply_mode + persist + push doorbell.
1606
2967
  const updateId = `cu_${uuidv4()}`;
1607
- const applyMode = computeApplyMode(model, flags);
1608
- const patchJson = JSON.stringify({ ...(model !== undefined ? { model } : {}), flags });
2968
+ const applyMode = computeApplyMode(model, flags, channels);
2969
+ const patchJson = JSON.stringify({
2970
+ ...(model !== undefined ? { model } : {}),
2971
+ flags,
2972
+ ...(channels !== undefined ? { channels } : {}),
2973
+ });
1609
2974
  const networkId = node.network_id || "default";
1610
2975
  db.run(
1611
2976
  `INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7, ?8)`,
@@ -1623,6 +2988,51 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1623
2988
  },
1624
2989
  );
1625
2990
 
2991
+ // One alias can own several `nodes` rows in a network: re-creating a node
2992
+ // under the same alias leaves the old row behind, and `db.get` on
2993
+ // `WHERE alias = ? AND network_id = ?` then returns whichever row SQLite
2994
+ // happens to yield first. A field report showed the result: the client
2995
+ // queued a rules-file read for the live node, the node pulled with its own
2996
+ // token, the hub resolved its alias to the stale row, found nothing pending
2997
+ // and returned `request: null` until the read timed out.
2998
+ //
2999
+ // resolveNodeByAlias: prefer the row the alias's session points at (sessions
3000
+ // are UNIQUE per network+alias and carry the node_id that last reported),
3001
+ // then the newest row. Used where only an alias is known (a client targeting
3002
+ // by alias; unbound legacy node tokens).
3003
+ const resolveNodeByAlias = (alias: string, networkId: string) =>
3004
+ db.get<{ node_id: string; alias: string; network_id: string | null }>(
3005
+ `SELECT n.node_id, n.alias, n.network_id FROM nodes n
3006
+ LEFT JOIN sessions s ON s.alias = n.alias AND s.network_id = n.network_id AND s.node_id = n.node_id
3007
+ WHERE n.alias = ?1 AND n.network_id = ?2
3008
+ ORDER BY (s.node_id IS NOT NULL) DESC, n.created_at DESC, n.rowid DESC
3009
+ LIMIT 1`,
3010
+ alias,
3011
+ networkId,
3012
+ ) ?? null;
3013
+ // resolveCallerNode: the node a network token pulls/acks for. A token bound
3014
+ // to a node resolves to exactly that node (and never to another row that
3015
+ // shares its alias); only unbound legacy tokens fall back to the alias.
3016
+ // Callers have already required callerTokenIsNetwork + enforceNetworkId +
3017
+ // callerAlias.
3018
+ const resolveCallerNode = (): { node_id: string; network_id: string | null } | null => {
3019
+ const bound = callerTokenId
3020
+ ? db.get<{ bound_node_id: string | null }>(
3021
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
3022
+ callerTokenId,
3023
+ enforceNetworkId,
3024
+ )?.bound_node_id ?? null
3025
+ : null;
3026
+ if (bound) {
3027
+ return db.get<{ node_id: string; network_id: string | null }>(
3028
+ "SELECT node_id, network_id FROM nodes WHERE node_id = ?1 AND network_id = ?2",
3029
+ bound,
3030
+ enforceNetworkId,
3031
+ ) ?? null;
3032
+ }
3033
+ return resolveNodeByAlias(callerAlias!, enforceNetworkId!);
3034
+ };
3035
+
1626
3036
  server.tool(
1627
3037
  "get_config_update",
1628
3038
  "Node pulls its pending config update (called from agent-node when SSE config_update doorbell arrives). RFC-024.",
@@ -1646,11 +3056,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1646
3056
  // Unconditional network_id filter (was previously conditional on
1647
3057
  // enforceNetworkId being set; the new ntok guard above guarantees
1648
3058
  // it's non-null so the filter is always applied).
1649
- const node = db.get<any>(
1650
- "SELECT node_id, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
1651
- callerAlias,
1652
- enforceNetworkId,
1653
- );
3059
+ const node = resolveCallerNode();
1654
3060
  if (!node) {
1655
3061
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update: null }) }] };
1656
3062
  }
@@ -1703,11 +3109,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1703
3109
  // Cross-tenant guard: the ack-er must own the update being acked.
1704
3110
  // Resolve node by caller's alias under the enforced network.
1705
3111
  // Network filter is unconditional (guard above guarantees non-null).
1706
- const node = db.get<any>(
1707
- "SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
1708
- callerAlias,
1709
- enforceNetworkId,
1710
- );
3112
+ const node = resolveCallerNode();
1711
3113
  if (!node) {
1712
3114
  // Silently ignore stale ack — return ok so the node doesn't retry forever.
1713
3115
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "alias_unknown" }) }] };
@@ -1743,14 +3145,392 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1743
3145
  },
1744
3146
  );
1745
3147
 
3148
+ // ── app#225 — 节点规则文件(AGENTS.md / CLAUDE.md)远程读写 ──
3149
+ //
3150
+ // 形状照抄 RFC-024 config-apply:客户端调 read_node_rules_file /
3151
+ // write_node_rules_file 落一行 node_rules_requests + 门铃 {type:"rules_file"};
3152
+ // 节点收到门铃后 get_rules_file_request 拉(和 get_config_update 同一道
3153
+ // F-A 闸:网络 token + alias),做完 ack_rules_file_request 回报;客户端
3154
+ // 轮询 get_rules_file_result。
3155
+ //
3156
+ // 🔴 安全(#225 验收第 5 条):整条链路**没有路径参数**。文件名由节点按
3157
+ // 自己的 RUNTIME 决定(claude → CLAUDE.md,其余 → AGENTS.md),目录固定是
3158
+ // 节点进程的 cwd(agent-node/src/runtime/rules-file.ts)。hub 只传 op +
3159
+ // content,客户端连文件名都指定不了,所以不存在「借路径参数写任意文件」。
3160
+ // 读和写都要 canWrite:规则文件是节点行为的一部分,读它等于读节点配置。
3161
+ const RULES_FILE_MAX_BYTES = 256 * 1024;
3162
+ const RULES_REQUEST_STALE_MS = 60_000;
3163
+
3164
+ // Node skills view — skills_list / skill_read ride the same queue + doorbell.
3165
+ // `content` carries the skill NAME for skill_read (never a path; the node
3166
+ // validates it again against [A-Za-z0-9._-] and resolves it under its own
3167
+ // runtime's skills roots — node-skills.ts).
3168
+ const SKILL_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/;
3169
+ const isSkillsOp = (op: string) => op === "skills_list" || op === "skill_read";
3170
+
3171
+ // Project folder view — files_list / file_read ride the same queue + doorbell.
3172
+ // `content` carries a path RELATIVE to the node's work dir. The hub refuses
3173
+ // absolute paths, `..` segments, backslashes and NUL before any row exists;
3174
+ // the node re-validates, realpath-contains the result in its work dir and
3175
+ // refuses secret-looking files (node-files.ts). Stricter than skills on the
3176
+ // caller: a node token may not browse another node's disk, and only the token
3177
+ // that asked can read the answer.
3178
+ const NODE_FILE_PATH_MAX = 1024;
3179
+ const NODE_FILES_RESULT_MAX_CHARS = 1024 * 1024;
3180
+ const isFilesOp = (op: string) => op === "files_list" || op === "file_read";
3181
+ const normalizeNodeRelPath = (raw: unknown): string | null => {
3182
+ if (raw === undefined || raw === null) return "";
3183
+ if (typeof raw !== "string" || raw.length > NODE_FILE_PATH_MAX) return null;
3184
+ if (raw.includes("\0") || raw.includes("\\")) return null;
3185
+ if (raw.startsWith("/") || raw.startsWith("~") || /^[A-Za-z]:/.test(raw)) return null;
3186
+ const segs = raw.split("/").filter((s) => s !== "" && s !== ".");
3187
+ if (segs.some((s) => s === "..")) return null;
3188
+ return segs.join("/");
3189
+ };
3190
+
3191
+ const enqueueRulesFileRequest = (
3192
+ op: "read" | "write" | "skills_list" | "skill_read" | "files_list" | "file_read",
3193
+ a: { node_id?: string; child_node_id?: string; alias?: string; network_id?: string; content?: string },
3194
+ ) => {
3195
+ const effectiveNetId = getNetworkId(a.network_id);
3196
+ if (isFilesOp(op)) {
3197
+ if (callerTokenIsNetwork) {
3198
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_cannot_browse_files", message: "a node token cannot browse another node's project folder; use a user login" }) }] };
3199
+ }
3200
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, `${op} node files`);
3201
+ const rel = normalizeNodeRelPath(a.content);
3202
+ if (rel === null || (op === "file_read" && rel === "")) {
3203
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_path", reason: "path must be relative to the node work dir: no leading / or ~, no .. segment, no backslash or NUL, at most 1024 chars" }) }] };
3204
+ }
3205
+ a = { ...a, content: rel };
3206
+ }
3207
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, isSkillsOp(op) ? `${op} node skills` : `${op} rules file`);
3208
+ if (op === "skill_read" && (typeof a.content !== "string" || !SKILL_NAME_RE.test(a.content) || a.content === "." || a.content === "..")) {
3209
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_skill_name", reason: "name must match [A-Za-z0-9._-]{1,64} and not be . or .." }) }] };
3210
+ }
3211
+
3212
+ // Target: a `nodes` row by node_id (original path), or — app#225 follow-up —
3213
+ // an alias. An alias resolves to its `nodes` row when there is one; otherwise
3214
+ // to a live session in the caller's network that advertised
3215
+ // rules_file_capable (claude-code sessions mostly have no `nodes` row). The
3216
+ // queue key for such a session is `session:<alias>`, network-scoped.
3217
+ let node: { node_id: string; alias: string; network_id: string | null } | null = null;
3218
+ if (a.node_id || a.child_node_id || !a.alias) {
3219
+ const idArg = resolveNodeIdArg({ node_id: a.node_id, child_node_id: a.child_node_id });
3220
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
3221
+ const nodeId = idArg.node_id;
3222
+ const resolved = resolveTargetNode(nodeId, effectiveNetId);
3223
+ if (!resolved.row) {
3224
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: nodeId }) }] };
3225
+ }
3226
+ if (!resolved.sec1Ok) {
3227
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node", message: "node belongs to another network" }) }] };
3228
+ }
3229
+ node = resolved.row as any;
3230
+ } else {
3231
+ const scopeNet = effectiveNetId || "default";
3232
+ const byAlias = resolveNodeByAlias(a.alias, scopeNet);
3233
+ if (byAlias) {
3234
+ node = byAlias;
3235
+ } else {
3236
+ const session = db.get<{ alias: string; network_id: string | null }>(
3237
+ isFilesOp(op)
3238
+ ? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND files_capable = 1 ORDER BY updated_at DESC LIMIT 1"
3239
+ : isSkillsOp(op)
3240
+ ? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND skills_capable = 1 ORDER BY updated_at DESC LIMIT 1"
3241
+ : "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND rules_file_capable = 1 ORDER BY updated_at DESC LIMIT 1",
3242
+ a.alias,
3243
+ scopeNet,
3244
+ );
3245
+ if (!session) {
3246
+ if (isFilesOp(op)) {
3247
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "files_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve its project folder (its channel server may be too old)" }) }] };
3248
+ }
3249
+ return isSkillsOp(op)
3250
+ ? { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "skills_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve its skills (its channel server may be too old)" }) }] }
3251
+ : { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "rules_file_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve rules files (its channel server may be too old)" }) }] };
3252
+ }
3253
+ node = { node_id: `session:${session.alias}`, alias: session.alias, network_id: session.network_id };
3254
+ }
3255
+ }
3256
+ const nodeId = node.node_id;
3257
+ if (op === "write") {
3258
+ if (typeof a.content !== "string") {
3259
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_content", reason: "content must be a string" }) }] };
3260
+ }
3261
+ const bytes = Buffer.byteLength(a.content, "utf8");
3262
+ if (bytes > RULES_FILE_MAX_BYTES) {
3263
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "content_too_large", bytes, max_bytes: RULES_FILE_MAX_BYTES }) }] };
3264
+ }
3265
+ }
3266
+
3267
+ // 单飞 + 陈旧回收,同 update_node_config 的 F-B:节点掉线时旧行永远
3268
+ // 非终态,不回收就把这个节点锁死。
3269
+ // Separate single-flight lanes: the client opens the rules file and the
3270
+ // skills list together; one must not block the other.
3271
+ const inFlight = db.get<{ request_id: string; created_at: number; pulled_at: number | null }>(
3272
+ isFilesOp(op)
3273
+ ? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('files_list', 'file_read') ORDER BY created_at DESC LIMIT 1"
3274
+ : isSkillsOp(op)
3275
+ ? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('skills_list', 'skill_read') ORDER BY created_at DESC LIMIT 1"
3276
+ : "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('read', 'write') ORDER BY created_at DESC LIMIT 1",
3277
+ nodeId,
3278
+ );
3279
+ if (inFlight) {
3280
+ const age = Date.now() - (inFlight.pulled_at ?? inFlight.created_at);
3281
+ if (age <= RULES_REQUEST_STALE_MS) {
3282
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_in_flight", existing_request_id: inFlight.request_id, age_ms: age }) }] };
3283
+ }
3284
+ db.run(
3285
+ "UPDATE node_rules_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3",
3286
+ [Date.now(), `superseded after ${age}ms (> ${RULES_REQUEST_STALE_MS}ms) — node did not answer`, inFlight.request_id],
3287
+ );
3288
+ }
3289
+
3290
+ // Opportunistic content retention (bounded, indexed) — node-request-retention.ts.
3291
+ sweepNodeRequestContent();
3292
+ const requestId = `rf_${uuidv4()}`;
3293
+ const networkId = node.network_id || "default";
3294
+ db.run(
3295
+ `INSERT INTO node_rules_requests (request_id, node_id, network_id, op, content, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)`,
3296
+ [requestId, nodeId, networkId, op, op === "write" || op === "skill_read" || isFilesOp(op) ? a.content! : null, Date.now(), callerTokenId || "unknown"],
3297
+ );
3298
+ pushEvent(node.alias, { type: "rules_file", request_id: requestId }, networkId);
3299
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, op }) }] };
3300
+ };
3301
+
3302
+ server.tool(
3303
+ "read_node_rules_file",
3304
+ "Ask a node to send back its rules file (CLAUDE.md for claude nodes, AGENTS.md otherwise) from its working directory. No path argument by design. Poll get_rules_file_result with the returned request_id. app#225.",
3305
+ {
3306
+ ...NODE_ID_ALIAS_FIELDS,
3307
+ alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported rules_file_capable (claude-code sessions)."),
3308
+ network_id: z.string().max(200).optional(),
3309
+ },
3310
+ async ({ node_id, child_node_id, alias, network_id }) => enqueueRulesFileRequest("read", { node_id, child_node_id, alias, network_id }),
3311
+ );
3312
+
3313
+ server.tool(
3314
+ "write_node_rules_file",
3315
+ "Ask a node to overwrite its rules file (CLAUDE.md for claude nodes, AGENTS.md otherwise) in its working directory with `content`. No path argument by design. Poll get_rules_file_result with the returned request_id. app#225.",
3316
+ {
3317
+ ...NODE_ID_ALIAS_FIELDS,
3318
+ alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported rules_file_capable (claude-code sessions)."),
3319
+ content: z.string().max(RULES_FILE_MAX_BYTES).describe("Full new file content (UTF-8). The node writes it atomically."),
3320
+ network_id: z.string().max(200).optional(),
3321
+ },
3322
+ async ({ node_id, child_node_id, alias, content, network_id }) => enqueueRulesFileRequest("write", { node_id, child_node_id, alias, content, network_id }),
3323
+ );
3324
+
3325
+ server.tool(
3326
+ "list_node_skills",
3327
+ "Ask a node to list the skills its runtime loads (name, scope project|user|system, display path, frontmatter description). Read-only; no path argument. Poll get_rules_file_result — content is JSON {skills:[…]}.",
3328
+ {
3329
+ ...NODE_ID_ALIAS_FIELDS,
3330
+ alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported skills_capable."),
3331
+ network_id: z.string().max(200).optional(),
3332
+ },
3333
+ async ({ node_id, child_node_id, alias, network_id }) => enqueueRulesFileRequest("skills_list", { node_id, child_node_id, alias, network_id }),
3334
+ );
3335
+
3336
+ server.tool(
3337
+ "read_node_skill",
3338
+ "Ask a node for one skill's SKILL.md by name. Read-only; the node resolves the name under its own runtime's skills roots — no path argument. Poll get_rules_file_result — content is JSON {name, scope, path_rel, description, content}.",
3339
+ {
3340
+ ...NODE_ID_ALIAS_FIELDS,
3341
+ alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported skills_capable."),
3342
+ name: z.string().min(1).max(64).describe("Skill directory name, [A-Za-z0-9._-]."),
3343
+ network_id: z.string().max(200).optional(),
3344
+ },
3345
+ async ({ node_id, child_node_id, alias, name, network_id }) => enqueueRulesFileRequest("skill_read", { node_id, child_node_id, alias, content: name, network_id }),
3346
+ );
3347
+
3348
+ server.tool(
3349
+ "list_node_files",
3350
+ "Ask a node to list one directory level of its work dir (project folder view, read-only). `path` is relative to the work dir (default: the root); absolute paths and .. are refused. Poll get_rules_file_result — content is JSON {path, entries:[{name,type,size?,mtime?,hidden_reason?,no_descend?}], truncated, total}. User logins only.",
3351
+ {
3352
+ ...NODE_ID_ALIAS_FIELDS,
3353
+ alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported files_capable."),
3354
+ path: z.string().max(NODE_FILE_PATH_MAX).optional().describe("Directory relative to the node's work dir; omit or \"\" for the root."),
3355
+ network_id: z.string().max(200).optional(),
3356
+ },
3357
+ async ({ node_id, child_node_id, alias, path: relPath, network_id }) => enqueueRulesFileRequest("files_list", { node_id, child_node_id, alias, content: relPath ?? "", network_id }),
3358
+ );
3359
+
3360
+ server.tool(
3361
+ "read_node_file",
3362
+ "Ask a node for one text file under its work dir (project folder view, read-only, 256 KiB cap; binary / too large → size only; secret-looking files → name only). `path` is relative to the work dir. Poll get_rules_file_result — content is JSON {path, name, kind, size?, mtime?, content?, hidden_reason?}. User logins only.",
3363
+ {
3364
+ ...NODE_ID_ALIAS_FIELDS,
3365
+ alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported files_capable."),
3366
+ path: z.string().min(1).max(NODE_FILE_PATH_MAX).describe("File path relative to the node's work dir."),
3367
+ network_id: z.string().max(200).optional(),
3368
+ },
3369
+ async ({ node_id, child_node_id, alias, path: relPath, network_id }) => enqueueRulesFileRequest("file_read", { node_id, child_node_id, alias, content: relPath, network_id }),
3370
+ );
3371
+
3372
+ server.tool(
3373
+ "get_rules_file_request",
3374
+ "Node pulls its oldest pending rules-file request (called from agent-node when the SSE rules_file doorbell arrives). app#225.",
3375
+ {},
3376
+ async () => {
3377
+ // 同 get_config_update 的 F-A 闸:必须是网络 token + alias。
3378
+ if (!callerTokenIsNetwork || !enforceNetworkId) {
3379
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
3380
+ }
3381
+ if (!callerAlias) {
3382
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
3383
+ }
3384
+ const node = resolveCallerNode();
3385
+ // app#225 follow-up — no `nodes` row: this is a session-only target
3386
+ // (claude-code). Its queue key is `session:<alias>`, always paired with the
3387
+ // caller's network so a same-named alias in another network never matches.
3388
+ const queueKey: string = node ? node.node_id : `session:${callerAlias}`;
3389
+ const req = db.get<any>(
3390
+ "SELECT request_id, op, content FROM node_rules_requests WHERE node_id = ?1 AND network_id = ?2 AND status = 'pending' ORDER BY created_at ASC LIMIT 1",
3391
+ queueKey,
3392
+ enforceNetworkId,
3393
+ );
3394
+ if (!req) {
3395
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request: null }) }] };
3396
+ }
3397
+ db.run(
3398
+ "UPDATE node_rules_requests SET status = 'in_progress', pulled_at = ?1 WHERE request_id = ?2 AND status = 'pending'",
3399
+ [Date.now(), req.request_id],
3400
+ );
3401
+ return {
3402
+ content: [{
3403
+ type: "text" as const,
3404
+ text: JSON.stringify({
3405
+ ok: true,
3406
+ request: {
3407
+ request_id: req.request_id,
3408
+ op: req.op,
3409
+ ...(req.op === "write" || req.op === "skill_read" || isFilesOp(req.op) ? { content: req.content ?? "" } : {}),
3410
+ },
3411
+ }),
3412
+ }],
3413
+ };
3414
+ },
3415
+ );
3416
+
3417
+ server.tool(
3418
+ "ack_rules_file_request",
3419
+ "Node reports the outcome of a rules-file request — done (with content for reads) or failed (with error). app#225.",
3420
+ {
3421
+ request_id: z.string().min(1).max(200),
3422
+ status: z.enum(["done", "failed"]),
3423
+ file_name: z.string().max(64).optional(),
3424
+ exists: z.boolean().optional(),
3425
+ // Files results (JSON listing / a 256 KiB file, escaped) may exceed the rules
3426
+ // cap; the per-op cap is enforced below once the row's op is known.
3427
+ content: z.string().max(NODE_FILES_RESULT_MAX_CHARS).optional(),
3428
+ error: z.string().max(2000).optional(),
3429
+ },
3430
+ async ({ request_id: requestId, status, file_name: fileName, exists, content, error: ackError }) => {
3431
+ if (!callerTokenIsNetwork || !enforceNetworkId) {
3432
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
3433
+ }
3434
+ if (!callerAlias) {
3435
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
3436
+ }
3437
+ const node = resolveCallerNode();
3438
+ const queueKey: string = node ? node.node_id : `session:${callerAlias}`;
3439
+ const req = db.get<any>(
3440
+ "SELECT request_id, node_id, network_id, status, op FROM node_rules_requests WHERE request_id = ?1",
3441
+ requestId,
3442
+ );
3443
+ // 跨租户闸:只能 ack 自己的请求;别人的当不存在处理。session 键另外核网络。
3444
+ if (!req || req.node_id !== queueKey || req.network_id !== enforceNetworkId) {
3445
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "unknown_or_foreign_request" }) }] };
3446
+ }
3447
+ if (req.status === "done" || req.status === "failed" || req.status === "timeout") {
3448
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "already_terminal", current_status: req.status }) }] };
3449
+ }
3450
+ if (typeof content === "string" && !isFilesOp(req.op) && content.length > RULES_FILE_MAX_BYTES) {
3451
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "content_too_large", max: RULES_FILE_MAX_BYTES }) }] };
3452
+ }
3453
+ db.run(
3454
+ `UPDATE node_rules_requests SET status = ?1, acked_at = ?2, file_name = ?3, file_exists = ?4, result_content = ?5, error = ?6 WHERE request_id = ?7`,
3455
+ [
3456
+ status,
3457
+ Date.now(),
3458
+ fileName ?? null,
3459
+ typeof exists === "boolean" ? (exists ? 1 : 0) : null,
3460
+ status === "done" ? (content ?? null) : null,
3461
+ status === "failed" ? (ackError || "node reported failure without a reason") : null,
3462
+ requestId,
3463
+ ],
3464
+ );
3465
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, status }) }] };
3466
+ },
3467
+ );
3468
+
3469
+ server.tool(
3470
+ "get_rules_file_result",
3471
+ "Poll the outcome of a read_node_rules_file / write_node_rules_file request. Returns status pending|in_progress|done|failed|timeout, the file name the node used, and (for reads) the content. app#225.",
3472
+ { request_id: z.string().min(1).max(200), network_id: z.string().max(200).optional() },
3473
+ async ({ request_id: requestId, network_id: clientNetId }) => {
3474
+ const effectiveNetId = getNetworkId(clientNetId);
3475
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "read rules file result");
3476
+ const row = db.get<any>(
3477
+ "SELECT request_id, node_id, network_id, op, status, file_name, file_exists, result_content, error, created_at, pulled_at, acked_at, created_by_token FROM node_rules_requests WHERE request_id = ?1",
3478
+ requestId,
3479
+ );
3480
+ // SEC-1:结果行的网络必须等于调用方作用域,否则当不存在。
3481
+ // Project folder results: only the (user) token that asked may read them.
3482
+ const foreignFilesRow = !!row && isFilesOp(row.op) && (callerTokenIsNetwork || row.created_by_token !== (callerTokenId || "unknown"));
3483
+ if (!row || row.network_id !== (effectiveNetId || "default") || foreignFilesRow) {
3484
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found", request_id: requestId }) }] };
3485
+ }
3486
+ let status: string = row.status;
3487
+ const ageMs = Date.now() - (row.pulled_at ?? row.created_at);
3488
+ if ((status === "pending" || status === "in_progress") && ageMs > RULES_REQUEST_STALE_MS) {
3489
+ status = "timeout";
3490
+ db.run(
3491
+ "UPDATE node_rules_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3 AND status IN ('pending', 'in_progress')",
3492
+ [Date.now(), `node did not answer within ${RULES_REQUEST_STALE_MS}ms (offline, or running an agent-node without app#225 support)`, requestId],
3493
+ );
3494
+ row.error = `node did not answer within ${RULES_REQUEST_STALE_MS}ms (offline, or running an agent-node without app#225 support)`;
3495
+ }
3496
+ // Privacy: node file bytes are handed out for a short grace window after the
3497
+ // first terminal read, then purged (node-request-retention.ts). Only reached
3498
+ // after the SEC-1 scope check above — a foreign caller can't stamp/purge.
3499
+ const terminal = status === "done" || status === "failed" || status === "timeout";
3500
+ const purged = terminal ? noteTerminalResultRead(requestId).purged : false;
3501
+ return {
3502
+ content: [{
3503
+ type: "text" as const,
3504
+ text: JSON.stringify({
3505
+ ok: true,
3506
+ request_id: row.request_id,
3507
+ node_id: row.node_id,
3508
+ op: row.op,
3509
+ status,
3510
+ file_name: row.file_name ?? null,
3511
+ exists: row.file_exists === null || row.file_exists === undefined ? null : row.file_exists === 1,
3512
+ ...(status === "done" && row.op !== "write" && !purged ? { content: row.result_content ?? "" } : {}),
3513
+ ...(purged ? { content_purged: true } : {}),
3514
+ error: row.error ?? null,
3515
+ age_ms: ageMs,
3516
+ }),
3517
+ }],
3518
+ };
3519
+ },
3520
+ );
3521
+
3522
+
1746
3523
  server.tool(
1747
3524
  "restart_node",
1748
3525
  "Trigger a node restart without changing config. RFC-024 Vincent 2026-06-28 increment. Network-scoped (SEC-1); member+ role suffices (lifecycle ops are not privilege elevation).",
1749
3526
  {
1750
- node_id: z.string().min(1).max(200),
3527
+ ...NODE_ID_ALIAS_FIELDS,
1751
3528
  network_id: z.string().max(200).optional(),
1752
3529
  },
1753
- async ({ node_id: nodeId, network_id: clientNetId }) => {
3530
+ async ({ node_id, child_node_id, network_id: clientNetId }) => {
3531
+ const idArg = resolveNodeIdArg({ node_id, child_node_id });
3532
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
3533
+ const nodeId = idArg.node_id;
1754
3534
  const effectiveNetId = getNetworkId(clientNetId);
1755
3535
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
1756
3536
 
@@ -1782,10 +3562,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1782
3562
  }
1783
3563
  const updateId = `cu_${uuidv4()}`;
1784
3564
  const networkId = node.network_id || "default";
1785
- db.run(
1786
- `INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, '{}', 'restart_only', ?4, 'pending', ?5, ?6)`,
1787
- [updateId, nodeId, networkId, node.config_revision || 0, Date.now(), callerTokenId || "unknown"],
1788
- );
3565
+ // RFC-027 PR1.2a latent fix (#346 ack): restart_node must reset
3566
+ // lifecycle_state to 'active'. Otherwise a node that was previously
3567
+ // stop_node'd → 'stopped' would, after restart, stay marked
3568
+ // 'stopped' in the nodes table → the 6 MCP + 2 REST inbox guards
3569
+ // would refuse every routing attempt → node silently unreachable
3570
+ // (no error to operator, just no traffic). Pair the schema flip
3571
+ // with the config_updates INSERT inside one tx so we don't
3572
+ // half-commit if the dispatch INSERT throws.
3573
+ db.transaction(() => {
3574
+ db.run(
3575
+ `INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, '{}', 'restart_only', ?4, 'pending', ?5, ?6)`,
3576
+ [updateId, nodeId, networkId, node.config_revision || 0, Date.now(), callerTokenId || "unknown"],
3577
+ );
3578
+ db.run(
3579
+ `UPDATE nodes SET lifecycle_state = 'active' WHERE node_id = ?1`,
3580
+ [nodeId],
3581
+ );
3582
+ });
1789
3583
  pushEvent(node.alias, { type: "restart", update_id: updateId }, networkId);
1790
3584
  return {
1791
3585
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update_id: updateId, apply_mode: "restart_only" }) }],
@@ -1818,6 +3612,143 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1818
3612
  throw e;
1819
3613
  };
1820
3614
 
3615
+ // RFC-026 §9.2.1 / #338 PR2 — list_host_supervisors.
3616
+ // Surfaces host_supervisor daemons in the caller's network with
3617
+ // online flag + declared capabilities. Replaces the prior `node_daemon_`
3618
+ // prefix heuristic (dashboard /api/anet/node-create) + the role-extract
3619
+ // path on /api/nodes (#337). Member脱敏 strips host_telemetry IP /
3620
+ // cpu / mem (per RFC-026 §6 #3 — daemons can leak internal topology
3621
+ // to non-admin readers). Revoked daemon ntoks filtered out via
3622
+ // join on api_tokens.revoked_at.
3623
+ server.tool(
3624
+ "list_host_supervisors",
3625
+ "List host_supervisor daemon nodes in the caller's network (online status + runtimes_supported + allowed_secret_keys + telemetry; member-脱敏). RFC-026 §9.2.1.",
3626
+ {
3627
+ network_id: z.string().max(200).optional(),
3628
+ },
3629
+ async ({ network_id: clientNetId }) => {
3630
+ // SEC-1 — use resolveReadScope, NOT getNetworkId. getNetworkId is
3631
+ // for write-tool helpers and pairs with `canWrite`; READ tools that
3632
+ // bypass canWrite and trust getNetworkId leak across tenants
3633
+ // (PR2 v1 BLOCKER per 通信龙 audit — utok_ caller with
3634
+ // enforceNetworkId=null could pass any network_id and read daemon
3635
+ // names + allowed_secret_keys for tenants they're not a member of).
3636
+ // resolveReadScope checks network_members for the user + denies
3637
+ // on non-membership, mirroring REST resolveRestNetworkScope.
3638
+ const readScope = resolveReadScope(clientNetId);
3639
+ if (readScope.denied) {
3640
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
3641
+ }
3642
+ // Caller role for 脱敏 decision (admin/owner = full, others = masked).
3643
+ // Use the resolved scope's networkId (the verified one), not the
3644
+ // unchecked clientNetId.
3645
+ const scopedNetId = readScope.networkId ?? null;
3646
+ const callerRole = enforceUserId && scopedNetId
3647
+ ? getUserNetworkRole(enforceUserId, scopedNetId)
3648
+ : null;
3649
+ // Network-bound tokens get full telemetry within their bound network
3650
+ // (already gate-locked by enforceNetworkId path of resolveReadScope).
3651
+ const isPrivileged = callerRole === "admin" || callerRole === "owner" || (!enforceUserId);
3652
+
3653
+ // Pull candidate daemons. Active-token EXISTS subquery handles BOTH
3654
+ // revoked daemon ntoks (revoked_at set) AND DELETEd token rows
3655
+ // (the standard revokeToken path deletes the row, not flagging
3656
+ // revoked_at — PR2 v1 SHOULD-FIX per 通信龙 audit). EXISTS naturally
3657
+ // dedupes if a daemon ever had multiple tokens (e.g. rotation; nit ⚪).
3658
+ let sql = `
3659
+ SELECT
3660
+ n.node_id, n.alias, n.hostname, n.network_id,
3661
+ n.runtimes_supported, n.allowed_secret_keys,
3662
+ n.created_at, n.updated_at,
3663
+ s.last_seen_at AS session_last_seen,
3664
+ s.status AS session_status,
3665
+ s.cpu_cores AS session_cpu_cores,
3666
+ s.mem_total_gb AS session_mem_total_gb,
3667
+ s.ip AS session_ip,
3668
+ n.config_snapshot
3669
+ FROM nodes n
3670
+ LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
3671
+ WHERE EXISTS (
3672
+ SELECT 1 FROM api_tokens t
3673
+ WHERE t.network_id = n.network_id
3674
+ AND t.name = 'node:' || n.alias
3675
+ AND t.revoked_at IS NULL
3676
+ )
3677
+ `;
3678
+ const sqlParams: any[] = [];
3679
+ sql = addReadScope(sql, sqlParams, readScope, "n.network_id");
3680
+ sql += ` ORDER BY n.updated_at DESC`;
3681
+ const rows = db.all<Record<string, any>>(sql, ...sqlParams);
3682
+
3683
+ // Filter to role=host_supervisor (read from config_snapshot;
3684
+ // schema-promoted columns runtimes_supported/allowed_secret_keys
3685
+ // are pre-extracted but role isn't a first-class column).
3686
+ const nowMs = Date.now();
3687
+ // 心跳周期是 3 分钟(agent-node/src/cli.ts 的 report_status 定时器),
3688
+ // 窗口必须大于它,否则每次心跳后的 60s~180s 之间必然抖成 offline
3689
+ // (通信评审牛 #1279 独审④)。取 5min,与仓内既有 stale 口径一致。
3690
+ const ONLINE_MS = 5 * 60_000;
3691
+ const daemons = rows
3692
+ .map(r => {
3693
+ let snapRole: string | null = null;
3694
+ if (r.config_snapshot) {
3695
+ try {
3696
+ const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
3697
+ snapRole = typeof parsed?.role === "string" ? parsed.role : null;
3698
+ } catch { /* malformed snapshot — role stays null */ }
3699
+ }
3700
+ return { row: r, role: snapRole };
3701
+ })
3702
+ .filter(({ role }) => role === "host_supervisor")
3703
+ .map(({ row: r }) => {
3704
+ // online = sessions.last_seen_at within ONLINE_MS
3705
+ let online = false;
3706
+ let lastSeenAt: string | null = null;
3707
+ if (r.session_last_seen) {
3708
+ lastSeenAt = r.session_last_seen;
3709
+ const t = parseDbTimestampMs(r.session_last_seen);
3710
+ if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
3711
+ }
3712
+ // Parse self-declare arrays (default to [] for pre-PR2 daemons)
3713
+ let runtimes: string[] = [];
3714
+ let secrets: string[] = [];
3715
+ try {
3716
+ if (r.runtimes_supported) {
3717
+ const parsed = JSON.parse(r.runtimes_supported);
3718
+ runtimes = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
3719
+ }
3720
+ } catch { /* malformed — empty */ }
3721
+ try {
3722
+ if (r.allowed_secret_keys) {
3723
+ const parsed = JSON.parse(r.allowed_secret_keys);
3724
+ secrets = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
3725
+ }
3726
+ } catch { /* malformed — empty */ }
3727
+ // host_telemetry — member脱敏 drops IP/cpu/mem
3728
+ const telemetry: Record<string, unknown> = {
3729
+ alert_level: online ? "green" : "gray",
3730
+ };
3731
+ if (isPrivileged) {
3732
+ telemetry.cpu_cores = r.session_cpu_cores ?? null;
3733
+ telemetry.mem_gb = r.session_mem_total_gb ?? null;
3734
+ telemetry.ip_internal = r.session_ip ?? null;
3735
+ }
3736
+ return {
3737
+ daemon_node_id: r.node_id,
3738
+ alias: r.alias,
3739
+ hostname: r.hostname,
3740
+ online,
3741
+ last_seen_at: lastSeenAt,
3742
+ runtimes_supported: runtimes,
3743
+ allowed_secret_keys: secrets,
3744
+ host_telemetry: telemetry,
3745
+ };
3746
+ });
3747
+
3748
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, daemons, count: daemons.length }) }] };
3749
+ },
3750
+ );
3751
+
1821
3752
  // §2.5 step 1+2 — dashboard-facing tool. Validates spec, mint
1822
3753
  // child-ntok, stash env_blob in pendingEnvBlobs Map, write request
1823
3754
  // row (metadata only — no env_blob in SQL), pushEvent doorbell.
@@ -1829,8 +3760,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1829
3760
  node_spec: z.object({
1830
3761
  name: z.string().min(1).max(64),
1831
3762
  runtime: z.string().min(1).max(64),
1832
- model: z.string().min(1).max(100),
1833
- flags: z.record(z.unknown()).optional(),
3763
+ model: z.string().min(1).max(100).optional().nullable(),
3764
+ flags: z.record(z.string(), z.unknown()).optional(),
1834
3765
  env_refs: z.array(z.string().max(64)).optional(),
1835
3766
  channels: z.array(z.unknown()).optional(),
1836
3767
  }),
@@ -1859,22 +3790,115 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1859
3790
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
1860
3791
  }
1861
3792
 
3793
+ // #1353 Fix ① — daemon self-declared "I can't create nodes right now"
3794
+ // is now a dispatch-time gate, not just a stored fact. Before this,
3795
+ // daemon reported `daemon_capabilities.can_create_nodes=false` (via
3796
+ // #1371 + #1377), hub stored it in config_snapshot, and then no
3797
+ // consumer read it — dispatch still returned `{ok:true, request_id}`
3798
+ // and pushed an SSE doorbell to the broken daemon. That's the exact
3799
+ // "在线但静默失败" case the issue title reports.
3800
+ //
3801
+ // Vincent's own 2026-08-28 comment on #1353 authorizes this shape:
3802
+ // "daemon 侧确知不可用时,hub 应当**拒绝派发**并返回可操作的错误,
3803
+ // 而不是发一个注定失败的 doorbell".
3804
+ //
3805
+ // 🔴 Pre-#1371 compat: a daemon that has NOT reported
3806
+ // `can_create_nodes` at all leaves the field UNDEFINED in the
3807
+ // snapshot. Do NOT fail-closed on undefined — that would silently
3808
+ // start rejecting every daemon on the old (preview.10 through .55
3809
+ // roughly) shape. Only `=== false` gates; `=== true` and `undefined`
3810
+ // both pass through (new-guard-matches-existing-stance).
3811
+ try {
3812
+ const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
3813
+ const caps = snap?.daemon_capabilities;
3814
+ if (caps && caps.can_create_nodes === false) {
3815
+ const reason: string = (typeof caps.create_nodes_blocked_reason === "string"
3816
+ && caps.create_nodes_blocked_reason.length > 0)
3817
+ ? caps.create_nodes_blocked_reason
3818
+ : "anet_bin_unknown";
3819
+ // #1545 —— 光说 `blocked_reason` 不够:**那个判断是什么时候做出来的**
3820
+ // 决定了下一步完全不同的两件事。
3821
+ // 刚测的 blocked ⇒ 去那台机器按 reason 修
3822
+ // 三周前测的 blocked ⇒ 那台 daemon 是**开机算一次就永久缓存**的旧版本
3823
+ // (agent-node ≤ 2.5.0-preview.54),它可能早就好了 ——
3824
+ // 先重启/升级它,再谈修 pin
3825
+ // 而在拒绝这一刻,用户正好在决定"去修哪台机器"。
3826
+ //
3827
+ // 🔴 年龄未知时给的是**显式 null + 一个说明为什么的枚举**,不是省略这两个键。
3828
+ // 省略读起来像"没有这个问题",而调用方(常常是个 agent)会默认它是刚测的
3829
+ // —— 那是朝「更可信」方向撒谎,比不给更糟。
3830
+ const rawAge = caps.create_capability_observed_ms_ago;
3831
+ const reportedAgeOk = typeof rawAge === "number" && Number.isFinite(rawAge)
3832
+ && rawAge >= 0 && rawAge <= 365 * 24 * 60 * 60 * 1000;
3833
+ // 🔴 daemon 报的是「测完到**这份 report 发出**之间隔了多久」,不是「到现在」。
3834
+ // 两者差一个心跳间隔(3 分钟),而对一个已经失联的 daemon 差多少都可能。
3835
+ // 直接把它当成"多久以前测的"渲染,会**朝更新鲜的方向**低报 ——
3836
+ // 正是这一格要防的那个方向。所以在这里补上心跳那一段:
3837
+ // 绝对年龄 = (now − last_seen_at) + daemon 报的时长
3838
+ // hub 的钟出绝对时间,daemon 只提供时长(它自己的钟偏移污染不了这个数)。
3839
+ //
3840
+ // 这是冷路径(只有被拒时才走),多一次小查询不影响心跳路径。
3841
+ let heartbeatAgeMs: number | null = null;
3842
+ try {
3843
+ const sess = db.get<{ last_seen_at: string | null }>(
3844
+ "SELECT last_seen_at FROM sessions WHERE node_id = ?1 ORDER BY last_seen_at DESC LIMIT 1",
3845
+ daemon_node_id,
3846
+ );
3847
+ // #1650 — 这一列是 TEXT(`datetime('now')`),UTC 但不带时区标记。
3848
+ // Date.parse 会按**本机时区**解析,误差 = 主机偏移(生产 hub 在 UTC+8
3849
+ // 时这个诊断数字整整偏 8 小时)。走 parseHubTimestamp 显式按 UTC 解。
3850
+ const t = parseHubTimestamp(sess?.last_seen_at);
3851
+ if (t !== null) heartbeatAgeMs = Math.max(0, Date.now() - t);
3852
+ } catch { /* 取不到就是取不到 —— 下面如实说 unknown,不猜 0 */ }
3853
+
3854
+ // 🔴 三种取值,不能塌成两种:
3855
+ // known 两半都有,可以给绝对年龄
3856
+ // unknown_legacy_daemon 那台 daemon 根本没报这一格(旧版本)
3857
+ // unknown_no_heartbeat_time 它报了,但 hub 这边没有可用的心跳时间
3858
+ // ⇒ **补不出绝对年龄**,而不是"年龄是 0"
3859
+ const ageKnown = reportedAgeOk && heartbeatAgeMs !== null;
3860
+ return { content: [{ type: "text" as const, text: JSON.stringify({
3861
+ ok: false,
3862
+ error: "daemon_cannot_create_nodes",
3863
+ blocked_reason: reason,
3864
+ // 到**现在**为止,这个判断做出来有多久了(毫秒)。null = 补不出来。
3865
+ capability_observed_ms_ago: ageKnown ? heartbeatAgeMs! + (rawAge as number) : null,
3866
+ capability_age: ageKnown
3867
+ ? "known"
3868
+ : (reportedAgeOk ? "unknown_no_heartbeat_time" : "unknown_legacy_daemon"),
3869
+ // 🔴 这里**不复制那张 code → 修法命令 的表**。它只有一个作者,在
3870
+ // `@sleep2agi/agent-network` 的 daemon-capability-display —— 抄一份到 hub,
3871
+ // 两份就会各自漂移,而「hub 教的修法」和「CLI 教的修法」不一致时,
3872
+ // 没有任何东西会红。这里只把人指到那一份。
3873
+ // (同 `blocked_reason` 是 z.enum 而不是自由文本的立场:hub 出**代码**,
3874
+ // 渲染留给客户端。)
3875
+ remediation_hint: "run `anet daemon list` where that daemon is configured — it prints the exact, paste-able fix command for this blocked_reason",
3876
+ daemon_node_id,
3877
+ }) }] };
3878
+ }
3879
+ } catch { /* malformed snapshot: permissive, mirrors L3230 fallthrough */ }
3880
+
1862
3881
  // Read daemon's host_supervisor capability + allowlist from its
1863
- // last reported config_snapshot. P1 simplification: we accept
1864
- // (allowed_runtimes empty / allowed_secret_keys absent) as
1865
- // "accept any in the global enum"; P2 will tighten when daemon
1866
- // self-publishes its own allowlist via daemon_capabilities.
3882
+ // last reported config_snapshot. PR3 (#338) canonical path is
3883
+ // `daemon_capabilities.runtimes_supported` (RFC-026 §9.3).
3884
+ // Pre-PR3 daemons (preview.10 and earlier) place these at the
3885
+ // TOP level of the snapshot rather than nested — those reads
3886
+ // return undefined here and `daemonAllowedRuntimes` stays null
3887
+ // (permissive — no allowlist enforcement); they fall back to
3888
+ // §4.2.2 structural validation only, identical to pre-PR3
3889
+ // behavior. No regression on in-flight daemons.
1867
3890
  let daemonAllowList = new Set<string>();
1868
3891
  let daemonAllowedRuntimes: string[] | null = null;
1869
3892
  try {
1870
3893
  const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
1871
- if (snap?.daemon_capabilities?.allowed_secret_keys) {
1872
- daemonAllowList = new Set(snap.daemon_capabilities.allowed_secret_keys);
3894
+ const caps = snap?.daemon_capabilities;
3895
+ if (Array.isArray(caps?.allowed_secret_keys)) {
3896
+ daemonAllowList = new Set(caps.allowed_secret_keys);
1873
3897
  }
1874
- if (Array.isArray(snap?.daemon_capabilities?.allowed_runtimes)) {
1875
- daemonAllowedRuntimes = snap.daemon_capabilities.allowed_runtimes;
3898
+ if (Array.isArray(caps?.runtimes_supported)) {
3899
+ daemonAllowedRuntimes = caps.runtimes_supported;
1876
3900
  }
1877
- } catch { /* permissive P1 fallback */ }
3901
+ } catch { /* permissive fallback */ }
1878
3902
 
1879
3903
  // §4.2.2 — structural validation (catches name/runtime/model/
1880
3904
  // flag injection at the hub edge). Daemon repeats this; double
@@ -1980,7 +4004,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1980
4004
  VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
1981
4005
  [
1982
4006
  requestId, daemon_node_id, node_spec.name, networkIdForChild,
1983
- node_spec.runtime, node_spec.model, JSON.stringify(node_spec.flags || {}),
4007
+ node_spec.runtime, node_spec.model ?? null, JSON.stringify(node_spec.flags || {}),
1984
4008
  JSON.stringify(envKeys), childTokenId, Date.now(), callerTokenId || "unknown",
1985
4009
  ],
1986
4010
  );
@@ -1990,9 +4014,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1990
4014
  // SSE handler resolves the rest via MCP call.
1991
4015
  pushEvent(daemon.alias, { type: "create_node", request_id: requestId }, networkIdForChild);
1992
4016
 
1993
- // §4.5 audit — dispatch succeeded
4017
+ // §4.5 audit — request queued and doorbell attempted. pushEvent is
4018
+ // fire-and-forget, so actual SSE delivery is intentionally not claimed.
1994
4019
  auditCreateNode({
1995
- action: "create_node_dispatched",
4020
+ action: "create_node_dispatch_attempted",
1996
4021
  user_id: enforceUserId,
1997
4022
  network_id: networkIdForChild,
1998
4023
  target_id: requestId,
@@ -2000,7 +4025,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2000
4025
  daemon_node_id,
2001
4026
  child_name: node_spec.name,
2002
4027
  runtime: node_spec.runtime,
2003
- model: node_spec.model,
4028
+ model: node_spec.model ?? null,
2004
4029
  flag_keys: Object.keys(node_spec.flags || {}),
2005
4030
  env_keys: envKeys,
2006
4031
  },
@@ -2012,6 +4037,78 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2012
4037
  },
2013
4038
  );
2014
4039
 
4040
+ // #1362 — daemon-side compensation for missed SSE doorbells. pushEvent is
4041
+ // intentionally best-effort; after a daemon connects or reconnects, it pulls
4042
+ // any still-pending requests bound to its own daemon token.
4043
+ server.tool(
4044
+ "list_my_pending_create_requests",
4045
+ "Daemon lists pending create-node requests bound to itself for SSE reconnect compensation. RFC-026/#1362.",
4046
+ {},
4047
+ async () => {
4048
+ const callerDaemon = resolveCallerDaemonTokenBound();
4049
+ if (!callerDaemon.ok) {
4050
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4051
+ }
4052
+ const rows = db.all<{ request_id: string; child_name: string; created_at: number }>(
4053
+ `SELECT request_id, child_name, created_at
4054
+ FROM node_create_requests
4055
+ WHERE daemon_node_id = ?1
4056
+ AND network_id = ?2
4057
+ AND status = 'pending'
4058
+ ORDER BY created_at ASC`,
4059
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
4060
+ );
4061
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: rows.length, requests: rows }) }] };
4062
+ },
4063
+ );
4064
+
4065
+ // #1448 — stop/delete/start 门铃的 SSE 重连补偿,镜像 create 的
4066
+ // list_my_pending_create_requests(#1394)。pushEvent(push.ts) 是纯内存
4067
+ // fan-out:派发门铃那刻若 daemon 无 live SSE 订阅者(hub 重启/网络抖/boot
4068
+ // 窗口)就静默丢弃、不入队,于是 node_stop_requests/node_start_requests 的行
4069
+ // 永远停在 pending、节点卡死 stopping/deleting/starting。daemon 每次 SSE
4070
+ // connected 调本工具把漏掉的门铃拉回来重放(reconcilePendingLifecycleRequestsOnConnect)。
4071
+ //
4072
+ // 统一一个工具返回两张表的 pending 行(stop 表用 action 区分 stop/delete),
4073
+ // 省一次往返。鉴权同 create:token-bound daemon 身份(alias 不是安全边界),
4074
+ // 只返回绑定到本 daemon + 本网络的 pending 行。
4075
+ server.tool(
4076
+ "list_my_pending_lifecycle_requests",
4077
+ "Daemon lists pending stop/delete/start requests bound to itself for SSE reconnect compensation. Mirrors list_my_pending_create_requests (#1394/#1448).",
4078
+ {},
4079
+ async () => {
4080
+ const callerDaemon = resolveCallerDaemonTokenBound();
4081
+ if (!callerDaemon.ok) {
4082
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4083
+ }
4084
+ const stop_requests = db.all<{ request_id: string; action: string; child_node_id: string; child_alias: string; created_at: number }>(
4085
+ `SELECT request_id, action, child_node_id, child_alias, created_at
4086
+ FROM node_stop_requests
4087
+ WHERE daemon_node_id = ?1
4088
+ AND network_id = ?2
4089
+ AND status = 'pending'
4090
+ ORDER BY created_at ASC`,
4091
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
4092
+ );
4093
+ const start_requests = db.all<{ request_id: string; child_node_id: string; child_alias: string; created_at: number }>(
4094
+ `SELECT request_id, child_node_id, child_alias, created_at
4095
+ FROM node_start_requests
4096
+ WHERE daemon_node_id = ?1
4097
+ AND network_id = ?2
4098
+ AND status = 'pending'
4099
+ ORDER BY created_at ASC`,
4100
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
4101
+ );
4102
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4103
+ ok: true,
4104
+ stop_count: stop_requests.length,
4105
+ start_count: start_requests.length,
4106
+ stop_requests,
4107
+ start_requests,
4108
+ }) }] };
4109
+ },
4110
+ );
4111
+
2015
4112
  // §2.5 step 3 — daemon-facing pull. Returns full spec + env_blob +
2016
4113
  // child_ntok in one shot. Map evicted on take (one-shot consume).
2017
4114
  //
@@ -2064,7 +4161,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2064
4161
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "env_blob_unavailable" }) }] };
2065
4162
  }
2066
4163
  // Hydrate spec from row.
2067
- const specRow = db.get<{ child_name: string; runtime: string; model: string; flags_json: string }>(
4164
+ const specRow = db.get<{ child_name: string; runtime: string; model: string | null; flags_json: string }>(
2068
4165
  `SELECT child_name, runtime, model, flags_json FROM node_create_requests WHERE request_id = ?1`,
2069
4166
  request_id,
2070
4167
  );
@@ -2104,14 +4201,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2104
4201
  // record fork-side info.
2105
4202
  server.tool(
2106
4203
  "ack_create_request",
2107
- "Daemon acks a create-node request (called after fork). status='started' or 'failed'. RFC-026.",
4204
+ "Daemon acks a create-node request (called after fork). status='started' | 'failed' | 'rejected' | 'runtime_capability_check_failed'. RFC-026 §9.3 D2.",
2108
4205
  {
2109
4206
  request_id: z.string().min(1).max(200),
2110
- status: z.enum(["started", "failed", "rejected"]),
4207
+ // RFC-026 §9.3 D2 — runtime_capability_check_failed signals the
4208
+ // daemon spawned the child OK but it died within FAIL_FAST_MS
4209
+ // (5s in agent-node v2.5.0-preview.11+), indicating a
4210
+ // declaration↔reality gap on this daemon's runtimes_supported.
4211
+ // Treated terminal like 'failed' but fires a distinct audit_log
4212
+ // action so dashboards can highlight "lying daemons" separately
4213
+ // from generic spawn failures.
4214
+ status: z.enum(["started", "failed", "rejected", "runtime_capability_check_failed"]),
2111
4215
  error: z.string().max(1000).optional(),
2112
4216
  child_pid: z.number().int().optional(),
4217
+ runtime: z.string().max(64).optional(), // populated by daemon when status=runtime_capability_check_failed
2113
4218
  },
2114
- async ({ request_id, status, error: ackError, child_pid: _pid }) => {
4219
+ async ({ request_id, status, error: ackError, child_pid: _pid, runtime: ackRuntime }) => {
2115
4220
  const callerDaemon = resolveCallerDaemonTokenBound();
2116
4221
  if (!callerDaemon.ok) {
2117
4222
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
@@ -2137,7 +4242,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2137
4242
  );
2138
4243
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "awaiting_register" }) }] };
2139
4244
  }
2140
- // failed / rejected — revoke child-ntok + mark request terminal
4245
+ // failed / rejected / runtime_capability_check_failed — revoke
4246
+ // child-ntok + mark request terminal.
2141
4247
  if (row.child_token_id) {
2142
4248
  db.run(`UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = ?1 AND revoked_at IS NULL`, [row.child_token_id]);
2143
4249
  }
@@ -2145,10 +4251,817 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2145
4251
  `UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3 WHERE request_id = ?4 AND status IN ('pending', 'delivered')`,
2146
4252
  [status, ackError || null, ackedAt, request_id],
2147
4253
  );
4254
+ // RFC-026 §9.3 D2 — surface declaration↔reality gap on a
4255
+ // distinct audit_log action so dashboards / operators can spot
4256
+ // chronically-lying daemons separate from generic spawn-failed.
4257
+ if (status === "runtime_capability_check_failed") {
4258
+ auditCreateNode({
4259
+ action: "daemon_capability_lied",
4260
+ user_id: null,
4261
+ network_id: row.network_id,
4262
+ target_id: request_id,
4263
+ detail: {
4264
+ daemon_node_id: row.daemon_node_id,
4265
+ runtime: ackRuntime || null,
4266
+ error: ackError ? ackError.slice(0, 500) : null,
4267
+ acked_at: ackedAt,
4268
+ },
4269
+ });
4270
+ }
4271
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
4272
+ },
4273
+ );
4274
+
4275
+ // ─── RFC-027 §2 — stop/delete node lifecycle ───────────────────────
4276
+ //
4277
+ // Two user-facing MCP tools (stop_node, delete_node) + two daemon-
4278
+ // facing tools (get_stop_request, ack_stop_request). State machine
4279
+ // per §2.3 — nodes.lifecycle_state ∈ {active, stopping, stopped,
4280
+ // deleting}. row-gone implies deleted. Security per §4:
4281
+ // §4.1 SEC-1: trust-root SQL join, not getNetworkId
4282
+ // §4.2 D6: delete_node refuses target.role==host_supervisor
4283
+ // §4.3 D4: in-flight inbox default-refuse + force+audit
4284
+ // §4.4 D7: daemon writes backup chmod 700, sweeper真删
4285
+ // §4.5 D8: audit_log in same SQLite tx as state UPDATE
4286
+ //
4287
+ // The dispatcher logic shared by stop_node + delete_node lives in
4288
+ // dispatchStopOrDelete; the two tool handlers thin-wrap it with
4289
+ // their respective action discriminator.
4290
+ type DispatchAction = "stop" | "delete";
4291
+ type DispatchArgs = {
4292
+ action: DispatchAction;
4293
+ child_node_id: string;
4294
+ daemon_node_id: string;
4295
+ force: boolean;
4296
+ grace_seconds: number;
4297
+ delete_config: boolean;
4298
+ confirm_alias?: string;
4299
+ };
4300
+ // RFC-027 PR2 prereq — auto-resolve daemon_node_id from child_node_id.
4301
+ // The dashboard (and most callers of stop_node / delete_node) shouldn't
4302
+ // need to track the daemon→child mapping themselves; the hub has it on
4303
+ // node_create_requests at child creation time. Returns null when no
4304
+ // creation record exists (orphan node row OR pre-RFC-026 node) — caller
4305
+ // must then pass daemon_node_id explicitly. node_id derivation:
4306
+ // `node_${request_id.replace(/^cr_/,"")}` (see create-node-daemon.ts
4307
+ // and PR1 BLOCKER-1 fix), so we reverse it: `cr_${node_id.slice(5)}`.
4308
+ const resolveDaemonForChild = (child_node_id: string): string | null => {
4309
+ if (!child_node_id.startsWith("node_")) return null;
4310
+ const requestId = `cr_${child_node_id.slice(5)}`;
4311
+ const row = db.get<{ daemon_node_id: string }>(
4312
+ `SELECT daemon_node_id FROM node_create_requests WHERE request_id = ?1`,
4313
+ requestId,
4314
+ );
4315
+ return row?.daemon_node_id ?? null;
4316
+ };
4317
+
4318
+ /**
4319
+ * 为什么这里要分两种「解析不到 daemon」(#196 / app 仓)。
4320
+ *
4321
+ * `resolveDaemonForChild` 对两件完全不同的事都返回 `null`:
4322
+ * ① id 不以 `node_` 开头 —— 这个节点**根本不是 daemon 创建的**,
4323
+ * 是有人在某台机器上直接 `anet node start` 起来的。hub 上没有任何
4324
+ * daemon 可以代它执行停止,**这条路径对它在概念上就不成立**。
4325
+ * ② id 以 `node_` 开头,但 `node_create_requests` 里查不到那一行 ——
4326
+ * 它可能确实是 daemon 建的,只是记录缺失,这时「显式传 daemon_node_id」
4327
+ * 是一条真的走得通的路。
4328
+ *
4329
+ * 🔴 原先两种情况打印同一句 `pass daemon_node_id explicitly`。对 ① 来说
4330
+ * **这句建议的前提是假的** —— 没有 daemon 可传,用户按它做只会走进死路。
4331
+ * 2026-08-28 对生产 hub 实测:218 个节点里 **207 个**是 ① 这种。
4332
+ *
4333
+ * 信息在源头就存在(那个 `startsWith("node_")` 判断),只是被丢在了返回值里。
4334
+ */
4335
+ const explainUnresolvableDaemon = (child_node_id: string) =>
4336
+ child_node_id.startsWith("node_")
4337
+ ? {
4338
+ ok: false, error: "daemon_not_resolvable",
4339
+ message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
4340
+ }
4341
+ : {
4342
+ ok: false, error: "not_daemon_managed",
4343
+ message:
4344
+ `node ${child_node_id} was not created by a daemon (it was started by hand with ` +
4345
+ `\`anet node start\` on some machine), so no daemon on the Hub can stop it. ` +
4346
+ `Run \`anet node stop <alias>\` on that machine instead. ` +
4347
+ `Daemon-created children have ids beginning with \`node_\`; this one does not.`,
4348
+ };
4349
+
4350
+ const dispatchStopOrDelete = (args: DispatchArgs, clientNetId?: string | null) => {
4351
+ // §4.1 — SEC-1 trust-root join: resolve target node row WITHIN the
4352
+ // caller's scope. resolveReadScope returns 'denied' if the caller
4353
+ // doesn't belong to clientNetId; for the row-existence test we
4354
+ // re-join with member's networks so an attacker can't probe by
4355
+ // node_id from a network they don't belong to.
4356
+ const scope = resolveReadScope(clientNetId);
4357
+ if (scope.denied) {
4358
+ return { ok: false, error: "forbidden_cross_tenant", message: scope.denied };
4359
+ }
4360
+ const nodeQ: any[] = [args.child_node_id];
4361
+ let nodeSql = `SELECT node_id, alias, network_id, lifecycle_state, config_snapshot, runtimes_supported
4362
+ FROM nodes WHERE node_id = ?1`;
4363
+ nodeSql = addReadScope(nodeSql, nodeQ, scope);
4364
+ const node = db.get<{
4365
+ node_id: string;
4366
+ alias: string;
4367
+ network_id: string;
4368
+ lifecycle_state: string | null;
4369
+ config_snapshot: string | null;
4370
+ }>(nodeSql, ...nodeQ);
4371
+ if (!node) return { ok: false, error: "forbidden_cross_tenant", message: "node not found in your networks" };
4372
+
4373
+ // Caller must hold a write-capable role on the resolved network
4374
+ // (admin/owner/member; viewer denied). canWrite walks getUserNetworkRole.
4375
+ if (!canWrite(node.network_id)) {
4376
+ return { ok: false, error: "permission_denied", message: "viewer role cannot stop/delete nodes" };
4377
+ }
4378
+
4379
+ // §4.2 D6 — delete refuses targets whose role is host_supervisor.
4380
+ // Stop is allowed against any node per RFC table (host_supervisor's
4381
+ // stop is functionally a no-op anyway: the daemon's children_map
4382
+ // only tracks its child nodes, never the daemon itself).
4383
+ //
4384
+ // PR1 SF-4 (#345 review) — fail-CLOSED on role read. The first cut
4385
+ // parsed config_snapshot and treated parse-fail / missing-field as
4386
+ // role=null which slipped through the gate. A daemon row with a
4387
+ // corrupt snapshot would then be deletable via delete_node. Fix:
4388
+ // - read role via two independent paths (snapshot.role + the
4389
+ // indexed `runtimes_supported` column whose presence ≈ daemon)
4390
+ // - if EITHER path indicates host_supervisor → refuse
4391
+ // - if snapshot parsed but role missing AND the node has any
4392
+ // daemon-shape evidence (non-null runtimes_supported column,
4393
+ // present in any node_create_requests as daemon_node_id) →
4394
+ // refuse defensively. The defense-in-depth daemon-side
4395
+ // children_map miss is the second layer; the hub gate must
4396
+ // itself fail-closed when role is ambiguous.
4397
+ if (args.action === "delete") {
4398
+ let role: string | null = null;
4399
+ let snapshotParseFailed = false;
4400
+ try {
4401
+ const snap = node.config_snapshot ? JSON.parse(node.config_snapshot) : null;
4402
+ role = typeof snap?.role === "string" ? snap.role : null;
4403
+ } catch { snapshotParseFailed = true; }
4404
+ const ambiguous = snapshotParseFailed || (role == null);
4405
+ // Independent corroborating check: nodes flagged as daemons by
4406
+ // create_node dispatchers will show up as daemon_node_id in
4407
+ // node_create_requests. Costs one indexed query.
4408
+ let looksLikeDaemon = false;
4409
+ if (ambiguous) {
4410
+ const m = db.get<{ n: number }>(
4411
+ `SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1`,
4412
+ node.node_id,
4413
+ );
4414
+ if ((m?.n ?? 0) > 0) looksLikeDaemon = true;
4415
+ }
4416
+ if (role === "host_supervisor" || (ambiguous && looksLikeDaemon)) {
4417
+ return {
4418
+ ok: false, error: "cannot_delete_daemon_via_delete_node",
4419
+ message: snapshotParseFailed
4420
+ ? "node config_snapshot unreadable; node is referenced as a daemon — refuse delete defensively (use delete_daemon path)"
4421
+ : "host_supervisor daemons must be removed via the dedicated delete_daemon path (RFC-027.5)",
4422
+ };
4423
+ }
4424
+ }
4425
+
4426
+ // delete_node confirm_alias gate: dashboard's二次确认 input must
4427
+ // match the actual alias byte-for-byte. Hub rejects mismatched
4428
+ // input even if dashboard UI claims it's disabled.
4429
+ if (args.action === "delete") {
4430
+ if (typeof args.confirm_alias !== "string" || args.confirm_alias !== node.alias) {
4431
+ return { ok: false, error: "confirm_alias_mismatch", message: "confirm_alias must equal the node's alias" };
4432
+ }
4433
+ }
4434
+
4435
+ // Daemon must exist + be in the same network. We do NOT enforce
4436
+ // daemon.role==host_supervisor here: a child whose creator daemon
4437
+ // got demoted should still be stoppable. The daemon will refuse
4438
+ // with noop_not_my_child if children_map doesn't know it.
4439
+ //
4440
+ // PR1.2 e2e catch (BLOCKER): pushEvent keys SSE by clientKey ≈
4441
+ // `${networkId}:${sessionName}`, and agent-node registers its SSE
4442
+ // connection under its ALIAS (not its node_id). The create_node
4443
+ // doorbell at line ~2197 correctly pushes via `daemon.alias`;
4444
+ // stop_node was pushing via `daemon_node_id` so every doorbell
4445
+ // missed every SSE client → daemon never woke up → request_id
4446
+ // stayed 'pending' forever → child never reaped. The 24 unit tests
4447
+ // in stop-delete-node.test.ts all mock pushEvent and inject the
4448
+ // post-doorbell handler call directly, so single-unit coverage
4449
+ // couldn't surface this — exactly the failure class 通信龙 said
4450
+ // the docker e2e gate exists to catch (BLOCKER-1 同源). Load the
4451
+ // alias along with node_id + network_id and dispatch to the alias.
4452
+ const daemon = db.get<{ node_id: string; alias: string; network_id: string }>(
4453
+ `SELECT node_id, alias, network_id FROM nodes WHERE node_id = ?1`, args.daemon_node_id,
4454
+ );
4455
+ if (!daemon) return { ok: false, error: "daemon_not_found" };
4456
+ if (daemon.network_id !== node.network_id) {
4457
+ // Cross-tenant child↔daemon mismatch shouldn't be possible in
4458
+ // normal flow but is a SEC-1 hardening — refuse loud.
4459
+ return { ok: false, error: "daemon_cross_tenant", message: "daemon and child are in different networks" };
4460
+ }
4461
+
4462
+ // #1448 finding-4 — daemon 必须是该 child 的**权威创建者**,镜像 start_node 的
4463
+ // daemon_child_mismatch。此前 stop/delete 接受调用方传入的 daemon_node_id,只校验
4464
+ // 它存在 + 同网,不验它真的创建了这个 child。同网调用方传错 daemon_node_id → 门铃
4465
+ // 路由到错 daemon。🔴 f3(#1453) 合入后更糟:错 daemon 无该 child 的 map entry → 走
4466
+ // 收敛分支 sweep(错机器 pgrep 空) + ack stopped → hub 假收敛 stopped、child 仍在
4467
+ // 正确机器上跑(旧 noop 至少留下可见的卡死态)。网络 scope 只挡跨租户,网内是 footgun。
4468
+ //
4469
+ // 用「确定性不匹配」判据:authority 已知(child 有 create 记录)且不等 → 拒。不对
4470
+ // authority==null(create 记录被裁剪的旧 child)加拒,避免给它们的 stop/delete 引入
4471
+ // 新失败面——handler 的 daemon_node_id??resolveDaemonForChild 已兜住无法解析的情形。
4472
+ const authoritativeDaemonId = resolveDaemonForChild(args.child_node_id);
4473
+ if (authoritativeDaemonId && authoritativeDaemonId !== args.daemon_node_id) {
4474
+ return { ok: false, error: "daemon_child_mismatch", message: "daemon_node_id is not this child's authoritative creator daemon" };
4475
+ }
4476
+
4477
+ // State machine gate.
4478
+ const state = node.lifecycle_state ?? "active";
4479
+ if (args.action === "stop" && state !== "active") {
4480
+ return { ok: false, error: state === "stopping" ? "node_already_stopping" : "node_not_active", current_state: state };
4481
+ }
4482
+ if (args.action === "delete" && state === "deleting") {
4483
+ // #1286 —— stale-deleting 重派出口。
4484
+ //
4485
+ // 在 .40 埋点修复之前,daemon 丢失 ack 会让行**永远**停在 deleting:
4486
+ // 重发 delete 被这里挡住,force=true 也一样 —— 状态机没有出口,
4487
+ // 2026-08-28 实测两行卡死(acc-…-3373 / probe-tl2),API 层面无解。
4488
+ //
4489
+ // 出口的三个条件,缺一不放行:
4490
+ // ① 调用方显式 force=true(不改默认行为 —— 正常并发的重复 delete 仍被拒)
4491
+ // ② 该 child 最近一条 delete 请求不是终态(pending/dispatched)
4492
+ // —— 终态说明 daemon 刚 ack 过,行马上会转移,不该重派
4493
+ // ③ 那条请求已经晾了超过 STALE_DELETING_MS —— 🔴 不是"刚派发就能 force":
4494
+ // 给正常的 doorbell→ack 往返留时间,否则 force 变成竞态放大器。
4495
+ const STALE_DELETING_MS = 5 * 60_000;
4496
+ const last = db.get<{ request_id: string; status: string; created_at: number }>(
4497
+ `SELECT request_id, status, created_at FROM node_stop_requests
4498
+ WHERE child_node_id = ?1 AND action = 'delete'
4499
+ ORDER BY created_at DESC LIMIT 1`, node.node_id,
4500
+ );
4501
+ const lastIsStale = !!last
4502
+ && (last.status === "pending" || last.status === "dispatched")
4503
+ && (Date.now() - last.created_at) > STALE_DELETING_MS;
4504
+ if (!(args.force === true && (lastIsStale || !last))) {
4505
+ return {
4506
+ ok: false, error: "node_already_deleting", current_state: state,
4507
+ // 🔴 告诉调用方出口在哪,而不是让他反复撞同一面墙。
4508
+ ...(last ? { last_request_id: last.request_id, last_request_status: last.status } : {}),
4509
+ hint: last && (last.status === "pending" || last.status === "dispatched")
4510
+ ? `再等一下;若超过 ${STALE_DELETING_MS / 60000} 分钟仍未收敛,带 force=true 重试可重新派发`
4511
+ : "上一条请求已终态,行应即将转移;稍后重查",
4512
+ };
4513
+ }
4514
+ // 放行:走正常派发路径,生成新 request_id。旧请求行保留(审计线索),
4515
+ // daemon 侧对未知 request 的 get_stop_request 会拿到 request_not_found,无副作用。
4516
+ }
4517
+ if (args.action === "delete" && state === "stopping") {
4518
+ // Can't start delete while a stop is mid-flight — race-prone.
4519
+ return { ok: false, error: "node_stopping_in_progress", current_state: state };
4520
+ }
4521
+
4522
+ // §4.3 D4 — in-flight inbox check. Count unacked tasks routed to
4523
+ // this node's alias (inbox routing uses session_name == alias).
4524
+ // Default refuse with the count surfaced; force=true overrides
4525
+ // and triggers the forced_stop_with_in_flight audit row.
4526
+ // SF-5 (#345 review): legacy inbox rows may have network_id=NULL
4527
+ // and would silently miss a network_id=?2 strict equality match.
4528
+ // Use COALESCE so a NULL inbox row is counted against the same
4529
+ // network as the target node (which is the only safe default
4530
+ // — pre-multi-network rows existed in single-network mode).
4531
+ const inFlightRow = db.get<{ n: number }>(
4532
+ `SELECT COUNT(*) AS n FROM inbox WHERE session_name = ?1 AND acked = 0
4533
+ AND COALESCE(network_id, ?2) = ?2`,
4534
+ node.alias, node.network_id,
4535
+ );
4536
+ const inFlight = inFlightRow?.n ?? 0;
4537
+ if (inFlight > 0 && !args.force) {
4538
+ return { ok: false, error: "node_busy_in_flight", in_flight_count: inFlight, hint: "set force=true to override (audit logged)" };
4539
+ }
4540
+
4541
+ // ── all gates passed; create request + transition state + push doorbell, transactionally ──
4542
+ const requestId = generateId("sr");
4543
+ const now = Date.now();
4544
+ const newState = args.action === "stop" ? "stopping" : "deleting";
4545
+
4546
+ // #1448 finding-5 — 记下该 child **当前活着的** ntok 的 token_id,让 ack 精确按
4547
+ // token_id 撤销,而非按 name='node:<alias>' 广撤。后者会在「同 alias 节点在这次
4548
+ // delete 未收敛的窗口内被重建(拿到同名新 token)」时,把新 token 一并误撤。这里
4549
+ // 在派发时刻定住身份;若此刻没有活 token(异常),存 null,ack 退回按 name 撤(旧语义)。
4550
+ const childTokenRow = db.get<{ token_id: string }>(
4551
+ `SELECT token_id FROM api_tokens WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
4552
+ node.network_id, `node:${node.alias}`,
4553
+ );
4554
+ const childTokenId = childTokenRow?.token_id ?? null;
4555
+
4556
+ // §4.5 D8 — lifecycle UPDATE + audit INSERTs + request INSERT atomic.
4557
+ // PR1.1: use db.transaction() (SQLiteAdapter wraps better-sqlite3's
4558
+ // native transaction; PgAdapter ships a real BEGIN/COMMIT/ROLLBACK
4559
+ // shim). Lets us drop the open-coded BEGIN/COMMIT + makes the
4560
+ // SF-2 failure-injection test (mock db.run throw inside callback)
4561
+ // reliable across both backends.
4562
+ try {
4563
+ db.transaction(() => {
4564
+ db.run(
4565
+ `INSERT INTO node_stop_requests
4566
+ (request_id, network_id, daemon_node_id, child_node_id, child_alias, child_token_id, action,
4567
+ delete_config, grace_seconds, force, in_flight_at_dispatch, created_by_token,
4568
+ status, created_at)
4569
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'pending', ?13)`,
4570
+ [
4571
+ requestId, node.network_id, args.daemon_node_id, node.node_id, node.alias, childTokenId, args.action,
4572
+ args.delete_config ? 1 : 0, args.grace_seconds, args.force ? 1 : 0, inFlight,
4573
+ callerTokenId || "unknown", now,
4574
+ ],
4575
+ );
4576
+ db.run(
4577
+ `UPDATE nodes SET lifecycle_state = ?1 WHERE node_id = ?2`,
4578
+ [newState, node.node_id],
4579
+ );
4580
+ // §4.5 D8 — audit dispatch action. Uses the STRICT variant so a
4581
+ // failed audit INSERT propagates and triggers ROLLBACK (PR1 SF-2
4582
+ // review catch — auditCreateNode's swallow-and-warn would have
4583
+ // committed the lifecycle UPDATE without an audit row, leaving
4584
+ // exactly the "deleted but no audit" window §4.5 closes).
4585
+ auditCreateNodeStrict({
4586
+ action: args.action === "stop" ? "stop_node_dispatched" : "delete_node_dispatched",
4587
+ user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
4588
+ detail: {
4589
+ child_node_id: node.node_id, child_alias: node.alias,
4590
+ daemon_node_id: args.daemon_node_id, action: args.action,
4591
+ force: args.force, delete_config: args.delete_config,
4592
+ grace_seconds: args.grace_seconds, in_flight_at_dispatch: inFlight,
4593
+ lifecycle_state_before: state, lifecycle_state_after: newState,
4594
+ ts_request: now,
4595
+ },
4596
+ });
4597
+ if (args.force && inFlight > 0) {
4598
+ auditCreateNodeStrict({
4599
+ action: "forced_stop_with_in_flight",
4600
+ user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
4601
+ detail: { in_flight_count: inFlight, child_alias: node.alias },
4602
+ });
4603
+ }
4604
+ }); // end db.transaction
4605
+ } catch (e: any) {
4606
+ return { ok: false, error: "dispatch_tx_failed", message: e?.message || String(e) };
4607
+ }
4608
+
4609
+ // SSE doorbell — daemon will pull via get_stop_request.
4610
+ // PR1.2 e2e fix: route by daemon.alias (see SELECT above for the
4611
+ // BLOCKER explanation). Mirrors create_node's pushEvent target at
4612
+ // line ~2197.
4613
+ pushEvent(daemon.alias, { type: "stop_node", request_id: requestId }, node.network_id);
4614
+ return {
4615
+ ok: true, request_id: requestId, action: args.action,
4616
+ lifecycle_state: newState, in_flight_at_dispatch: inFlight,
4617
+ };
4618
+ };
4619
+
4620
+ server.tool(
4621
+ "stop_node",
4622
+ "Stop the agent-node child process; keep config dir intact. Reversible via restart_node. RFC-027 §2.2. daemon_node_id is auto-resolved from child_node_id when omitted (looked up in node_create_requests).",
4623
+ {
4624
+ ...NODE_ID_ALIAS_FIELDS,
4625
+ // PR2 prereq: dashboard rarely knows daemon_node_id directly.
4626
+ // When omitted, hub resolves from the original creation record
4627
+ // (node_create_requests.daemon_node_id keyed by this child).
4628
+ daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
4629
+ force: z.boolean().optional().default(false),
4630
+ grace_seconds: z.number().int().min(5).max(60).optional().default(10),
4631
+ network_id: z.string().max(200).optional(),
4632
+ },
4633
+ async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, force, grace_seconds, network_id: clientNetId }) => {
4634
+ const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
4635
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
4636
+ const child_node_id = idArg.node_id;
4637
+ const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
4638
+ if (!resolved) {
4639
+ return { content: [{ type: "text" as const,
4640
+ text: JSON.stringify(explainUnresolvableDaemon(child_node_id)) }] };
4641
+ }
4642
+ const r = dispatchStopOrDelete(
4643
+ { action: "stop", child_node_id, daemon_node_id: resolved, force: force ?? false,
4644
+ grace_seconds: grace_seconds ?? 10, delete_config: false },
4645
+ clientNetId,
4646
+ );
4647
+ return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
4648
+ },
4649
+ );
4650
+
4651
+ server.tool(
4652
+ "delete_node",
4653
+ "Stop child + revoke ntok + delete hub row + (default) backup config to ~/.anet/deleted/<ts>-<alias>/ for 30d. confirm_alias must equal the node's alias. daemon_node_id is auto-resolved when omitted. RFC-027 §2.2.",
4654
+ {
4655
+ ...NODE_ID_ALIAS_FIELDS,
4656
+ daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
4657
+ confirm_alias: z.string().min(1).max(200),
4658
+ force: z.boolean().optional().default(false),
4659
+ grace_seconds: z.number().int().min(5).max(60).optional().default(10),
4660
+ delete_config: z.boolean().optional().default(true),
4661
+ network_id: z.string().max(200).optional(),
4662
+ },
4663
+ async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, confirm_alias, force, grace_seconds, delete_config, network_id: clientNetId }) => {
4664
+ const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
4665
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
4666
+ const child_node_id = idArg.node_id;
4667
+ const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
4668
+ if (!resolved) {
4669
+ return { content: [{ type: "text" as const,
4670
+ text: JSON.stringify(explainUnresolvableDaemon(child_node_id)) }] };
4671
+ }
4672
+ const r = dispatchStopOrDelete(
4673
+ { action: "delete", child_node_id, daemon_node_id: resolved, force: force ?? false,
4674
+ grace_seconds: grace_seconds ?? 10, delete_config: delete_config ?? true, confirm_alias },
4675
+ clientNetId,
4676
+ );
4677
+ return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
4678
+ },
4679
+ );
4680
+
4681
+ server.tool(
4682
+ "get_stop_request",
4683
+ "Daemon pulls a pending stop/delete request (called when SSE stop_node doorbell arrives). RFC-027 §2.4.",
4684
+ {
4685
+ request_id: z.string().min(1).max(200),
4686
+ },
4687
+ async ({ request_id }) => {
4688
+ const callerDaemon = resolveCallerDaemonTokenBound();
4689
+ if (!callerDaemon.ok) {
4690
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4691
+ }
4692
+ const row = db.get<{
4693
+ daemon_node_id: string; status: string; network_id: string;
4694
+ child_node_id: string; child_alias: string; action: string;
4695
+ delete_config: number; grace_seconds: number; force: number;
4696
+ }>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, action,
4697
+ delete_config, grace_seconds, force
4698
+ FROM node_stop_requests WHERE request_id = ?1`, request_id);
4699
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
4700
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
4701
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
4702
+ }
4703
+ if (row.network_id !== callerDaemon.networkId) {
4704
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
4705
+ }
4706
+ // Stamp delivered_at on first pull (idempotent — only if still pending).
4707
+ db.run(
4708
+ `UPDATE node_stop_requests SET status = 'delivered', delivered_at = ?1
4709
+ WHERE request_id = ?2 AND status = 'pending'`,
4710
+ [Date.now(), request_id],
4711
+ );
4712
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4713
+ ok: true,
4714
+ request_id,
4715
+ child_node_id: row.child_node_id,
4716
+ child_alias: row.child_alias,
4717
+ action: row.action,
4718
+ delete_config: row.delete_config === 1,
4719
+ grace_seconds: row.grace_seconds,
4720
+ force: row.force === 1,
4721
+ }) }] };
4722
+ },
4723
+ );
4724
+
4725
+ server.tool(
4726
+ "ack_stop_request",
4727
+ "Daemon reports stop/delete completion (or per-status failure). On 'stopped' status finalizes the lifecycle: stop→stopped + keep config; delete→DB row gone + revoke ntok. RFC-027 §2.3 + §4.5.",
4728
+ {
4729
+ request_id: z.string().min(1).max(200),
4730
+ status: z.enum(["stopped", "stop_failed", "noop_not_my_child"]),
4731
+ exit_signal: z.string().max(16).optional(),
4732
+ backup_path: z.string().max(500).optional(),
4733
+ error: z.string().max(1000).optional(),
4734
+ },
4735
+ async ({ request_id, status, exit_signal, backup_path, error: ackError }) => {
4736
+ // #1358 —— 🔴 在此之前,这个工具的**每一条出口都是静默的**:到达不记、
4737
+ // 三条拒绝不记、成功收尾也不记。而隔壁 create-node 的收尾**是记的**
4738
+ // (`✓ create-node finalize`)。这种不对称最坑人:读日志的人看到 create 有、
4739
+ // delete 没有,第一反应是「delete 没发生」—— 但两者的日志覆盖面本来就不同,
4740
+ // 那条推论没有依据。排查 #1286 时我自己就差点这样读。
4741
+ console.log(`[commhub] ← ack_stop_request request=${request_id} status=${status}${exit_signal ? ` exit_signal=${exit_signal}` : ""}`);
4742
+ const callerDaemon = resolveCallerDaemonTokenBound();
4743
+ if (!callerDaemon.ok) {
4744
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4745
+ }
4746
+ const row = db.get<{
4747
+ daemon_node_id: string; status: string; network_id: string;
4748
+ child_node_id: string; child_alias: string; child_token_id: string | null; action: string;
4749
+ in_flight_at_dispatch: number; force: number;
4750
+ }>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, child_token_id, action,
4751
+ in_flight_at_dispatch, force
4752
+ FROM node_stop_requests WHERE request_id = ?1`, request_id);
4753
+ if (!row) {
4754
+ console.warn(`[commhub] ✕ ack_stop_request rejected: request_not_found request=${request_id}`);
4755
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
4756
+ }
4757
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
4758
+ console.warn(`[commhub] ✕ ack_stop_request rejected: not_your_request request=${request_id} row_daemon=${row.daemon_node_id} caller=${callerDaemon.daemonNodeId}`);
4759
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
4760
+ }
4761
+ if (row.network_id !== callerDaemon.networkId) {
4762
+ console.warn(`[commhub] ✕ ack_stop_request rejected: cross_network_request request=${request_id} row_net=${row.network_id} caller_net=${callerDaemon.networkId}`);
4763
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
4764
+ }
4765
+ const now = Date.now();
4766
+
4767
+ // §4.5 D8 — lifecycle UPDATE + request UPDATE + audit INSERT
4768
+ // (+ DELETE for delete action) atomic via db.transaction().
4769
+ // PR1.1: replaces hand-rolled BEGIN..COMMIT so PG adapter and
4770
+ // SQLite both get correct rollback semantics from one code path.
4771
+ try {
4772
+ db.transaction(() => {
4773
+ db.run(
4774
+ `UPDATE node_stop_requests SET status = ?1, error = ?2, exit_signal = ?3,
4775
+ backup_path = ?4, acked_at = ?5
4776
+ WHERE request_id = ?6`,
4777
+ [status, ackError || null, exit_signal || null, backup_path || null, now, request_id],
4778
+ );
4779
+ if (status === "stopped" && row.action === "stop") {
4780
+ db.run(`UPDATE nodes SET lifecycle_state = 'stopped' WHERE node_id = ?1`, [row.child_node_id]);
4781
+ auditCreateNodeStrict({
4782
+ action: "stop_node_completed",
4783
+ user_id: null, network_id: row.network_id, target_id: request_id,
4784
+ detail: {
4785
+ child_node_id: row.child_node_id, child_alias: row.child_alias,
4786
+ exit_signal: exit_signal || null, ts_daemon_ack: now,
4787
+ lifecycle_state_after: "stopped",
4788
+ },
4789
+ });
4790
+ } else if (status === "stopped" && row.action === "delete") {
4791
+ // Revoke the child's ntok, then DELETE the nodes row.
4792
+ // #1448 finding-5 — 精确按派发时定住的 child_token_id 撤(镜像
4793
+ // ack_create_request 的 token_id 精撤)。旧行(升级前派发,child_token_id
4794
+ // 为 NULL)fallback 回按 name='node:<alias>' 撤——旧语义,不引入回归。
4795
+ // 按 token_id 撤避免了「同 alias 在本次 delete 窗口内被重建、拿到同名新
4796
+ // token」时把新 token 一并误撤。
4797
+ if (row.child_token_id) {
4798
+ db.run(
4799
+ `UPDATE api_tokens SET revoked_at = datetime('now')
4800
+ WHERE token_id = ?1 AND revoked_at IS NULL`,
4801
+ [row.child_token_id],
4802
+ );
4803
+ } else {
4804
+ db.run(
4805
+ `UPDATE api_tokens SET revoked_at = datetime('now')
4806
+ WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
4807
+ [row.network_id, `node:${row.child_alias}`],
4808
+ );
4809
+ }
4810
+ db.run(`DELETE FROM nodes WHERE node_id = ?1`, [row.child_node_id]);
4811
+ auditCreateNodeStrict({
4812
+ action: "delete_node_completed",
4813
+ user_id: null, network_id: row.network_id, target_id: request_id,
4814
+ detail: {
4815
+ child_node_id: row.child_node_id, child_alias: row.child_alias,
4816
+ exit_signal: exit_signal || null, backup_path: backup_path || null,
4817
+ ts_daemon_ack: now, lifecycle_state_after: "deleted",
4818
+ },
4819
+ });
4820
+ } else if (status === "stop_failed") {
4821
+ db.run(`UPDATE nodes SET lifecycle_state = 'stop_failed' WHERE node_id = ?1`, [row.child_node_id]);
4822
+ // No completion audit; the failure error is on the request row.
4823
+ }
4824
+ // noop_not_my_child: leave lifecycle_state as-is; the hub-side
4825
+ // sweeper / next reconciliation will pick it up. Don't transition.
4826
+ });
4827
+ } catch (e: any) {
4828
+ console.warn(`[commhub] ✕ ack_stop_request finalize_tx_failed request=${request_id} action=${row.action} child=${row.child_alias}: ${e?.message || e}`);
4829
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "finalize_tx_failed", message: e?.message || String(e) }) }] };
4830
+ }
4831
+
4832
+ // 🔴 关键的是最后那半句「据此做了什么」。#1286 里 hub 的代码路径经核对是
4833
+ // 正确的,缺的正是「它到底走没走那条分支」—— 只说「收到了 ack」回答不了。
4834
+ const did = status === "stopped" && row.action === "delete"
4835
+ ? "revoked ntok + DELETE FROM nodes"
4836
+ : status === "stopped" && row.action === "stop"
4837
+ ? "lifecycle_state=stopped"
4838
+ : status === "stop_failed"
4839
+ ? "lifecycle_state=stop_failed"
4840
+ : "no lifecycle transition (noop_not_my_child)";
4841
+ console.log(`[commhub] ✓ ${row.action}-node finalize: request=${request_id} child=${row.child_alias} (${row.child_node_id}) status=${status} → ${did}`);
4842
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
4843
+ },
4844
+ );
4845
+
4846
+ // start_node is deliberately daemon-mediated. A stopped child has no SSE
4847
+ // consumer of its own, so restart_node's self-doorbell cannot revive it.
4848
+ server.tool(
4849
+ "start_node",
4850
+ "Start a stopped child through its host supervisor daemon. daemon_node_id is auto-resolved from the original create request.",
4851
+ {
4852
+ ...NODE_ID_ALIAS_FIELDS,
4853
+ daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
4854
+ network_id: z.string().max(200).optional(),
4855
+ },
4856
+ async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, network_id: clientNetId }) => {
4857
+ const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
4858
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
4859
+ const child_node_id = idArg.node_id;
4860
+ const resolvedDaemonId = daemon_node_id ?? resolveDaemonForChild(child_node_id);
4861
+ if (!resolvedDaemonId) {
4862
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4863
+ ok: false, error: "daemon_not_resolvable",
4864
+ message: "no create request maps this child to a daemon",
4865
+ }) }] };
4866
+ }
4867
+ const scope = resolveReadScope(clientNetId);
4868
+ if (scope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "forbidden_cross_tenant" }) }] };
4869
+ const q: any[] = [child_node_id];
4870
+ const child = db.get<{ node_id: string; alias: string; network_id: string; lifecycle_state: string | null }>(
4871
+ addReadScope(`SELECT node_id, alias, network_id, lifecycle_state FROM nodes WHERE node_id = ?1`, q, scope), ...q,
4872
+ );
4873
+ if (!child) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "forbidden_cross_tenant" }) }] };
4874
+ if (!canWrite(child.network_id)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
4875
+ // #1448 finding-2 — stale-starting reaper,对齐 update_node_config / restart_node
4876
+ // 的 60s single-flight stale-supersede。start 只受理 'stopped';一旦卡 'starting'
4877
+ // (门铃丢 / daemon 在 UPDATE 'starting' 后死),config/restart 有 reaper、delete 有
4878
+ // 5min force,唯独 start 裸奔 → 下面的 gate 永远拦、也无人把节点拉回 stopped →
4879
+ // 永久卡 'starting'。出口:'starting' 且最近一条 in-flight start 请求晾过阈值 → 标
4880
+ // 旧请求 timeout 超越、放行重派;未过阈值则拒 node_already_starting(带 age)。
4881
+ const priorState = child.lifecycle_state ?? "active";
4882
+ if (priorState !== "stopped") {
4883
+ if (priorState !== "starting") {
4884
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_stopped", current_state: priorState }) }] };
4885
+ }
4886
+ const STALE_STARTING_MS = 60_000;
4887
+ const inFlight = db.get<{ request_id: string; created_at: number; delivered_at: number | null; acked_at: number | null }>(
4888
+ `SELECT request_id, created_at, delivered_at, acked_at FROM node_start_requests
4889
+ WHERE child_node_id = ?1 AND status IN ('pending', 'delivered')
4890
+ ORDER BY created_at DESC LIMIT 1`, child.node_id,
4891
+ );
4892
+ if (inFlight) {
4893
+ // Age anchor = COALESCE(acked_at, delivered_at, created_at),同 config reaper:
4894
+ // 已 pull/已 ack 的活着但慢的 start 会刷新存活时钟,不被误 reap。
4895
+ const ageAnchor = inFlight.acked_at ?? inFlight.delivered_at ?? inFlight.created_at;
4896
+ const age = Date.now() - ageAnchor;
4897
+ if (age <= STALE_STARTING_MS) {
4898
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4899
+ ok: false, error: "node_already_starting",
4900
+ existing_request_id: inFlight.request_id, age_ms: age,
4901
+ hint: `再等一下;超过 ${STALE_STARTING_MS / 1000}s 仍卡 starting,重试 start 会重新派发`,
4902
+ }) }] };
4903
+ }
4904
+ // Stale — 标旧请求 timeout(autocommit,先于下面 tx 的新 INSERT,解除
4905
+ // node_start_requests 对 pending/delivered 的 child 唯一约束),放行重派。
4906
+ db.run(
4907
+ `UPDATE node_start_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3`,
4908
+ [Date.now(), `superseded by new start after ${age}ms stale (> ${STALE_STARTING_MS}ms threshold)`, inFlight.request_id],
4909
+ );
4910
+ }
4911
+ // else: 'starting' 但无 in-flight 行(异常残留态)——照样放行重派以自愈。
4912
+ }
4913
+ // The caller may provide daemon_node_id, but it cannot override the
4914
+ // authoritative child->daemon relationship recorded at creation.
4915
+ const authoritativeDaemonId = resolveDaemonForChild(child_node_id);
4916
+ if (!authoritativeDaemonId || authoritativeDaemonId !== resolvedDaemonId) {
4917
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_child_mismatch" }) }] };
4918
+ }
4919
+ const daemon = db.get<{ node_id: string; alias: string; network_id: string }>(
4920
+ `SELECT node_id, alias, network_id FROM nodes WHERE node_id = ?1`, resolvedDaemonId,
4921
+ );
4922
+ if (!daemon) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found" }) }] };
4923
+ if (daemon.network_id !== child.network_id) {
4924
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_cross_tenant" }) }] };
4925
+ }
4926
+ const requestId = generateId("str");
4927
+ const now = Date.now();
4928
+ try {
4929
+ db.transaction(() => {
4930
+ db.run(
4931
+ `INSERT INTO node_start_requests
4932
+ (request_id, network_id, daemon_node_id, child_node_id, child_alias,
4933
+ created_by_token, status, created_at)
4934
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7)`,
4935
+ [requestId, child.network_id, daemon.node_id, child.node_id, child.alias, callerTokenId || "unknown", now],
4936
+ );
4937
+ // #1448 finding-2 — 也接受从 'starting' 重派(stale-supersede 后原态仍是
4938
+ // starting);两种入态都合法地转/留到 'starting'。
4939
+ db.run(`UPDATE nodes SET lifecycle_state = 'starting' WHERE node_id = ?1 AND lifecycle_state IN ('stopped', 'starting')`, [child.node_id]);
4940
+ auditCreateNodeStrict({
4941
+ action: "start_node_dispatched", user_id: enforceUserId,
4942
+ network_id: child.network_id, target_id: requestId,
4943
+ detail: { child_node_id: child.node_id, child_alias: child.alias, daemon_node_id: daemon.node_id,
4944
+ lifecycle_state_before: priorState, lifecycle_state_after: "starting", ts_request: now },
4945
+ });
4946
+ });
4947
+ } catch (e: any) {
4948
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "dispatch_tx_failed", message: e?.message || String(e) }) }] };
4949
+ }
4950
+ pushEvent(daemon.alias, { type: "start_node", request_id: requestId }, child.network_id);
4951
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, lifecycle_state: "starting" }) }] };
4952
+ },
4953
+ );
4954
+
4955
+ server.tool(
4956
+ "get_start_request",
4957
+ "Host supervisor pulls an authenticated pending start request.",
4958
+ { request_id: z.string().min(1).max(200) },
4959
+ async ({ request_id }) => {
4960
+ const callerDaemon = resolveCallerDaemonTokenBound();
4961
+ if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4962
+ const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
4963
+ `SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id = ?1`, request_id,
4964
+ );
4965
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
4966
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
4967
+ if (row.network_id !== callerDaemon.networkId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
4968
+ if (!['pending', 'delivered'].includes(row.status)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_startable", status: row.status }) }] };
4969
+ db.run(`UPDATE node_start_requests SET status='delivered', delivered_at=?1 WHERE request_id=?2 AND status='pending'`, [Date.now(), request_id]);
4970
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id, child_node_id: row.child_node_id, child_alias: row.child_alias }) }] };
4971
+ },
4972
+ );
4973
+
4974
+ server.tool(
4975
+ "ack_start_request",
4976
+ "Host supervisor reports start completion or failure.",
4977
+ {
4978
+ request_id: z.string().min(1).max(200),
4979
+ status: z.enum(["started", "start_failed"]),
4980
+ child_pid: z.number().int().positive().optional(),
4981
+ error: z.string().max(1000).optional(),
4982
+ },
4983
+ async ({ request_id, status, child_pid, error: ackError }) => {
4984
+ const callerDaemon = resolveCallerDaemonTokenBound();
4985
+ if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4986
+ const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
4987
+ `SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id=?1`, request_id,
4988
+ );
4989
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
4990
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
4991
+ if (row.network_id !== callerDaemon.networkId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
4992
+ if (row.status === "started" || row.status === "start_failed") {
4993
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: row.status, idempotent: true }) }] };
4994
+ }
4995
+ if (!['pending', 'delivered'].includes(row.status)) {
4996
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_ackable", status: row.status }) }] };
4997
+ }
4998
+ if (status === "started" && !child_pid) {
4999
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "child_pid_required" }) }] };
5000
+ }
5001
+ const now = Date.now();
5002
+ try {
5003
+ db.transaction(() => {
5004
+ db.run(`UPDATE node_start_requests SET status=?1,error=?2,child_pid=?3,acked_at=?4 WHERE request_id=?5`, [status, ackError || null, child_pid || null, now, request_id]);
5005
+ db.run(`UPDATE nodes SET lifecycle_state=?1 WHERE node_id=?2`, [status === "started" ? "active" : "stopped", row.child_node_id]);
5006
+ if (status === "started") auditCreateNodeStrict({
5007
+ action: "start_node_completed", network_id: row.network_id, target_id: request_id,
5008
+ detail: { child_node_id: row.child_node_id, child_alias: row.child_alias, child_pid, lifecycle_state_after: "active", ts_daemon_ack: now },
5009
+ });
5010
+ });
5011
+ } catch (e: any) {
5012
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "finalize_tx_failed", message: e?.message || String(e) }) }] };
5013
+ }
2148
5014
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
2149
5015
  },
2150
5016
  );
2151
5017
 
5018
+ // RFC-027 PR1.1 — list_my_children: daemon-only query. Returns the
5019
+ // {child_node_id, alias, lifecycle_state} tuples for nodes whose
5020
+ // active create-request has this daemon as daemon_node_id. Used at
5021
+ // daemon boot to rebuild the in-memory childrenMap (PR1 dropped
5022
+ // every entry on daemon restart → stop/delete silently no-op'd).
5023
+ //
5024
+ // Network-scope is the daemon's own (resolveCallerDaemonTokenBound
5025
+ // already binds tokenIsNetwork to a single network). No SEC-1 leak:
5026
+ // returns ONLY children whose request landed under this daemon's
5027
+ // token + network.
5028
+ server.tool(
5029
+ "list_my_children",
5030
+ "Daemon pulls the alias + node_id list of children it spawned (for childrenMap rebuild after restart). RFC-027 PR1.1.",
5031
+ {},
5032
+ async () => {
5033
+ const callerDaemon = resolveCallerDaemonTokenBound();
5034
+ if (!callerDaemon.ok) {
5035
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
5036
+ }
5037
+ // node_create_requests carries the canonical authoritative
5038
+ // child→daemon mapping (the row id IS the request, and the
5039
+ // child node_id derives from it deterministically via
5040
+ // `node_${request_id.replace(/^cr_/, "")}`). Filter to children
5041
+ // that completed registration (have a nodes row) so we don't
5042
+ // ask the daemon to pgrep for children that never came up.
5043
+ const rows = db.all<{ request_id: string; child_name: string; child_node_id: string; lifecycle_state: string | null }>(
5044
+ `SELECT ncr.request_id, ncr.child_name,
5045
+ ('node_' || substr(ncr.request_id, 4)) AS child_node_id,
5046
+ n.lifecycle_state
5047
+ FROM node_create_requests ncr
5048
+ LEFT JOIN nodes n ON n.node_id = ('node_' || substr(ncr.request_id, 4))
5049
+ WHERE ncr.daemon_node_id = ?1
5050
+ AND ncr.network_id = ?2
5051
+ AND ncr.status IN ('succeeded', 'delivered')
5052
+ AND n.node_id IS NOT NULL
5053
+ AND COALESCE(n.lifecycle_state, 'active') NOT IN ('stopped', 'stop_failed')`,
5054
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
5055
+ );
5056
+ const children = rows.map(r => ({
5057
+ child_node_id: r.child_node_id,
5058
+ alias: r.child_name,
5059
+ lifecycle_state: r.lifecycle_state ?? "active",
5060
+ }));
5061
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: children.length, children }) }] };
5062
+ },
5063
+ );
5064
+
2152
5065
  // ── RFC-028 P1 — Provider & Model Registry + connectivity probe ──
2153
5066
  // Background timers (probe GC + sweeper) idempotent; safe to call
2154
5067
  // per registerTools.
@@ -2377,6 +5290,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2377
5290
  const willReplaceModels = patch.models !== undefined;
2378
5291
  const newModelIds: string[] = [];
2379
5292
 
5293
+ // No-op early return (通信牛 nit): all patch fields matched the
5294
+ // existing row values + no models supplied → no real change to
5295
+ // persist. Returning here means we don't write an empty-diff
5296
+ // audit row (audit noise). Audit_log INSERT must only fire when
5297
+ // something actually changed.
5298
+ if (sets.length === 0 && !willReplaceModels) {
5299
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id, no_changes: true, hint: "patch fields matched existing row, no-op" }) }] };
5300
+ }
5301
+
2380
5302
  try {
2381
5303
  db.exec("BEGIN");
2382
5304
  if (sets.length > 0) {
@@ -2415,10 +5337,6 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2415
5337
  throw e;
2416
5338
  }
2417
5339
 
2418
- if (Object.keys(diff).length === 0 && !willReplaceModels) {
2419
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id, no_changes: true, hint: "patch fields matched existing row, no-op" }) }] };
2420
- }
2421
-
2422
5340
  return { content: [{ type: "text" as const, text: JSON.stringify({
2423
5341
  ok: true, provider_id,
2424
5342
  fields_changed: Object.keys(diff),
@@ -2628,6 +5546,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2628
5546
  export interface UpsertNodeWithSec1GuardInput {
2629
5547
  node_id: string;
2630
5548
  callerNetworkId: string | null;
5549
+ callerUserId?: string | null;
5550
+ callerTokenId?: string | null;
2631
5551
  node_name?: string | null;
2632
5552
  alias?: string | null;
2633
5553
  runtime?: string | null;
@@ -2640,19 +5560,60 @@ export interface UpsertNodeWithSec1GuardInput {
2640
5560
  }
2641
5561
  export type UpsertNodeOutcome =
2642
5562
  | { result: "inserted" | "updated"; node_id: string }
2643
- | { result: "refused"; reason: "cross_network"; existingNet: string | null; callerNet: string | null }
5563
+ | { result: "refused"; reason: "cross_network" | "token_node_mismatch" | "owner_mismatch"; existingNet: string | null; callerNet: string | null }
2644
5564
  | { result: "skipped"; reason: "missing_node_id" };
2645
5565
 
2646
5566
  const _norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x);
2647
5567
 
5568
+ /**
5569
+ * A capability that changes peer delivery semantics is trusted only when the
5570
+ * authenticating ntok is immutably bound to the exact reported node. Legacy
5571
+ * alias-only tokens may still heartbeat, but cannot opt a node into #698.
5572
+ */
5573
+ export function trustedConfigSnapshotForNode(
5574
+ snapshot: unknown | null | undefined,
5575
+ callerTokenId: string | null | undefined,
5576
+ nodeId: string,
5577
+ ): unknown | null | undefined {
5578
+ if (!snapshot || typeof snapshot !== "object") return snapshot;
5579
+ const copy = { ...(snapshot as Record<string, unknown>) };
5580
+ const token = callerTokenId
5581
+ ? db.get<{ bound_node_id: string | null }>(
5582
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1",
5583
+ callerTokenId,
5584
+ )
5585
+ : null;
5586
+ if (token?.bound_node_id !== nodeId) delete copy.peer_reply_inbox_capable;
5587
+ return copy;
5588
+ }
5589
+
2648
5590
  export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): UpsertNodeOutcome {
2649
5591
  if (!input.node_id) return { result: "skipped", reason: "missing_node_id" };
2650
- const existing = db.get<{ network_id: string | null }>(
2651
- "SELECT network_id FROM nodes WHERE node_id = ?1",
5592
+ const existing = db.get<{ network_id: string | null; owner_user_id: string | null }>(
5593
+ "SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1",
2652
5594
  input.node_id,
2653
5595
  );
2654
5596
  const callerNet = input.callerNetworkId;
2655
5597
 
5598
+ // RFC-036 — when a token was minted with a node_id binding, a heartbeat may
5599
+ // report only that exact node. Legacy unbound ntok rows remain compatible,
5600
+ // but they can never consume owner-gated schedule intents.
5601
+ if (input.callerTokenId) {
5602
+ const token = db.get<{ bound_node_id: string | null; user_id: string; network_id: string | null }>(
5603
+ "SELECT bound_node_id, user_id, network_id FROM api_tokens WHERE token_id = ?1",
5604
+ input.callerTokenId,
5605
+ );
5606
+ if (token?.bound_node_id && token.bound_node_id !== input.node_id) {
5607
+ return { result: "refused", reason: "token_node_mismatch", existingNet: existing?.network_id ?? null, callerNet };
5608
+ }
5609
+ if (token?.network_id && _norm(token.network_id) !== _norm(callerNet)) {
5610
+ return { result: "refused", reason: "cross_network", existingNet: existing?.network_id ?? null, callerNet };
5611
+ }
5612
+ }
5613
+ if (existing?.owner_user_id && input.callerUserId !== existing.owner_user_id) {
5614
+ return { result: "refused", reason: "owner_mismatch", existingNet: existing.network_id, callerNet };
5615
+ }
5616
+
2656
5617
  // Legacy / first-write paths: row missing OR network_id NULL → claim.
2657
5618
  const isLegacy = !existing
2658
5619
  || existing.network_id === null
@@ -2698,10 +5659,38 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
2698
5659
  callerNet ?? null,
2699
5660
  ],
2700
5661
  );
2701
- if (input.config_snapshot) {
5662
+ const trustedSnapshot = trustedConfigSnapshotForNode(
5663
+ input.config_snapshot,
5664
+ input.callerTokenId,
5665
+ input.node_id,
5666
+ );
5667
+ if (trustedSnapshot) {
5668
+ // RFC-026 §9.3 / #338 PR2+PR3 — promote daemon self-declare fields
5669
+ // to first-class indexable columns alongside the snapshot blob.
5670
+ // The snapshot stays the source of truth for non-list reads; the
5671
+ // columns exist so `list_host_supervisors` doesn't JSON.parse on
5672
+ // every call. typeof-narrow per
5673
+ // per team rule (typeof-narrow extracted JSON fields at the boundary) — zod narrowed but
5674
+ // input.config_snapshot is typed `unknown` here.
5675
+ //
5676
+ // PR3 nit ①: read from nested `daemon_capabilities.*` (canonical
5677
+ // per RFC §9.3 + matches existing hub create_node reads at
5678
+ // tools.ts:2010/2075). PR2 ate from top-level keys, which the
5679
+ // hub create_node path never read → max_concurrent_children
5680
+ // backpressure was dead config + allowlist enforcement bypassed.
5681
+ const snap = trustedSnapshot as Record<string, unknown> | null;
5682
+ const caps = (snap?.daemon_capabilities ?? null) as Record<string, unknown> | null;
5683
+ const runtimesRaw = caps?.runtimes_supported;
5684
+ const allowedRaw = caps?.allowed_secret_keys;
5685
+ const runtimesJson = Array.isArray(runtimesRaw) && runtimesRaw.every(s => typeof s === "string")
5686
+ ? JSON.stringify(runtimesRaw)
5687
+ : null;
5688
+ const allowedJson = Array.isArray(allowedRaw) && allowedRaw.every(s => typeof s === "string")
5689
+ ? JSON.stringify(allowedRaw)
5690
+ : null;
2702
5691
  db.run(
2703
- `UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
2704
- [JSON.stringify(input.config_snapshot), input.node_id],
5692
+ `UPDATE nodes SET config_snapshot = ?1, runtimes_supported = ?2, allowed_secret_keys = ?3 WHERE node_id = ?4`,
5693
+ [JSON.stringify(trustedSnapshot), runtimesJson, allowedJson, input.node_id],
2705
5694
  );
2706
5695
  // RFC-024 — finalize any pending/restarting update whose target
2707
5696
  // patch is now reflected in the snapshot. Closes the
@@ -2711,7 +5700,7 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
2711
5700
  // update_id to call ack_config_update(applied) itself. Hub does
2712
5701
  // it on the new child's behalf by content-matching the patch
2713
5702
  // against the reported snapshot.
2714
- finalizePendingMatchingUpdates(input.node_id, input.config_snapshot);
5703
+ finalizePendingMatchingUpdates(input.node_id, trustedSnapshot);
2715
5704
  }
2716
5705
  // RFC-026 §2.5 step 4 — on every register/report_status, opportunistically
2717
5706
  // close out any pending create_node request whose child_name matches
@@ -2771,6 +5760,15 @@ export function finalizePendingMatchingUpdates(
2771
5760
 
2772
5761
  const snapModel: string | null | undefined = snapshot?.model;
2773
5762
  const snapFlags: Record<string, unknown> = (snapshot?.flags && typeof snapshot.flags === "object") ? snapshot.flags : {};
5763
+ // #260 P5 — snapshot.channels is the (sorted, bare-type) channel set
5764
+ // the node currently has forked. agent-node's buildConfigSnapshot
5765
+ // always emits it (even as []), so the "absent field" case here means
5766
+ // an older pre-#260-P5 agent-node — for those, don't finalize a
5767
+ // channels-carrying patch (would false-positive on the OTHER fields).
5768
+ const snapChannelsPresent = Array.isArray(snapshot?.channels);
5769
+ const snapChannels: string[] = snapChannelsPresent
5770
+ ? (snapshot.channels as unknown[]).filter((c): c is string => typeof c === "string")
5771
+ : [];
2774
5772
 
2775
5773
  const finalizedIds: string[] = [];
2776
5774
 
@@ -2795,6 +5793,35 @@ export function finalizePendingMatchingUpdates(
2795
5793
  if (snapFlags[k] !== v) { matches = false; break; }
2796
5794
  }
2797
5795
  }
5796
+ // #260 P5 — channels field content-match. Without this the
5797
+ // node's very first startup report_status matches (patch.flags
5798
+ // is `{}` for a channels-only update, so the flags loop is
5799
+ // trivially satisfied) and hub prematurely marks the update
5800
+ // applied, deleting the pending row + bumping config_revision
5801
+ // BEFORE the doorbell reaches the child. Codex catch on PR #411.
5802
+ //
5803
+ // Set-equality is enough because the patch side comes from
5804
+ // narrowChannelsPatch → already deduped + case-folded + in
5805
+ // EDITABLE_CHANNELS iteration order; the snapshot side comes
5806
+ // from buildConfigSnapshot which mirrors the same shape.
5807
+ if (matches && Array.isArray(patch.channels)) {
5808
+ if (!snapChannelsPresent) {
5809
+ // Pre-#260-P5 agent-node — no channels field in snapshot,
5810
+ // so we can't prove the apply landed. Skip finalize; hub's
5811
+ // F-B reaper still supersedes this eventually.
5812
+ matches = false;
5813
+ } else {
5814
+ const wanted = new Set<string>(patch.channels);
5815
+ const have = new Set<string>(snapChannels);
5816
+ if (wanted.size !== have.size) {
5817
+ matches = false;
5818
+ } else {
5819
+ for (const w of wanted) {
5820
+ if (!have.has(w)) { matches = false; break; }
5821
+ }
5822
+ }
5823
+ }
5824
+ }
2798
5825
  }
2799
5826
 
2800
5827
  if (!matches) continue;