@sleep2agi/commhub-server 0.9.0-preview.5 → 0.9.0-preview.51

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 (129) 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 +80 -1
  24. package/src/create-node-validate.ts +49 -5
  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 +690 -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/hub-timestamp.test.ts +38 -0
  47. package/src/hub-timestamp.ts +27 -0
  48. package/src/inbox-count-sse.test.ts +310 -0
  49. package/src/inbox-count.ts +29 -0
  50. package/src/index.ts +15 -2227
  51. package/src/lifecycle-guard.ts +50 -0
  52. package/src/list-host-supervisors.test.ts +314 -0
  53. package/src/mcp-write-network-resolution.test.ts +429 -0
  54. package/src/network-name-http.test.ts +75 -0
  55. package/src/network-scope.ts +101 -0
  56. package/src/node-attrs-validate.ts +88 -0
  57. package/src/node-attrs.test.ts +217 -0
  58. package/src/node-id-alias.test.ts +188 -0
  59. package/src/node-lifecycle-controllable.test.ts +64 -0
  60. package/src/node-lifecycle-controllable.ts +51 -0
  61. package/src/observer-avatar-http.test.ts +428 -0
  62. package/src/observer-push.test.ts +252 -0
  63. package/src/package-version-consistency.test.ts +25 -0
  64. package/src/peer-reply-atomic.test.ts +455 -0
  65. package/src/peer-reply-capability.test.ts +70 -0
  66. package/src/push.ts +163 -7
  67. package/src/redact-tokens.test.ts +65 -0
  68. package/src/redact-tokens.ts +40 -0
  69. package/src/reply-target-derivation.test.ts +96 -0
  70. package/src/report-status-host-schema.test.ts +211 -0
  71. package/src/report-status-resume-id-handover.test.ts +150 -0
  72. package/src/require-explicit-test-db.ts +9 -0
  73. package/src/resolve-port.test.ts +57 -0
  74. package/src/resolve-port.ts +44 -0
  75. package/src/response-charset.test.ts +130 -0
  76. package/src/rest-explicit-columns-http.test.ts +216 -0
  77. package/src/rest-identity.test.ts +129 -0
  78. package/src/rest-identity.ts +100 -0
  79. package/src/rest-projections.ts +67 -0
  80. package/src/rest-write-network-resolution.test.ts +133 -0
  81. package/src/rules-file-transport.test.ts +235 -0
  82. package/src/runtime-label.test.ts +22 -0
  83. package/src/runtime-label.ts +21 -0
  84. package/src/scheduled-run-terminal.test.ts +268 -0
  85. package/src/scheduled-tasks-http.test.ts +483 -0
  86. package/src/scheduled-tasks.ts +549 -0
  87. package/src/send-reply-agent-warning.test.ts +202 -0
  88. package/src/send-reply-alias-optional.test.ts +131 -0
  89. package/src/send-reply-attachments.test.ts +458 -0
  90. package/src/serve-error.test.ts +38 -0
  91. package/src/serve-error.ts +27 -0
  92. package/src/server.ts +3780 -0
  93. package/src/shared/external-schedule-contract.ts +60 -0
  94. package/src/side-thread-command-transport.test.ts +302 -0
  95. package/src/side-thread-command-transport.ts +318 -0
  96. package/src/side-thread-http-integration.test.ts +233 -0
  97. package/src/side-thread-http.ts +504 -0
  98. package/src/side-thread-production.test.ts +72 -0
  99. package/src/side-thread-production.ts +118 -0
  100. package/src/side-thread.test.ts +1067 -0
  101. package/src/side-thread.ts +2495 -0
  102. package/src/skillhub-http.test.ts +92 -0
  103. package/src/start-node.test.ts +109 -0
  104. package/src/stop-delete-node.test.ts +952 -0
  105. package/src/task-auth-origin-http.test.ts +91 -0
  106. package/src/task-auth-origin.test.ts +24 -0
  107. package/src/task-auth-origin.ts +7 -0
  108. package/src/task-consumption.test.ts +471 -0
  109. package/src/task-diagnostic-http.test.ts +149 -0
  110. package/src/task-diagnostic.test.ts +115 -0
  111. package/src/task-diagnostic.ts +118 -0
  112. package/src/task-idempotency-mcp.test.ts +142 -0
  113. package/src/task-idempotency.test.ts +60 -0
  114. package/src/task-idempotency.ts +75 -0
  115. package/src/task-lifecycle-watcher.test.ts +204 -0
  116. package/src/task-lifecycle-watcher.ts +49 -0
  117. package/src/task-network-resolution-http.test.ts +141 -0
  118. package/src/tasks-pagination-http.test.ts +141 -0
  119. package/src/tools-list-json-schema.test.ts +66 -0
  120. package/src/tools.ts +3026 -197
  121. package/src/update-provider.test.ts +65 -0
  122. package/src/upload-early-reject-guard.test.ts +155 -0
  123. package/src/uploads-http.test.ts +97 -15
  124. package/src/uploads.test.ts +66 -0
  125. package/src/uploads.ts +130 -5
  126. package/src/user-inbox-by-agent-http.test.ts +161 -0
  127. package/src/user-inbox-netid-notnull.test.ts +122 -0
  128. package/src/user-inbox-read-http.test.ts +223 -0
  129. 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,16 @@ 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";
52
65
 
53
66
  function ts(): string {
54
67
  return new Date().toTimeString().slice(0, 8);
@@ -61,7 +74,55 @@ function parseMetaJson(value: unknown): unknown | null {
61
74
 
62
75
  function normalizeMetaJson(meta: unknown): string | null {
63
76
  if (!meta || typeof meta !== "object") return null;
64
- try { return JSON.stringify(meta); } catch { return null; }
77
+ // #222 cross-host safety — strip `path` from meta.attachments[] when
78
+ // `file_id` is also present. Shared helper lives in uploads.ts so the
79
+ // REST handler in index.ts can apply the identical sanitization
80
+ // (otherwise REST and MCP transports would have different meta_json
81
+ // shapes for the same send).
82
+ try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
83
+ }
84
+
85
+ // ── #1281 — 子节点生命周期工具的参数名统一 ────────────────────────────
86
+ //
87
+ // stop_node / start_node / delete_node 历史上用 `child_node_id`(RFC-027),
88
+ // restart_node / update_node_config 用 `node_id`。同一个对象(子节点)两个名字,
89
+ // 调用方按一个工具的习惯给另一个传就吃 -32602。两侧都已是**已发布契约**
90
+ // (start_node 随 #1273 合入),且 hub API 在 RFC-030 混版滚动期老客户端还在
91
+ // 发老名——所以不能硬改名,改为**两个名字都接受**:
92
+ // · canonical = `node_id`(= DB 列 nodes.node_id、= create_node 的输出、
93
+ // restart/update 历史用名;「建完节点→再操作它」用同一个名才是傻瓜式)。
94
+ // · `child_node_id` 作为**兼容 alias 保留** + deprecation 注释;不加运行时
95
+ // warning(老客户端是设计内兼容,不是错误,别刷屏)。
96
+ // create_node 的 `daemon_node_id` 指宿主 daemon,是另一层语义,不在本次统一内。
97
+ //
98
+ // server.tool 收的是 ZodRawShape(裸对象),挂不了 .refine(),所以「至少一个
99
+ // 必填」+「两个都传须相等」在 handler 侧用 resolveNodeIdArg 兜——返回结构化
100
+ // ok:false 反而比 schema 层的 -32602 更友好。两个名字在 schema 层都做成
101
+ // optional + 宽松 .min(1).max(200)(不带 regex),使命名与校验强度同时统一:
102
+ // 对 restart/update 不变(本就宽松),对 stop/start/delete 是放宽(原 regex 会
103
+ // 在 schema 层挡掉 alias/杂串,放宽后落到查表返回 node_not_found,对合法调用
104
+ // 方无影响,只是错误形态更一致)。
105
+ const NODE_ID_ALIAS_FIELDS = {
106
+ node_id: z.string().min(1).max(200).optional()
107
+ .describe("Target child node ID (canonical, #1281). The value create_node returns. Must be in caller's network."),
108
+ child_node_id: z.string().min(1).max(200).optional()
109
+ .describe("DEPRECATED alias for node_id (#1281): kept for back-compat with pre-unification clients; prefer node_id."),
110
+ } as const;
111
+
112
+ /** #1281 — 把 node_id / child_node_id 收敛成单一内部 id。两个都传且不等 ⇒
113
+ * node_id_conflict(挡「node_id 填 A、child_node_id 填 B」的手滑,比单纯二选一强);
114
+ * 都不传 ⇒ node_id_required。纯函数,模块级导出以便单测直接验解析逻辑。 */
115
+ export function resolveNodeIdArg(a: { node_id?: string; child_node_id?: string }):
116
+ { ok: true; node_id: string } | { ok: false; error: string; message: string } {
117
+ const n = a.node_id, c = a.child_node_id;
118
+ if (n && c && n !== c) {
119
+ 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)" };
120
+ }
121
+ const id = n ?? c;
122
+ if (!id) {
123
+ 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)" };
124
+ }
125
+ return { ok: true, node_id: id };
65
126
  }
66
127
 
67
128
  export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null) {
@@ -87,30 +148,213 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
87
148
  }],
88
149
  };
89
150
  };
90
- // If enforceNetworkId is set, override any client-supplied network_id
91
- const getNetworkId = (clientNetId?: string | null) => enforceNetworkId ?? clientNetId ?? null;
151
+ // MCP-side auth context in the shape network-scope.ts helpers take.
152
+ // null = legacy global-token / open-dev mode (unscoped, allow-all).
153
+ const mcpAuthCtx = enforceUserId ? { userId: enforceUserId, networkId: enforceNetworkId ?? null } : null;
154
+
155
+ // If enforceNetworkId is set (ntok_), override any client-supplied
156
+ // network_id. #517: for utok_ with no explicit network_id, fall back to
157
+ // the user's single membership via the SAME singleNetworkId used by REST
158
+ // POST /api/task (network-scope.ts) — utok_ rows carry network_id=null by
159
+ // design, so without this fallback single-network nodes could read
160
+ // everything but write nothing. Multi-network stays null (ambiguous) and
161
+ // canWrite rejects with network_id_required.
162
+ const getNetworkId = (clientNetId?: string | null) => {
163
+ const explicit = enforceNetworkId ?? clientNetId ?? null;
164
+ if (explicit !== null || !enforceUserId) return explicit;
165
+ return singleNetworkId({ networkId: null, networkIds: getUserNetworkIds(enforceUserId) });
166
+ };
92
167
 
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.
168
+ // Check write access — delegates to the shared canRestWriteNetwork so MCP
169
+ // and REST cannot drift again (#517). isAdmin=false: MCP has no admin
170
+ // bypass today; keep behavior identical to before the extraction.
96
171
  const canWrite = (effectiveNetworkId?: string | null): boolean => {
97
- if (!enforceUserId) return true; // legacy global token mode, allow
98
172
  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
173
+ return canRestWriteNetwork(mcpAuthCtx, netId, false);
102
174
  };
103
175
 
176
+ // #517: name the REAL cause. The old catch-all blamed permissions for
177
+ // what was actually an unresolvable network, sending operators down the
178
+ // wrong debugging path (roles/membership) for hours.
104
179
  const writeDeniedReply = (effectiveNetworkId?: string | null, action = "write") => {
105
180
  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"
181
+ let error: string;
182
+ let message: string;
183
+ if (!netId) {
184
+ const memberships = enforceUserId ? getUserNetworkIds(enforceUserId) : [];
185
+ error = "network_id_required";
186
+ message = memberships.length === 0
187
+ ? "user token has no network memberships; join or create a network first"
188
+ : `user token spans ${memberships.length} networks; pass network_id explicitly (see /api/auth/me networks[].network_id)`;
189
+ } else if (enforceUserId && !getUserNetworkRole(enforceUserId, netId)) {
190
+ error = "access_denied";
191
+ message = "access denied to requested network (not a member)";
192
+ } else {
193
+ error = "permission_denied";
194
+ message = action === "send_task"
109
195
  ? "Viewer role cannot send tasks"
110
196
  : "Viewer role cannot write to this network";
111
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied", message }) }] };
197
+ }
198
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error, message }) }] };
112
199
  };
113
200
 
201
+ const skillHubReply = (value: Record<string, unknown>) => ({
202
+ content: [{ type: "text" as const, text: JSON.stringify(value) }],
203
+ });
204
+
205
+ // SkillHub is a network registry, not a public filesystem. Content is an
206
+ // immutable SKILL.md snapshot; identity comes only from the authenticated
207
+ // MCP principal (never from request fields).
208
+ server.tool(
209
+ "submit_skill",
210
+ "Submit an immutable SKILL.md version to the caller's network SkillHub. Node identity is token-bound. New submissions require review.",
211
+ {
212
+ slug: z.string().min(2).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
213
+ name: z.string().min(1).max(120),
214
+ description: z.string().max(1000).optional(),
215
+ version: z.string().min(1).max(40).regex(/^[0-9A-Za-z]+(?:[._-][0-9A-Za-z]+)*$/),
216
+ content: z.string().min(1).max(128 * 1024).refine(value => !value.includes("\0"), "content must not contain NUL bytes"),
217
+ network_id: z.string().max(200).optional(),
218
+ },
219
+ async ({ slug, name, description, version, content, network_id: clientNetId }) => {
220
+ const effectiveNetId = getNetworkId(clientNetId);
221
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
222
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
223
+ const sourceType = callerTokenIsNetwork ? "node" : "user";
224
+ if (sourceType === "node" && !callerAlias) return skillHubReply({ ok: false, error: "node_identity_unbound" });
225
+ const contentHash = createHash("sha256").update(content, "utf8").digest("hex");
226
+ const existing = db.get<any>(
227
+ `SELECT skill_id, content_hash, status FROM skillhub_skills
228
+ WHERE network_id = ?1 AND slug = ?2 AND version = ?3`,
229
+ effectiveNetId, slug, version,
230
+ );
231
+ if (existing) {
232
+ if (existing.content_hash === contentHash) {
233
+ return skillHubReply({ ok: true, idempotent: true, skill_id: existing.skill_id, status: existing.status });
234
+ }
235
+ return skillHubReply({ ok: false, error: "skill_version_conflict", hint: "publish changed content under a new version" });
236
+ }
237
+ const skillId = `skill_${uuidv4()}`;
238
+ try {
239
+ db.run(
240
+ `INSERT INTO skillhub_skills
241
+ (skill_id, network_id, slug, name, description, version, content, content_hash, status, source_type, source_alias, created_by_user)
242
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
243
+ [skillId, effectiveNetId, slug, name.trim(), description?.trim() || "", version, content, contentHash, sourceType, callerAlias, enforceUserId || null],
244
+ );
245
+ } catch (error: any) {
246
+ // Two submitters may pass the preflight SELECT together. Resolve the
247
+ // UNIQUE race into the same deterministic idempotent/conflict contract
248
+ // instead of leaking a storage exception as HTTP/MCP 500.
249
+ if (!/unique|duplicate key/i.test(error?.message || "")) throw error;
250
+ const winner = db.get<any>(
251
+ `SELECT skill_id, content_hash, status FROM skillhub_skills
252
+ WHERE network_id = ?1 AND slug = ?2 AND version = ?3`,
253
+ effectiveNetId, slug, version,
254
+ );
255
+ if (!winner) throw error;
256
+ if (winner.content_hash === contentHash) {
257
+ return skillHubReply({ ok: true, idempotent: true, skill_id: winner.skill_id, status: winner.status });
258
+ }
259
+ return skillHubReply({ ok: false, error: "skill_version_conflict", hint: "publish changed content under a new version" });
260
+ }
261
+ db.run(
262
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
263
+ VALUES (?1, ?2, 'skill_submit', 'skill', ?3, ?4, ?5)`,
264
+ [enforceUserId || null, callerAlias || null, skillId, JSON.stringify({ slug, version, source_type: sourceType }), effectiveNetId],
265
+ );
266
+ return skillHubReply({ ok: true, skill_id: skillId, status: "pending", source_type: sourceType, source_alias: callerAlias });
267
+ },
268
+ );
269
+
270
+ server.tool(
271
+ "list_skills",
272
+ "List published skills in a network. Owners/admins may include pending review items.",
273
+ {
274
+ network_id: z.string().max(200).optional(),
275
+ include_pending: z.boolean().optional(),
276
+ query: z.string().max(120).optional(),
277
+ limit: z.number().int().min(1).max(200).optional(),
278
+ },
279
+ async ({ network_id: clientNetId, include_pending, query, limit }) => {
280
+ const effectiveNetId = getNetworkId(clientNetId);
281
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "read");
282
+ const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
283
+ if (enforceUserId && !role) return writeDeniedReply(effectiveNetId, "read");
284
+ // An ntok_ belongs to a node even when it was minted by the network
285
+ // owner. Never inherit the owner's review power through that token.
286
+ const reviewer = !callerTokenIsNetwork && (role === "owner" || role === "admin");
287
+ const showPending = !!include_pending && reviewer;
288
+ const params: unknown[] = [effectiveNetId];
289
+ let sql = `SELECT skill_id, slug, name, description, version, status, source_type, source_alias,
290
+ created_at, updated_at, reviewed_at, review_note
291
+ FROM skillhub_skills WHERE network_id = ?1`;
292
+ if (!showPending) sql += ` AND status = 'published'`;
293
+ if (query?.trim()) {
294
+ params.push(`%${query.trim()}%`);
295
+ sql += ` AND (slug LIKE ?${params.length} OR name LIKE ?${params.length} OR description LIKE ?${params.length})`;
296
+ }
297
+ sql += ` ORDER BY updated_at DESC LIMIT ${limit ?? 100}`;
298
+ return skillHubReply({ ok: true, reviewer, skills: db.all(sql, ...params) });
299
+ },
300
+ );
301
+
302
+ server.tool(
303
+ "get_skill",
304
+ "Read one SkillHub SKILL.md. Pending content is visible only to owners/admins.",
305
+ { skill_id: z.string().regex(/^skill_[A-Za-z0-9_-]+$/).max(200), network_id: z.string().max(200).optional() },
306
+ async ({ skill_id, network_id: clientNetId }) => {
307
+ const effectiveNetId = getNetworkId(clientNetId);
308
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "read");
309
+ const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
310
+ if (enforceUserId && !role) return writeDeniedReply(effectiveNetId, "read");
311
+ const row = db.get<any>(
312
+ `SELECT skill_id, slug, name, description, version, content, status,
313
+ source_type, source_alias, created_at, updated_at, reviewed_at, review_note
314
+ FROM skillhub_skills WHERE skill_id = ?1 AND network_id = ?2`,
315
+ skill_id, effectiveNetId,
316
+ );
317
+ if (!row) return skillHubReply({ ok: false, error: "skill_not_found" });
318
+ const reviewer = !callerTokenIsNetwork && (role === "owner" || role === "admin");
319
+ if (row.status !== "published" && !reviewer) {
320
+ return skillHubReply({ ok: false, error: "skill_not_found" });
321
+ }
322
+ return skillHubReply({ ok: true, skill: row });
323
+ },
324
+ );
325
+
326
+ server.tool(
327
+ "review_skill",
328
+ "Publish or reject a pending SkillHub submission. Network owner/admin only.",
329
+ {
330
+ skill_id: z.string().regex(/^skill_[A-Za-z0-9_-]+$/).max(200),
331
+ decision: z.enum(["published", "rejected"]),
332
+ note: z.string().max(1000).optional(),
333
+ network_id: z.string().max(200).optional(),
334
+ },
335
+ async ({ skill_id, decision, note, network_id: clientNetId }) => {
336
+ const effectiveNetId = getNetworkId(clientNetId);
337
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
338
+ const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
339
+ if (callerTokenIsNetwork || (role !== "owner" && role !== "admin")) return skillHubReply({ ok: false, error: "skill_review_admin_required" });
340
+ const row = db.get<any>(`SELECT status FROM skillhub_skills WHERE skill_id = ?1 AND network_id = ?2`, skill_id, effectiveNetId);
341
+ if (!row) return skillHubReply({ ok: false, error: "skill_not_found" });
342
+ if (row.status !== "pending") return skillHubReply({ ok: false, error: "skill_not_pending", status: row.status });
343
+ const updated = db.run(
344
+ `UPDATE skillhub_skills SET status = ?1, review_note = ?2, reviewed_by_user = ?3,
345
+ reviewed_at = datetime('now'), updated_at = datetime('now') WHERE skill_id = ?4 AND network_id = ?5 AND status = 'pending'`,
346
+ [decision, note?.trim() || null, enforceUserId || null, skill_id, effectiveNetId],
347
+ );
348
+ if (updated.changes !== 1) return skillHubReply({ ok: false, error: "skill_not_pending" });
349
+ db.run(
350
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
351
+ VALUES (?1, ?2, 'skill_review', 'skill', ?3, ?4, ?5)`,
352
+ [enforceUserId || null, callerAlias || null, skill_id, JSON.stringify({ decision }), effectiveNetId],
353
+ );
354
+ return skillHubReply({ ok: true, skill_id, status: decision });
355
+ },
356
+ );
357
+
114
358
  const addScope = (sql: string, params: any[], networkId?: string | null, column = "network_id"): string => {
115
359
  if (!networkId) return sql;
116
360
  sql += ` AND ${column} = ?${params.length + 1}`;
@@ -118,15 +362,25 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
118
362
  return sql;
119
363
  };
120
364
 
365
+ /** #1548 —— 把一个还标着 blocked 的会话拉回 idle。只在「它刚发出终态回复 / 刚派了任务」这两个活性证据处调用。 */
366
+ const releaseBlockedSession = (alias: string | null | undefined, networkId: string | null | undefined): void => {
367
+ if (!alias) return;
368
+ const params: any[] = [alias];
369
+ let sql = "UPDATE sessions SET status = 'idle', updated_at = datetime('now') WHERE alias = ?1 AND status = 'blocked'";
370
+ sql = addScope(sql, params, networkId ?? null);
371
+ try {
372
+ db.run(sql, params);
373
+ } catch {
374
+ // 展示层的一次修正失败不能影响主路径。
375
+ }
376
+ };
377
+
121
378
  type ReadScope = { networkId?: string | null; networkIds?: string[] | null; denied?: string };
122
379
 
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
- };
380
+ // Delegates to the shared membership query (network-scope.ts) — was a
381
+ // byte-for-byte duplicate of getUserNetworkIds before #517.
382
+ const getReadableNetworkIds = (): string[] =>
383
+ enforceUserId ? getUserNetworkIds(enforceUserId) : [];
130
384
 
131
385
  const resolveReadScope = (clientNetId?: string | null): ReadScope => {
132
386
  if (!enforceUserId) return { networkId: clientNetId ?? null, networkIds: null };
@@ -141,6 +395,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
141
395
  return { networkId: null, networkIds: getReadableNetworkIds() };
142
396
  };
143
397
 
398
+ // RFC-027 §2.3 race-free invariant — assertNodeActive lives in
399
+ // server/src/lifecycle-guard.ts so REST handlers in server/src/index.ts
400
+ // can use the SAME code path. PR1.1 had it inline here; PR1.2a
401
+ // (#346 review catch) extracted because the closure scope made it
402
+ // unreachable from REST and left the §2.3 race open on dashboard
403
+ // Dispatch (POST /api/task + /api/broadcast). Per
404
+ // 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.
405
+
144
406
  const addReadScope = (sql: string, params: any[], scope: ReadScope, column = "network_id"): string => {
145
407
  if (scope.networkId) {
146
408
  sql += ` AND ${column} = ?${params.length + 1}`;
@@ -161,6 +423,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
161
423
  | { state: "offline"; alias: string; session: any; message: string }
162
424
  | { state: "not_found"; alias: string; message: string };
163
425
 
426
+ // Only call after the authenticated, network-scoped lookup resolved a
427
+ // concrete target. Error paths intentionally omit this identity object.
428
+ const actualTo = (target: Exclude<DeliveryTarget, { state: "not_found" }>, networkId?: string | null) => ({
429
+ alias: target.alias,
430
+ to_node_id: target.session?.node_id ?? null,
431
+ network_id: networkId ?? null,
432
+ });
433
+
164
434
  const scopedSessionStatus = (alias: string, networkId?: string | null) => {
165
435
  const params: any[] = [alias];
166
436
  let sql = "SELECT status, updated_at, last_seen_at, node_id FROM sessions WHERE alias = ?1";
@@ -261,9 +531,39 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
261
531
  model: z.string().max(200).optional().describe("AI model name"),
262
532
  node_name: z.string().max(200).optional().describe("Stable node display name (may differ from alias)"),
263
533
  network_id: z.string().max(200).optional().describe("Network this agent belongs to"),
534
+ // ╭─ 🔴 往 report_status 里加字段之前先读这一段(#1545 实测,zod 4.3.6)─────────╮
535
+ // │ **这份 schema 的子对象一半严一半松,而后果完全不同。**
536
+ // │
537
+ // │ 非 strict:host / process_telemetry / config_snapshot / daemon_capabilities
538
+ // │ `.strict()`:external_schedules(含其 schedules[] 元素)
539
+ // │ side_thread_capability(含其 exactBoundary)
540
+ // │
541
+ // │ 往**非 strict** 的对象里加一个 hub 还不认识的键 ⇒ zod **静默丢弃**。
542
+ // │ 节点不会掉线,但字段人间蒸发 —— 现场表现为「daemon 明明发了、hub 上没有」,
543
+ // │ 排查起来和 daemon 侧 bug 一模一样。
544
+ // │ 往**strict** 的对象里加同一个键 ⇒ `unrecognized_keys`,**整份 report_status 被拒**。
545
+ // │ 而 agent-node 的 register() 是 `await` 且无人 catch ⇒ **节点进程当场死掉**
546
+ // │ (#1225 就是这么全网躺倒的,见下面 host.ip 那段)。
547
+ // │
548
+ // │ 所以「新字段能不能 daemon 先发」**没有统一答案,取决于你往哪个子对象里加**:
549
+ // │ 非 strict → 可以先发(会丢,不会死);strict → hub 必须先合,否则升级即失联。
550
+ // │
551
+ // │ 🔴 别把这条读成「strict 更危险、都改成非 strict」。两者各有其位:
552
+ // │ external_schedules / side_thread_capability 是**有界快照**,strict 挡的是
553
+ // │ 「节点往里塞任意键」;daemon_capabilities 是**能力自述**,天然要向前兼容
554
+ // │ (旧 hub 见到新能力应当忽略而不是拒绝整份上报)。**加字段前先看清你在哪一边。**
555
+ // ╰──────────────────────────────────────────────────────────────────────────────╯
264
556
  host: z.object({
265
- hostname: z.string().max(200).optional(),
266
- ip: z.string().max(200).optional(),
557
+ // 🔴 `.nullable()` 不是防御性冗余,是**发送方声明的类型**:agent-node 的
558
+ // HostTelemetry 是 `ip: string | null`,`firstNonInternalIPv4()` 在没有
559
+ // 非回环 IPv4 的机器上返回 null(--network none 的容器、断网的笔记本、
560
+ // 部分 CI runner)。此前这里只有 `.optional()`,于是那台机器上的
561
+ // agent-node 一启动就收到 MCP -32602,而 register() 是 await 且无人 catch
562
+ // —— **整个节点进程当场死掉**,和 runtime 无关。
563
+ // 同一个对象里四个数值字段本来就写着 `.nullable().optional()`,
564
+ // 两个字符串字段是唯一的例外;正确写法一直在隔壁那一行。
565
+ hostname: z.string().max(200).nullable().optional(),
566
+ ip: z.string().max(200).nullable().optional(),
267
567
  cpu_load_1min: z.number().nullable().optional(),
268
568
  cpu_cores: z.number().nullable().optional(),
269
569
  mem_total_gb: z.number().nullable().optional(),
@@ -281,6 +581,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
281
581
  uptime_seconds: z.number().nullable().optional(),
282
582
  in_flight_count: z.number().nullable().optional(),
283
583
  }).optional().describe("Per-agent process telemetry reported by agent-node"),
584
+ external_schedules: z.object({
585
+ observed_at: z.string().datetime({ offset: true }).max(64),
586
+ schedules: z.array(z.object({
587
+ id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/),
588
+ name: z.string().min(1).max(200),
589
+ kind: z.enum(["cron", "systemd", "tmux", "playwright", "custom"]),
590
+ frequency: z.string().min(1).max(120),
591
+ last_run_at: z.string().datetime({ offset: true }).max(64).nullable(),
592
+ last_status: z.enum(["success", "failed", "running", "unknown"]),
593
+ last_error: z.string().max(500).nullable(),
594
+ next_run_at: z.string().datetime({ offset: true }).max(64).nullable(),
595
+ log_ref: z.string().min(1).max(255).nullable(),
596
+ enabled: z.boolean(),
597
+ // RFC-036 — only agent-node managed cron markers advertise write
598
+ // capability. Legacy/other kinds omit these fields and stay read-only.
599
+ editable: z.boolean().optional(),
600
+ revision: z.number().int().min(0).optional(),
601
+ }).strict()).max(64),
602
+ error: z.enum(["invalid_manifest", "unsafe_manifest", "read_failed"]).optional(),
603
+ }).strict().optional().describe("Bounded node-reported external schedule snapshot; never includes host paths or commands"),
284
604
  // RFC-024 B6 — masked snapshot of the node's effective config
285
605
  // (model + 6 dashboard-editable flags). Secrets ARE NOT in this
286
606
  // shape (env._envRef stays on host); the dashboard reads this
@@ -291,12 +611,110 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
291
611
  // can grey out remote-restart for them.
292
612
  config_snapshot: z.object({
293
613
  model: z.string().max(200).optional().nullable(),
294
- flags: z.record(z.unknown()).optional(),
614
+ flags: z.record(z.string(), z.unknown()).optional(),
295
615
  config_revision: z.number().int().min(0).optional(),
296
616
  config_update_capable: z.boolean().optional(),
617
+ peer_reply_inbox_capable: z.literal(true).optional(),
618
+ side_thread_capability: z.object({
619
+ supported: z.boolean(),
620
+ runtime: z.string().max(64),
621
+ runtimeVersion: z.string().max(64),
622
+ topology: z.string().max(64),
623
+ evidenceRevision: z.string().max(100),
624
+ mode: z.literal("native-exact-fork").optional(),
625
+ exactBoundary: z.object({ through: z.boolean(), before: z.boolean() }).strict().optional(),
626
+ reason: z.enum(["runtime", "version", "topology", "experimental-api", "exact-boundary"]).optional(),
627
+ }).strict().optional(),
628
+ // RFC-026 P2 / #338 — daemon role surfaced for hub /api/nodes
629
+ // discovery (#337 extracts this field). "host_supervisor" =
630
+ // anet daemon. Default-stripping zod would drop this otherwise.
631
+ role: z.string().max(64).optional().nullable(),
632
+ // RFC-026 §9.3 / #338 PR3 — daemon self-declare nested under
633
+ // `daemon_capabilities` (canonical shape per existing hub reads
634
+ // at tools.ts:2010/2075 — PR1/PR2 placed these top-level, hub
635
+ // never saw them, max_concurrent_children stayed default + the
636
+ // allowlists stayed unenforced. PR3 nit ① per 通信龙).
637
+ // Soft caps avoid abuse via attacker daemon.
638
+ // 🔴 这个对象**故意不是 `.strict()`** —— 能力自述必须向前兼容:
639
+ // 旧 hub 见到新 daemon 报的新能力,应当忽略那一格,而不是拒掉整份 report
640
+ // 把节点踢下线。代价是新键在 hub 升级前会被**静默丢弃**(所以新字段仍然
641
+ // 应当 hub 先合)。同一份 schema 里 side_thread_capability / external_schedules
642
+ // 是 `.strict()` 的,后果不同 —— 见 `host:` 上方那段方框注释。
643
+ daemon_capabilities: z.object({
644
+ runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
645
+ allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
646
+ max_concurrent_children: z.number().int().min(1).max(1000).optional(),
647
+ // #1353 —— daemon **当下**能不能创建节点。与 runtimes_supported 不是一回事:
648
+ // 后者是声明(我支持哪些 runtime),这个是实际能力。一个 daemon 可以声明支持
649
+ // 三种 runtime,同时因为 ANET_BIN pin 解析不出来而一种也创建不了。
650
+ //
651
+ // 🔴 为什么 hub 需要看见它:pin 只存在于 /etc/anet-daemon/path.conf 或
652
+ // 显式开启的环境变量里,**重启不带环境就会丢**。丢了之后 daemon 照常注册、
653
+ // 在线、收 doorbell,hub 返回 ok:true + request_id —— 而节点永远不出现。
654
+ // 在此之前 hub 完全看不出区别,Dashboard 的「选服务器」还会把它列为可选。
655
+ can_create_nodes: z.boolean().optional(),
656
+ // 只收**代码**,不收原始报错文本 —— 上游 unsafePathHelp() 的消息里带完整机器路径,
657
+ // 而这个字段会走到 Dashboard。enum 而不是 string:一个自由文本字段等于给
658
+ // 「把路径塞进来」留了口子。
659
+ // #1353 —— 四类失败,修法完全不同:
660
+ // identity 重装 anet / unset ANET_BIN_ABS
661
+ // source 写 /etc/anet-daemon/path.conf(**要 sudo**)
662
+ // shape 换成 realpath
663
+ // permission **一行 chmod go-w**
664
+ // 混成一条会让人修错方向 —— 2026-08-28 我一天里撞到其中两类,看到的是同一条错误。
665
+ //
666
+ // 🔴 `anet_bin_pin_unresolved` **必须留着**:已经在跑的 agent-node@2.5.0-preview.40
667
+ // 发的就是这个值。zod 对象里任何一个字段验证失败会让**整个 report_status 被拒**,
668
+ // 不只是丢掉这一格 —— 那会让所有 .40 daemon 在 hub 上变成失联。
669
+ // 删一个 enum 值的代价,远大于多留一个。
670
+ // 🔴 仍然是 enum 不是 string:自由文本字段就是给机器路径留的口子,
671
+ // 而上游 e.message 里带的正是完整路径。
672
+ create_nodes_blocked_reason: z.enum([
673
+ "anet_bin_pin_unresolved", // 兼容:.40 及更早
674
+ "anet_bin_identity",
675
+ "anet_bin_source",
676
+ "anet_bin_shape",
677
+ "anet_bin_permission",
678
+ "anet_bin_unknown",
679
+ ]).optional(),
680
+ // #1545 —— 上面那一格**是什么时候测出来的**。
681
+ //
682
+ // 🔴 版本号一律带包名 + 完整版本:仓里三个包各自独立编号,裸的 `preview.NN`
683
+ // 同时是三个不同的时间点。此处曾写成「preview.67」——
684
+ // 那是 agent-network 的号,agent-node 根本没有 .67(npm 404,最高 .56)。
685
+ // 🔴 为什么必须有:**agent-node ≤ 2.5.0-preview.54** 的 `daemonCreateCapability()`
686
+ // 用一个进程级缓存(`_createCapCache`)只算一次。开机时 pin 坏 ⇒ 之后
687
+ // 永远上报 blocked(哪怕运维已经写好 path.conf);开机时好 ⇒ 之后把二进制
688
+ // chmod 掉也**永远上报 ready**。后者是朝「没问题」方向说谎。
689
+ // 而 hub 这边,一个 3 秒前测的 blocked 和一个三周前测的 blocked,
690
+ // 在 `last_seen_at` 上长得一模一样 —— 心跳是新的,那一格不是。
691
+ //
692
+ // 🔴 为什么是「多久以前」(时长)而不是绝对时间戳:这个值来自**节点自己的钟**。
693
+ // 时钟偏移下,绝对时间戳会算出一个既可能"永远新鲜"也可能"来自 1970"的年龄,
694
+ // 而且错的方向不可预测。时长对偏移免疫 —— hub 用自己的钟在收到时换算。
695
+ //
696
+ // 🔴 为什么不加 `.min()/.max()/.int()`:**这几个约束都会让整条 report_status 被拒**
697
+ // (zod 对象里任何一个已知字段验证失败 = 整份被拒,不是丢掉这一格),
698
+ // 而这一格是纯诊断信息,不值得拿一台节点的在线状态去换。
699
+ // 收得宽,**在读取处消毒**(见 /api/host-supervisors)。
700
+ // 这条不是我新立的:同一个 schema 上方 `anet_bin_pin_unresolved` 那条注释
701
+ // 记的就是同一次教训 —— 删一个 enum 值会让所有 .40 daemon 失联。
702
+ //
703
+ // 🔴 `.catch(null)` 不是装饰:光写 `z.number()` **仍然会拒 NaN**
704
+ // (实测:`z.number().nullable().optional()` 对 NaN 抛),而那意味着
705
+ // 一次算错的时长能让整台节点在 hub 上失联 —— 正是 #1225 里 host.ip
706
+ // 那个形状。`.catch(null)` 让这一格**在任何输入下都不可能拒掉整份 report**:
707
+ // 合法数字透传,其余一律变 null(读取侧当作「没报」)。
708
+ // 类型仍然是 number 而不是 unknown —— 别给一个 attacker daemon
709
+ // 留下"往快照里塞任意大对象"的口子(同 schema 上方 soft cap 的立场)。
710
+ //
711
+ // 不上报这一格 ≠ 0。旧 daemon 压根不发,读的人必须能把
712
+ // 「刚测的」「很久以前测的」「不知道」分成三件事说。
713
+ create_capability_observed_ms_ago: z.number().nullable().catch(null).optional(),
714
+ }).optional(),
297
715
  }).optional().describe("RFC-024 — masked node config snapshot"),
298
716
  },
299
- 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 }) => {
717
+ 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 }) => {
300
718
  const effectiveNetId = getNetworkId(netId);
301
719
  const sessionNetId = effectiveNetId ?? "default";
302
720
  if (!callerTokenIsNetwork || !enforceNetworkId) {
@@ -334,6 +752,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
334
752
  };
335
753
  }
336
754
  }
755
+ // #203 identity guard — network tokens must not silently rebind their
756
+ // own name via report_status. Without this, a runtime whose ALIAS
757
+ // drifted (env leak / wrong --alias / CurrentAliasResolver seeded from
758
+ // the wrong node_id) could rewrite api_tokens.name and cause every
759
+ // subsequent send_task from this token to be attributed to the drifted
760
+ // alias — the observed #203 symptom (grokB's send arriving as
761
+ // from=grokA). Only the legit rename path (rename.ts) may cross the
762
+ // token→alias binding. Symmetric to fromIdentityMismatchReply on the
763
+ // send side (test198).
764
+ if (callerTokenIsNetwork && callerAlias && !canonical.renamed && effectiveAlias !== callerAlias) {
765
+ return {
766
+ content: [{
767
+ type: "text" as const,
768
+ text: JSON.stringify({
769
+ ok: false,
770
+ error: "alias_identity_mismatch",
771
+ message: "report_status alias does not match the token-bound node alias; use anet node rename to change identity",
772
+ token_alias: callerAlias,
773
+ reported_alias: effectiveAlias,
774
+ }),
775
+ }],
776
+ };
777
+ }
337
778
  console.log(`[${ts()}] ${effectiveAlias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
338
779
  if (callerTokenIsNetwork && callerTokenId) {
339
780
  try {
@@ -358,6 +799,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
358
799
  const processCpuPct = typeof proc?.cpu_pct === "number" ? proc.cpu_pct : null;
359
800
  const processUptimeSeconds = typeof proc?.uptime_seconds === "number" ? proc.uptime_seconds : null;
360
801
  const processInFlightCount = typeof proc?.in_flight_count === "number" ? proc.in_flight_count : null;
802
+ const externalSchedulesJson = externalSchedules === undefined ? null : JSON.stringify(externalSchedules);
803
+ const trustedCurrentSnapshot = node_id
804
+ ? trustedConfigSnapshotForNode(cfgSnap ?? null, callerTokenId ?? null, node_id)
805
+ : null;
806
+ const peerReplyInboxCapable = !!(
807
+ trustedCurrentSnapshot
808
+ && typeof trustedCurrentSnapshot === "object"
809
+ && (trustedCurrentSnapshot as Record<string, unknown>).peer_reply_inbox_capable === true
810
+ );
361
811
  const statusHostTelemetry = host ? {
362
812
  hostname: hostHostname,
363
813
  ip: hostIp,
@@ -380,10 +830,30 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
380
830
 
381
831
  db.transaction(() => {
382
832
  // Only delete same-alias sessions within the same network
833
+ // 同一 alias 可以有两个上报者轮流说话(agent-node 自己 `sdk-<node>` +
834
+ // grok 共存里 TUI 的 MCP `grok-cli-<node>`)。下面这条 DELETE 会把「另一个
835
+ // resume_id」的那行整行删掉,再 INSERT 一行新的 —— ON CONFLICT 里那一串
836
+ // COALESCE 在这条路径上一次都不会触发,于是上报里没带的列(version / agent /
837
+ // hostname / 六个遥测字段 / registered_at …)全部变 NULL。2026-09-04 DEV 真机:
838
+ // grok-v1 working 时 version=.64,7 秒后 idle 就成了 NULL,registered_at 被改成
839
+ // 那一秒。先把要被删的那行读出来,INSERT 之后把仍为 NULL 的列从它接手 —— 让「换 resume_id」
840
+ // 和「同 resume_id 更新」对描述性列的语义一致:上报了就覆盖,没上报就保留。
841
+ // 状态类列(status / task / output / progress / score)不接手,那是本次上报的事实。
842
+ const handover = db.get<Record<string, unknown>>(
843
+ `SELECT tmux_name, server, ip, hostname, agent, project_dir, version, node_id, session_id, config_path,
844
+ channels, model, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb,
845
+ disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct,
846
+ process_uptime_seconds, process_in_flight_count, external_schedules, registered_at
847
+ FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3
848
+ ORDER BY updated_at DESC LIMIT 1`,
849
+ effectiveAlias, resume_id, sessionNetId,
850
+ ) ?? null;
851
+ const keep = <T,>(fresh: T | null | undefined, column: string): T | null =>
852
+ (fresh ?? (handover?.[column] as T | null | undefined) ?? null);
383
853
  db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [effectiveAlias, resume_id, sessionNetId]);
384
854
  db.run(
385
- `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)
386
- 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'))
855
+ `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)
856
+ 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'))
387
857
  ON CONFLICT(resume_id) DO UPDATE SET
388
858
  alias = COALESCE(?2, sessions.alias), tmux_name = COALESCE(?3, sessions.tmux_name),
389
859
  server = COALESCE(?4, sessions.server), ip = COALESCE(?5, sessions.ip),
@@ -408,9 +878,41 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
408
878
  process_cpu_pct = COALESCE(?31, sessions.process_cpu_pct),
409
879
  process_uptime_seconds = COALESCE(?32, sessions.process_uptime_seconds),
410
880
  process_in_flight_count = COALESCE(?33, sessions.process_in_flight_count),
881
+ external_schedules = COALESCE(?34, sessions.external_schedules),
882
+ peer_reply_inbox_capable = ?35,
411
883
  last_seen_at = datetime('now'), updated_at = datetime('now')`,
412
- [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]
884
+ [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]
413
885
  );
886
+ if (handover) {
887
+ // 换 resume_id 那条路径上 ON CONFLICT 不会触发;INSERT 完再把没上报(仍为 NULL)的
888
+ // 描述性列从被替换的那行接手。INSERT 语句与参数列表保持原样 —— test698 的变异
889
+ // 用 sed 钉着它的字节形状。
890
+ db.run(
891
+ `UPDATE sessions SET
892
+ tmux_name = COALESCE(tmux_name, ?2), server = COALESCE(server, ?3), ip = COALESCE(ip, ?4),
893
+ hostname = COALESCE(hostname, ?5), agent = COALESCE(agent, ?6), project_dir = COALESCE(project_dir, ?7),
894
+ version = COALESCE(version, ?8), node_id = COALESCE(node_id, ?9), session_id = COALESCE(session_id, ?10),
895
+ config_path = COALESCE(config_path, ?11), channels = COALESCE(channels, ?12), model = COALESCE(model, ?13),
896
+ cpu_load_1min = COALESCE(cpu_load_1min, ?14), cpu_cores = COALESCE(cpu_cores, ?15),
897
+ mem_total_gb = COALESCE(mem_total_gb, ?16), mem_used_gb = COALESCE(mem_used_gb, ?17),
898
+ mem_avail_gb = COALESCE(mem_avail_gb, ?18), disk_total_gb = COALESCE(disk_total_gb, ?19),
899
+ disk_used_gb = COALESCE(disk_used_gb, ?20), disk_avail_gb = COALESCE(disk_avail_gb, ?21),
900
+ process_rss_bytes = COALESCE(process_rss_bytes, ?22), process_rss_mb = COALESCE(process_rss_mb, ?23),
901
+ process_cpu_pct = COALESCE(process_cpu_pct, ?24), process_uptime_seconds = COALESCE(process_uptime_seconds, ?25),
902
+ process_in_flight_count = COALESCE(process_in_flight_count, ?26),
903
+ external_schedules = COALESCE(external_schedules, ?27),
904
+ registered_at = COALESCE(?28, registered_at)
905
+ WHERE resume_id = ?1`,
906
+ [resume_id, handover.tmux_name ?? null, handover.server ?? null, handover.ip ?? null, handover.hostname ?? null,
907
+ handover.agent ?? null, handover.project_dir ?? null, handover.version ?? null, handover.node_id ?? null,
908
+ handover.session_id ?? null, handover.config_path ?? null, handover.channels ?? null, handover.model ?? null,
909
+ handover.cpu_load_1min ?? null, handover.cpu_cores ?? null, handover.mem_total_gb ?? null, handover.mem_used_gb ?? null,
910
+ handover.mem_avail_gb ?? null, handover.disk_total_gb ?? null, handover.disk_used_gb ?? null, handover.disk_avail_gb ?? null,
911
+ handover.process_rss_bytes ?? null, handover.process_rss_mb ?? null, handover.process_cpu_pct ?? null,
912
+ handover.process_uptime_seconds ?? null, handover.process_in_flight_count ?? null, handover.external_schedules ?? null,
913
+ handover.registered_at ?? null],
914
+ );
915
+ }
414
916
  if (host || proc) {
415
917
  db.run(
416
918
  `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)
@@ -459,6 +961,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
459
961
  upsertNodeWithSec1Guard({
460
962
  node_id,
461
963
  callerNetworkId: effectiveNetId ?? null,
964
+ callerUserId: enforceUserId ?? null,
965
+ callerTokenId: callerTokenId ?? null,
462
966
  node_name: nn || effectiveAlias,
463
967
  alias: effectiveAlias,
464
968
  runtime: nodeRuntime,
@@ -550,6 +1054,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
550
1054
  } else {
551
1055
  updatedTaskId = task;
552
1056
  }
1057
+ if (updatedTaskId) syncScheduledRunForTask(updatedTaskId, effectiveNetId);
553
1058
  });
554
1059
  // Log event after transaction
555
1060
  if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
@@ -610,7 +1115,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
610
1115
  const rows0 = db.get<{ cnt: number }>(countSql, ...countParams);
611
1116
  console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
612
1117
  const rowsParams: any[] = [alias];
613
- let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json
1118
+ let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json,
1119
+ CASE WHEN type = 'task' THEN COALESCE(task_id, id) ELSE task_id END AS task_id
614
1120
  FROM inbox WHERE session_name = ?1 AND acked = 0`;
615
1121
  rowsSql = addReadScope(rowsSql, rowsParams, readScope);
616
1122
  rowsSql += ` ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
@@ -634,13 +1140,32 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
634
1140
  alias: z.string().min(1).max(200).describe("Session alias"),
635
1141
  message_id: z.string().min(1).max(200),
636
1142
  response: z.string().max(10000).optional(),
1143
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
637
1144
  },
638
- async ({ alias, message_id, response }) => {
639
- const effectiveNetId = getNetworkId(null);
1145
+ async ({ alias, message_id, response, network_id: netId }) => {
1146
+ const effectiveNetId = getNetworkId(netId);
640
1147
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
641
1148
  console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
642
- const ackParams: any[] = [message_id, alias];
643
- let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2";
1149
+ // New task consumers ACK by the logical tasks.task_id exposed by
1150
+ // get_inbox. Keep exact inbox.id support for legacy consumers and for
1151
+ // non-task messages. Restrict the lookup to pending rows: after a retry
1152
+ // the original row can have id == task_id but is already ACKed, while
1153
+ // the current row has a fresh transport id and the same logical task_id.
1154
+ const inboxTaskParams: any[] = [message_id, alias];
1155
+ let inboxTaskSql = `SELECT id, type, COALESCE(task_id, id) AS task_id
1156
+ FROM inbox
1157
+ WHERE session_name = ?2 AND acked = 0
1158
+ AND (id = ?1 OR (type = 'task' AND task_id = ?1))`;
1159
+ inboxTaskSql = addScope(inboxTaskSql, inboxTaskParams, effectiveNetId);
1160
+ inboxTaskSql += " ORDER BY created_at DESC LIMIT 1";
1161
+ const inboxTask = db.get<{ id: string; type: string; task_id: string }>(inboxTaskSql, ...inboxTaskParams);
1162
+ if (!inboxTask) {
1163
+ return {
1164
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "message not found or already acknowledged" }) }],
1165
+ };
1166
+ }
1167
+ const ackParams: any[] = [inboxTask.id, alias];
1168
+ let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2 AND acked = 0";
644
1169
  ackSql = addScope(ackSql, ackParams, effectiveNetId);
645
1170
  const result = db.run(ackSql, ackParams);
646
1171
  if (result.changes === 0) {
@@ -650,11 +1175,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
650
1175
  }
651
1176
  // V2: sync tasks table — ack_inbox means delivered→acked
652
1177
  try {
653
- const taskParams: any[] = [message_id];
1178
+ if (inboxTask?.type !== "task") {
1179
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }] };
1180
+ }
1181
+ const taskParams: any[] = [inboxTask.task_id];
654
1182
  let taskSql = "UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'";
655
1183
  taskSql = addScope(taskSql, taskParams, effectiveNetId);
656
1184
  const ackResult = db.run(taskSql, taskParams);
657
- if (ackResult.changes > 0) logTaskEvent(message_id, "delivered", "acked", alias);
1185
+ if (ackResult.changes > 0) logTaskEvent(inboxTask.task_id, "delivered", "acked", alias);
658
1186
  } catch {}
659
1187
  return {
660
1188
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }],
@@ -662,22 +1190,208 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
662
1190
  }
663
1191
  );
664
1192
 
1193
+ // #520 — two monotonic runtime-evidence levels for exact tasks.
1194
+ //
1195
+ // runtime_submitted_at means agent-node handed the body to the vendor
1196
+ // runtime. consumed_at is stronger: an attributable turn-start or first
1197
+ // activity event came back. Merely fetching/acking an inbox row sets neither.
1198
+ // Node identity comes exclusively from the ntok; callers cannot self-report
1199
+ // an alias or node_id. A consumed mark also fills runtime_submitted_at because
1200
+ // that stronger fact logically implies submission.
1201
+ const markTaskRuntimeEvidence = (
1202
+ taskIds: string[],
1203
+ level: "submitted" | "consumed",
1204
+ taskContexts: Array<{ task_id: string; thread_id: string; turn_id: string }> = [],
1205
+ ) => {
1206
+ const task_ids = taskIds;
1207
+ if (!callerTokenIsNetwork || !callerAlias || !enforceNetworkId) {
1208
+ return {
1209
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_required" }) }],
1210
+ };
1211
+ }
1212
+ // PgAdapter.transaction currently opens a fresh subprocess/connection
1213
+ // per statement. The all-or-nothing batch promise cannot be made there;
1214
+ // refuse evidence rather than publish a partially stamped batch.
1215
+ if (db.dialect !== "sqlite") {
1216
+ return {
1217
+ content: [{ type: "text" as const, text: JSON.stringify({
1218
+ ok: false,
1219
+ error: "task_runtime_evidence_backend_unsupported",
1220
+ }) }],
1221
+ };
1222
+ }
1223
+
1224
+ const uniqueTaskIds = [...new Set(task_ids)];
1225
+ if (uniqueTaskIds.length !== task_ids.length) {
1226
+ return {
1227
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_id" }) }],
1228
+ };
1229
+ }
1230
+
1231
+ if (level !== "consumed" && taskContexts.length > 0) {
1232
+ return {
1233
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "runtime_context_requires_consumed" }) }],
1234
+ };
1235
+ }
1236
+ const contextByTaskId = new Map(taskContexts.map((context) => [context.task_id, context]));
1237
+ if (contextByTaskId.size !== taskContexts.length) {
1238
+ return {
1239
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_context" }) }],
1240
+ };
1241
+ }
1242
+ const foreignContext = taskContexts.find((context) => !uniqueTaskIds.includes(context.task_id));
1243
+ if (foreignContext) {
1244
+ return {
1245
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task_context_not_requested", task_id: foreignContext.task_id }) }],
1246
+ };
1247
+ }
1248
+
1249
+ const placeholders = uniqueTaskIds.map((_, i) => `?${i + 2}`).join(", ");
1250
+
1251
+ // Keep ownership preflight and every stamp in one SQLite transaction.
1252
+ // This closes the cross-process preflight→UPDATE race: no other writer
1253
+ // can reassign a task between the identity decision and the write.
1254
+ const outcome = db.transaction(() => {
1255
+ const canonicalCaller = resolveCanonicalAlias(enforceNetworkId, callerAlias).alias;
1256
+ const callerSession = db.get<{ node_id: string | null }>(
1257
+ `SELECT node_id FROM sessions
1258
+ WHERE network_id = ?1 AND alias = ?2
1259
+ ORDER BY updated_at DESC LIMIT 1`,
1260
+ enforceNetworkId,
1261
+ canonicalCaller,
1262
+ );
1263
+ const rows = db.all<{ task_id: string; to_node_id: string | null; to_name: string; thread_id: string | null; turn_id: string | null }>(
1264
+ `SELECT task_id, to_node_id, to_name, thread_id, turn_id FROM tasks
1265
+ WHERE network_id = ?1 AND task_id IN (${placeholders})`,
1266
+ enforceNetworkId,
1267
+ ...uniqueTaskIds,
1268
+ );
1269
+ const owned = new Map(rows.map((row) => [row.task_id, row]));
1270
+ const rejectedTaskId = uniqueTaskIds.find((taskId) => {
1271
+ const row = owned.get(taskId);
1272
+ if (!row) return true;
1273
+ // Prefer immutable node identity whenever the task has one. Direct
1274
+ // and legacy token-bound sessions legitimately have NULL node_id;
1275
+ // only that shape may fall back to the canonical token alias.
1276
+ if (row.to_node_id) return row.to_node_id !== callerSession?.node_id;
1277
+ return resolveCanonicalAlias(enforceNetworkId, row.to_name).alias !== canonicalCaller;
1278
+ });
1279
+ if (rejectedTaskId) {
1280
+ return { ok: false as const, error: "task_not_owned", task_id: rejectedTaskId };
1281
+ }
1282
+ const conflictingContext = uniqueTaskIds.find((taskId) => {
1283
+ const context = contextByTaskId.get(taskId);
1284
+ if (!context) return false;
1285
+ const row = owned.get(taskId)!;
1286
+ return (row.thread_id !== null && row.thread_id !== context.thread_id)
1287
+ || (row.turn_id !== null && row.turn_id !== context.turn_id);
1288
+ });
1289
+ if (conflictingContext) {
1290
+ return { ok: false as const, error: "task_runtime_context_conflict", task_id: conflictingContext };
1291
+ }
1292
+
1293
+ for (const taskId of uniqueTaskIds) {
1294
+ const row = owned.get(taskId)!;
1295
+ const ownershipSql = row.to_node_id
1296
+ ? "to_node_id = ?3"
1297
+ : "to_node_id IS NULL AND to_name = ?3";
1298
+ const ownerValue = row.to_node_id ?? row.to_name;
1299
+ let updateResult;
1300
+ if (level === "consumed") {
1301
+ const context = contextByTaskId.get(taskId);
1302
+ updateResult = db.run(
1303
+ `UPDATE tasks SET
1304
+ runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now')),
1305
+ consumed_at = COALESCE(consumed_at, datetime('now')),
1306
+ thread_id = COALESCE(thread_id, ?4),
1307
+ turn_id = COALESCE(turn_id, ?5)
1308
+ WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
1309
+ [enforceNetworkId, taskId, ownerValue, context?.thread_id ?? null, context?.turn_id ?? null],
1310
+ );
1311
+ } else {
1312
+ updateResult = db.run(
1313
+ `UPDATE tasks SET runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now'))
1314
+ WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
1315
+ [enforceNetworkId, taskId, ownerValue],
1316
+ );
1317
+ }
1318
+ if (updateResult.changes !== 1) {
1319
+ // A zero-row write after a successful preflight is an invariant
1320
+ // failure, never a successful evidence report.
1321
+ throw new Error(`task_runtime_evidence_write_race:${taskId}`);
1322
+ }
1323
+ }
1324
+ const evidenceRows = db.all<{
1325
+ task_id: string;
1326
+ runtime_submitted_at: string;
1327
+ consumed_at: string | null;
1328
+ thread_id: string | null;
1329
+ turn_id: string | null;
1330
+ }>(
1331
+ `SELECT task_id, runtime_submitted_at, consumed_at, thread_id, turn_id FROM tasks
1332
+ WHERE network_id = ?1 AND task_id IN (${placeholders})`,
1333
+ enforceNetworkId,
1334
+ ...uniqueTaskIds,
1335
+ );
1336
+ return { ok: true as const, tasks: evidenceRows };
1337
+ });
1338
+
1339
+ return {
1340
+ content: [{
1341
+ type: "text" as const,
1342
+ text: JSON.stringify(outcome),
1343
+ }],
1344
+ };
1345
+ };
1346
+
1347
+ const taskRuntimeEvidenceSchema = {
1348
+ task_ids: z.array(z.string().min(1).max(200)).min(1).max(100),
1349
+ task_contexts: z.array(z.object({
1350
+ task_id: z.string().min(1).max(200),
1351
+ thread_id: z.string().min(1).max(500),
1352
+ turn_id: z.string().min(1).max(500),
1353
+ }).strict()).max(100).optional(),
1354
+ };
1355
+
1356
+ server.tool(
1357
+ "mark_tasks_runtime_submitted",
1358
+ "Internal agent-node signal: exact task bodies were submitted to this token-bound node's vendor runtime.",
1359
+ taskRuntimeEvidenceSchema,
1360
+ async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "submitted", task_contexts),
1361
+ );
1362
+
1363
+ server.tool(
1364
+ "mark_tasks_consumed",
1365
+ "Internal agent-node signal: exact tasks produced attributable turn-start/activity evidence in this token-bound node's runtime.",
1366
+ taskRuntimeEvidenceSchema,
1367
+ async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "consumed", task_contexts),
1368
+ );
1369
+
665
1370
  // ═══════════════════════════════════════════
666
1371
  // Hub Tools (5)
667
1372
  // ═══════════════════════════════════════════
668
1373
 
669
1374
  server.tool(
670
1375
  "get_all_status",
671
- "Get status of all sessions. Hub uses this for the patrol loop.",
1376
+ "Get status of all sessions. Hub uses this for the patrol loop. " +
1377
+ "Pass filter_alias (comma-separated) when you only care about specific " +
1378
+ "nodes — the unfiltered result is one row per session with 31 columns and " +
1379
+ "is large enough on a real fleet that callers cannot read it.",
672
1380
  {
673
1381
  filter_status: z.string().max(50).optional(),
674
1382
  filter_server: z.string().max(200).optional(),
1383
+ // 2026-08-17: on a 222-session hub the unfiltered response is ~259 KB, past
1384
+ // what an MCP client can take in one result — so the caller that wanted the
1385
+ // status of THREE nodes could not get it from this tool at all. The patrol
1386
+ // loop still wants everything, hence optional rather than required.
1387
+ filter_alias: z.string().max(2000).optional()
1388
+ .describe("One alias, or several separated by commas. Exact matches only."),
675
1389
  network_id: z.string().max(200).optional().describe("Filter by network"),
676
1390
  },
677
- async ({ filter_status, filter_server, network_id: netId }) => {
1391
+ async ({ filter_status, filter_server, filter_alias, network_id: netId }) => {
678
1392
  const readScope = resolveReadScope(netId);
679
1393
  if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
680
- console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${readScope.networkId ? " net=" + readScope.networkId.slice(0, 12) : ""}`);
1394
+ 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) : ""}`);
681
1395
 
682
1396
  // Round-2/4 review ③: stale-marking moved to startStaleSessionSweeper()
683
1397
  // (background timer, ~60s cadence). Read path no longer fires UPDATE.
@@ -686,20 +1400,42 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
686
1400
  sql = addReadScope(sql, params, readScope);
687
1401
  if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
688
1402
  if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
1403
+ const aliasFilter = parseAliasFilter(filter_alias);
1404
+ const aliases = aliasFilter.aliases;
1405
+ if (aliasFilter.sql) {
1406
+ sql += aliasFilter.sql;
1407
+ params.push(...aliases);
1408
+ }
689
1409
  sql += " ORDER BY updated_at DESC";
690
1410
  const sessions = db.all(sql, ...params);
691
1411
 
1412
+ // `summary` has always counted every session in the read scope, ignoring
1413
+ // filter_status / filter_server — and now filter_alias. That is fine for
1414
+ // the patrol loop, but a caller who asked about three aliases and gets
1415
+ // back three rows plus "idle: 96" can easily read the 96 as being about
1416
+ // their three. A count that does not say what it counted invites exactly
1417
+ // that. So the response now says so, rather than the semantics changing
1418
+ // under existing callers.
692
1419
  const summaryParams: any[] = [];
693
1420
  let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
694
1421
  summarySql = addReadScope(summarySql, summaryParams, readScope);
695
1422
  summarySql += " GROUP BY status";
696
1423
  const summary = db.all(summarySql, ...summaryParams);
697
1424
 
1425
+ const filtered = !!(filter_status || filter_server || aliases.length > 0);
698
1426
  return {
699
1427
  content: [
700
1428
  {
701
1429
  type: "text" as const,
702
- text: JSON.stringify({ ok: true, sessions, summary }),
1430
+ text: JSON.stringify({
1431
+ ok: true,
1432
+ sessions,
1433
+ summary,
1434
+ summary_scope: filtered
1435
+ ? "every session in the read scope — NOT narrowed by the filters applied to `sessions`"
1436
+ : "every session in the read scope",
1437
+ sessions_returned: sessions.length,
1438
+ }),
703
1439
  },
704
1440
  ],
705
1441
  };
@@ -757,7 +1493,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
757
1493
  },
758
1494
  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);
759
1495
  const effectiveNetId = getNetworkId(netId);
760
- const metaJson = normalizeMetaJson(meta);
1496
+ const authOrigin: TaskAuthOrigin = callerTokenIsNetwork
1497
+ ? "node"
1498
+ : enforceUserId
1499
+ ? "user"
1500
+ : "legacy";
1501
+ const metaJson = normalizeMetaJson(stampTaskAuthOrigin(meta, authOrigin));
761
1502
 
762
1503
  // Role check FIRST — round5 follow-up (通信牛 oracle catch):
763
1504
  // the explicit-parent verification below distinguishes
@@ -832,6 +1573,45 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
832
1573
  const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
833
1574
  if (target.state === "not_found") return deliveryTargetReply(target)!;
834
1575
 
1576
+ // Dashboard sends carry a random client_request_id inside meta. Derive
1577
+ // the task primary key from authenticated scope + that request id so a
1578
+ // lost HTTP response can be retried safely, even after a hub restart.
1579
+ // The existing row must match the full request; key reuse with changed
1580
+ // content/target fails closed instead of silently returning another task.
1581
+ const clientRequestId = clientRequestIdFromMeta(meta);
1582
+ const id = clientRequestId
1583
+ ? idempotentTaskId(effectiveNetId ?? null, from_session, clientRequestId)
1584
+ : uuidv4();
1585
+ if (clientRequestId) {
1586
+ const existing = db.get<StoredIdempotentTask & { to_node_id: string | null }>(
1587
+ "SELECT task_id, from_name, to_node_id, to_name, priority, content, network_id, meta_json, status FROM tasks WHERE task_id = ?1",
1588
+ [id],
1589
+ );
1590
+ if (existing) {
1591
+ if (!idempotentTaskMatches(existing, {
1592
+ fromName: from_session, toName: targetAlias, priority, content: task,
1593
+ networkId: effectiveNetId ?? null, metaJson,
1594
+ })) {
1595
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1596
+ ok: false, error: "idempotency_conflict",
1597
+ message: "client_request_id was already used with a different task payload",
1598
+ }) }] };
1599
+ }
1600
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1601
+ ok: true, message_id: existing.task_id, task_id: existing.task_id,
1602
+ task_status: existing.status, idempotent_replay: true,
1603
+ // A replay acknowledges the already-persisted dispatch. Use that
1604
+ // row rather than today's session record so alias reuse or a
1605
+ // restarted process cannot rewrite historical recipient identity.
1606
+ actual_to: {
1607
+ alias: existing.to_name,
1608
+ to_node_id: existing.to_node_id ?? null,
1609
+ network_id: existing.network_id ?? null,
1610
+ },
1611
+ }) }] };
1612
+ }
1613
+ }
1614
+
835
1615
  // #212 dedup guardrail. If this exact (from, to, content) has already
836
1616
  // been delivered within COMMHUB_SEND_DEDUP_WINDOW_MS (default 5 min)
837
1617
  // we refuse the call and surface a structured `duplicate_send`
@@ -852,18 +1632,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
852
1632
  return { content: [{ type: "text" as const, text: JSON.stringify(payload) }] };
853
1633
  }
854
1634
 
1635
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 1/6).
1636
+ {
1637
+ const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
1638
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1639
+ }
1640
+
855
1641
  console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
856
- const id = uuidv4();
857
1642
  const fromNodeId = resolveNodeIdForAlias(from_session, effectiveNetId);
858
1643
  const targetNodeId = target.session?.node_id ?? null;
1644
+ const resolvedActualTo = actualTo(target, effectiveNetId);
859
1645
  // 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
860
1646
  // dashboard 在派任务一刻就反映出"任务已下发",不再等 agent 的
861
1647
  // report_status 心跳;status 字段交给 agent,避免与 working/idle
862
1648
  // 报告冲突)。
863
1649
  db.transaction(() => {
864
1650
  db.run(
865
- `INSERT INTO inbox (id, session_name, node_id, type, priority, content, context, from_session, requires_response, network_id, meta_json)
866
- VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, ?7, 'reply', ?8, ?9)`,
1651
+ `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, context, from_session, requires_response, network_id, meta_json)
1652
+ VALUES (?1, ?1, ?2, ?3, 'task', ?4, ?5, ?6, ?7, 'reply', ?8, ?9)`,
867
1653
  [id, targetAlias, targetNodeId, priority, task, context ?? null, from_session, effectiveNetId ?? null, metaJson]
868
1654
  );
869
1655
  db.run(
@@ -875,6 +1661,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
875
1661
  let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
876
1662
  touchSql = addScope(touchSql, touchParams, effectiveNetId);
877
1663
  db.run(touchSql, touchParams);
1664
+ // #1548 —— 能派活的发送方不可能真的 blocked;此处出 blocked → idle。
1665
+ releaseBlockedSession(from_session, effectiveNetId);
878
1666
  });
879
1667
  logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${targetAlias} (parent=${parentTaskId.slice(0,8)})` : `→ ${targetAlias}`);
880
1668
  // Only stamp the dedup index after the inbox/tasks transaction
@@ -893,9 +1681,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
893
1681
  let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
894
1682
  pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
895
1683
  const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
896
- if (target.state === "online") {
897
- pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
898
- }
1684
+ // 🔴 不要用 last_seen_at 的时间戳去猜「有没有人在听」——SSE 订阅者注册表
1685
+ // 就是那件事本身。pushEvent 在没有订阅者时已经是 no-op,所以这道闸不提供
1686
+ // 任何保护,只会把**连着但最近没心跳**的会话排除掉;而全网「停发心跳/状态类
1687
+ // 消息」的省额度规则让安静恰恰是常态。上面那段注释早就写着 "Push
1688
+ // unconditionally",同一类缺陷上次也是打在 Dashboard 任务上(f015d9d6:
1689
+ // 任务标了 delivered,会话却从未收到推送)。
1690
+ pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
1691
+ const actuallyDelivered = hasSubscribers(targetAlias, effectiveNetId);
1692
+ // #461 network observer summary — unconditional (task row exists
1693
+ // even when the target is offline/queued), metadata only.
1694
+ pushNetworkObserverEvent(effectiveNetId, { type: "new_task", task_id: id, from: from_session, to: targetAlias, status: actuallyDelivered ? "delivered" : "queued", priority });
899
1695
 
900
1696
  if (target.state === "offline") {
901
1697
  return {
@@ -910,6 +1706,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
910
1706
  task_id: id,
911
1707
  message_id: id,
912
1708
  session_status: target.session.status ?? "offline",
1709
+ actual_to: resolvedActualTo,
913
1710
  ...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
914
1711
  }),
915
1712
  }],
@@ -923,6 +1720,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
923
1720
  text: JSON.stringify({
924
1721
  ok: true,
925
1722
  message_id: id,
1723
+ actual_to: resolvedActualTo,
926
1724
  ...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
927
1725
  session_status: target.session?.status ?? "unknown",
928
1726
  }),
@@ -939,14 +1737,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
939
1737
  alias: z.string().min(1).max(200).describe("Target session alias"),
940
1738
  message: z.string().min(1).max(10000).describe("Message content"),
941
1739
  from_session: z.string().max(200).optional(),
1740
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
942
1741
  },
943
- async ({ alias, message, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
944
- const effectiveNetId = getNetworkId(null);
1742
+ async ({ alias, message, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1743
+ const effectiveNetId = getNetworkId(netId);
945
1744
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
946
1745
  const canonical = resolveCanonicalAlias(effectiveNetId, alias);
947
1746
  const targetAlias = canonical.alias;
948
1747
  const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
949
1748
  if (target.state === "not_found") return deliveryTargetReply(target)!;
1749
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 2/6).
1750
+ {
1751
+ const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
1752
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1753
+ }
950
1754
  console.log(`[${ts()}] ${from_session} → send_message → ${targetAlias}: ${message.slice(0, 60)}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
951
1755
  const id = uuidv4();
952
1756
  db.run(
@@ -955,9 +1759,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
955
1759
  [id, targetAlias, target.session?.node_id ?? null, message, from_session, effectiveNetId ?? null]
956
1760
  );
957
1761
 
958
- if (target.state === "online") {
959
- pushEvent(targetAlias, { type: "new_message", from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
960
- }
1762
+ // 同 send_task:可达性用订阅者注册表判,不用心跳时间戳猜。
1763
+ pushEvent(targetAlias, { type: "new_message", inbox_count: pendingInboxCount(targetAlias, effectiveNetId), from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
961
1764
 
962
1765
  const offlineReply = deliveryTargetReply(target, { message_id: id });
963
1766
  if (offlineReply) {
@@ -985,96 +1788,270 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
985
1788
  }
986
1789
  );
987
1790
 
988
- // ── V2: send_reply (关联 task_id,不触发 think) ──
989
- server.tool(
990
- "send_reply",
991
- "Send a reply to a task. Linked to task_id via in_reply_to. Does NOT trigger agent processing.",
992
- {
993
- alias: z.string().min(1).max(200).describe("Target session alias"),
1791
+ // ── V2/V3 reply primitives ──
1792
+ const replyToolSchema = {
1793
+ // #1085 — alias is OPTIONAL: when omitted (or empty) and `in_reply_to`
1794
+ // is set, the hub derives the target from the original task's
1795
+ // `from_name` (it already SELECTs that task by in_reply_to below).
1796
+ // The SDK-runtime bridge (agent-node/src/commhub-mcp.ts) exposes
1797
+ // send_reply as (task_id, text, status) with NO alias, so before this
1798
+ // it always sent alias=undefined → -32602 "expected string, received
1799
+ // undefined at alias". The target was already knowable from the task,
1800
+ // so requiring the caller to pass it was a redundant-param trap.
1801
+ alias: z.string().min(1).max(200).optional().describe("Target session alias — optional; derived from in_reply_to's task sender when omitted"),
994
1802
  text: z.string().min(1).max(10000).describe("Reply content"),
995
1803
  in_reply_to: z.string().max(200).optional().describe("Original task/message ID"),
996
1804
  status: z.enum(["replied", "failed", "cancelled"]).optional().default("replied").describe("Task outcome"),
997
1805
  from_session: z.string().max(200).optional(),
998
- },
999
- async ({ alias, text, in_reply_to, status: replyStatus, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1000
- const effectiveNetId = getNetworkId(null);
1806
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1807
+ // #507 — top-level attachments (parity with REST /api/task L506). MCP Zod
1808
+ // otherwise silently strips unknown fields, so a caller passing
1809
+ // `attachments` before this schema entry existed would see `ok:true`
1810
+ // and never learn the attachments were dropped. Validated by
1811
+ // validateAttachments (uploads.ts) — the same helper the REST path uses.
1812
+ 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."),
1813
+ // #507 — optional structured metadata (parity with send_task L837). If
1814
+ // both `attachments` (top-level) and `meta.attachments` are supplied,
1815
+ // top-level wins (same rule as REST /api/task L2101).
1816
+ meta: z.any().optional().describe("Optional structured reply metadata, e.g. { attachments: [...] }."),
1817
+ };
1818
+ const handleReply = async (args: any, peerCapabilityRequired: boolean) => {
1819
+ const { alias, text, in_reply_to, status: replyStatus = "replied", from_session: _fromIn, network_id: netId, attachments, meta } = args;
1820
+ const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1821
+ const effectiveNetId = getNetworkId(netId);
1001
1822
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1002
- console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
1003
- const id = uuidv4();
1004
- let taskBefore: { status: string } | null = null;
1005
- if (in_reply_to) {
1006
- const taskParams: any[] = [in_reply_to];
1007
- let taskSql = "SELECT status FROM tasks WHERE task_id = ?1";
1008
- taskSql = addScope(taskSql, taskParams, effectiveNetId);
1009
- taskBefore = db.get<{ status: string }>(taskSql, ...taskParams) ?? null;
1010
- if (!taskBefore) {
1011
- return {
1012
- content: [{
1013
- type: "text" as const,
1014
- text: JSON.stringify({
1015
- ok: false,
1016
- error: "reply_task_not_found",
1017
- message: `cannot apply reply: task not found (${in_reply_to})`,
1018
- in_reply_to,
1019
- reply_queued: false,
1020
- }),
1021
- }],
1022
- };
1823
+ if (peerCapabilityRequired && !in_reply_to) {
1824
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "peer_reply_task_required" }) }] };
1825
+ }
1826
+
1827
+ // #507 — validate attachments BEFORE any DB write. Rejects malformed
1828
+ // input (bad file_id, >20 items, size > cap, non-object item, wrong
1829
+ // type field) with an explicit error the caller can act on. Empty /
1830
+ // absent attachments return { ok: true, attachments: [] } — the
1831
+ // reverse-(e) invariant (no attachments → behavior unchanged) is
1832
+ // pinned by tests that assert byte-identical response shape
1833
+ // before/after this validation runs.
1834
+ const attachmentsResult = validateAttachments(attachments ?? (meta && typeof meta === "object" ? (meta as any).attachments : undefined));
1835
+ if (!attachmentsResult.ok) {
1836
+ return {
1837
+ content: [{
1838
+ type: "text" as const,
1839
+ text: JSON.stringify({
1840
+ ok: false,
1841
+ error: "bad_attachments",
1842
+ message: attachmentsResult.error,
1843
+ }),
1844
+ }],
1845
+ };
1846
+ }
1847
+ const mergedMeta = attachmentsResult.attachments.length
1848
+ ? { ...(meta && typeof meta === "object" ? meta : {}), attachments: attachmentsResult.attachments }
1849
+ : meta;
1850
+ const metaJson = normalizeMetaJson(mergedMeta);
1851
+
1852
+ // #1085 — derive the reply target when the caller omitted `alias`.
1853
+ // The original task's `from_name` IS the reply target; the hub knows
1854
+ // it from `in_reply_to`. Without this, an SDK-runtime send_reply
1855
+ // (schema has no alias) died with -32602 before reaching this handler.
1856
+ let effectiveAlias: string | undefined =
1857
+ (typeof alias === "string" && alias.trim()) ? alias : undefined;
1858
+ if (!effectiveAlias) {
1859
+ if (!in_reply_to) {
1860
+ 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" }) }] };
1023
1861
  }
1024
- if (!["created", "delivered", "acked", "running"].includes(taskBefore.status)) {
1025
- return {
1026
- content: [{
1027
- type: "text" as const,
1028
- text: JSON.stringify({
1029
- ok: false,
1030
- error: "reply_task_terminal",
1031
- message: `cannot apply reply: task is already terminal (${taskBefore.status})`,
1032
- in_reply_to,
1033
- task_status: taskBefore.status,
1034
- reply_queued: false,
1035
- }),
1036
- }],
1037
- };
1862
+ const derivParams: any[] = [in_reply_to];
1863
+ let derivSql = "SELECT from_name FROM tasks WHERE task_id = ?1";
1864
+ derivSql = addScope(derivSql, derivParams, effectiveNetId);
1865
+ const derived = db.get<{ from_name: string }>(derivSql, ...derivParams);
1866
+ if (!derived?.from_name) {
1867
+ 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})` }) }] };
1038
1868
  }
1869
+ effectiveAlias = derived.from_name;
1870
+ }
1871
+
1872
+ const canonicalReplyTarget = resolveCanonicalAlias(effectiveNetId, effectiveAlias);
1873
+ const replyTargetAlias = canonicalReplyTarget.alias;
1874
+ 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}]` : ""}`);
1875
+ const id = uuidv4();
1876
+ const replyTargetNodeId = resolveNodeIdForAlias(replyTargetAlias, effectiveNetId);
1877
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 3/6).
1878
+ {
1879
+ const lc = assertNodeActive(replyTargetAlias, effectiveNetId ?? null);
1880
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1039
1881
  }
1040
- const replyTargetNodeId = resolveNodeIdForAlias(alias, effectiveNetId);
1041
- const replyLogged = db.transaction(() => {
1882
+ const replyOutcome = db.transaction(() => {
1883
+ type ReplyTask = {
1884
+ status: string;
1885
+ from_node_id: string | null;
1886
+ from_name: string;
1887
+ to_name: string;
1888
+ to_node_id: string | null;
1889
+ };
1890
+ let taskBefore: ReplyTask | null = null;
1891
+ let callerNodeId: string | null = null;
1892
+ if (in_reply_to) {
1893
+ const taskParams: any[] = [in_reply_to];
1894
+ let taskSql = "SELECT status, from_node_id, from_name, to_name, to_node_id FROM tasks WHERE task_id = ?1";
1895
+ taskSql = addScope(taskSql, taskParams, effectiveNetId);
1896
+ taskBefore = db.get<ReplyTask>(taskSql, ...taskParams) ?? null;
1897
+ if (!taskBefore) return { ok: false as const, error: "reply_task_not_found" as const };
1898
+ if (!["created", "delivered", "acked", "running"].includes(taskBefore.status)) {
1899
+ return { ok: false as const, error: "reply_task_terminal" as const, taskStatus: taskBefore.status };
1900
+ }
1901
+
1902
+ // V3 atomic peer replies are capability-negotiated. Both identity
1903
+ // bindings and the recipient capability are re-read inside this
1904
+ // transaction before any inbox/task/run write. Legacy/unbound rows
1905
+ // fail toward compatibility send_reply, which terminalizes the
1906
+ // original task instead of creating a second response-requiring row.
1907
+ if (peerCapabilityRequired) {
1908
+ if (!taskBefore.from_node_id) {
1909
+ // Origin type is an intrinsic property of the task and must be
1910
+ // decided before caller capability. A Dashboard/human task still
1911
+ // needs the established send_reply terminalization path when the
1912
+ // replying node uses a legacy/unbound token.
1913
+ return { ok: false as const, error: "peer_reply_origin_not_node" as const };
1914
+ }
1915
+ if (!callerTokenIsNetwork || !callerTokenId || !enforceNetworkId) {
1916
+ return { ok: false as const, error: "peer_reply_node_token_required" as const };
1917
+ }
1918
+ const token = db.get<{ bound_node_id: string | null }>(
1919
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
1920
+ callerTokenId,
1921
+ enforceNetworkId,
1922
+ );
1923
+ if (!token?.bound_node_id || !taskBefore.to_node_id) {
1924
+ return { ok: false as const, error: "peer_reply_unsupported" as const };
1925
+ }
1926
+ if (token.bound_node_id !== taskBefore.to_node_id) {
1927
+ return { ok: false as const, error: "reply_task_not_owned" as const };
1928
+ }
1929
+ callerNodeId = token.bound_node_id;
1930
+ const canonicalTarget = replyTargetAlias;
1931
+ const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, taskBefore.from_name).alias;
1932
+ if (canonicalTarget !== canonicalOrigin) {
1933
+ return { ok: false as const, error: "reply_target_mismatch" as const };
1934
+ }
1935
+ const recipient = db.get<{ peer_reply_inbox_capable: number }>(
1936
+ `SELECT peer_reply_inbox_capable FROM sessions
1937
+ WHERE node_id = ?1 AND network_id = ?2 AND alias = ?3
1938
+ ORDER BY updated_at DESC LIMIT 1`,
1939
+ taskBefore.from_node_id,
1940
+ enforceNetworkId,
1941
+ canonicalOrigin,
1942
+ );
1943
+ if (recipient?.peer_reply_inbox_capable !== 1) {
1944
+ return { ok: false as const, error: "peer_reply_unsupported" as const };
1945
+ }
1946
+ } else if (callerTokenIsNetwork && enforceNetworkId) {
1947
+ // Compatibility send_reply is still an exact task transition for
1948
+ // node callers. Bind it to the token-authenticated assignee alias
1949
+ // and immutable original sender. Alias binding survives a
1950
+ // legitimate node-id rotation without letting another node close
1951
+ // the task.
1952
+ const canonicalCaller = resolveCanonicalAlias(enforceNetworkId, from_session).alias;
1953
+ const canonicalAssignee = resolveCanonicalAlias(enforceNetworkId, taskBefore.to_name).alias;
1954
+ if (canonicalCaller !== canonicalAssignee) {
1955
+ return { ok: false as const, error: "reply_task_not_owned" as const };
1956
+ }
1957
+ const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, taskBefore.from_name).alias;
1958
+ if (replyTargetAlias !== canonicalOrigin) {
1959
+ return { ok: false as const, error: "reply_target_mismatch" as const };
1960
+ }
1961
+ }
1962
+
1963
+ }
1964
+
1965
+ // #507 — write meta_json on inbox insert (parity with send_task L952).
1966
+ // Prior to this the attachments field on a send_reply call was
1967
+ // silently stripped by MCP Zod, leaving `ok:true` with no persisted
1968
+ // meta.
1042
1969
  db.run(
1043
- `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
1044
- VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7)`,
1045
- [id, alias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null]
1970
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id, meta_json)
1971
+ VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7, ?8)`,
1972
+ [id, replyTargetAlias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null, metaJson]
1046
1973
  );
1047
1974
 
1048
1975
  // 更新 tasks 表
1049
1976
  if (in_reply_to) {
1050
- const updateParams: any[] = [replyStatus, text, in_reply_to];
1051
- let updateSql = `UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now')
1052
- WHERE task_id = ?3 AND status IN ('created', 'delivered', 'acked', 'running')`;
1977
+ // #507 — persist meta_json onto the tasks row too, so
1978
+ // dashboard's task view sees the reply's attachments alongside
1979
+ // the reply text (parity with send_task L957/959 which writes
1980
+ // meta_json into both inbox and tasks). The COALESCE guards
1981
+ // against clobbering pre-existing meta_json when this reply
1982
+ // brings no attachments (metaJson === null → keep the existing
1983
+ // task meta_json unchanged). Reverse-(e) invariant.
1984
+ // #1823 —— 回复的附件写到 tasks.meta_json.reply_attachments,**不碰** meta_json.attachments:
1985
+ // 那个键是提问者随任务带的附件,客户端把它画在提问者的气泡里。以前这里用回复的 metaJson
1986
+ // 整体 COALESCE 替换,于是 agent 回的图出现在提问者气泡下面、提问者自己带的附件被覆盖
1987
+ // (Vincent 2026-09-06 实测「好像没成功发送给我」)。inbox 行(收件方视角)照旧写 metaJson。
1988
+ const replyTaskMetaJson = (() => {
1989
+ if (!attachmentsResult.attachments.length) return null;
1990
+ const rowParams: any[] = [in_reply_to];
1991
+ let rowSql = "SELECT meta_json FROM tasks WHERE task_id = ?1";
1992
+ rowSql = addScope(rowSql, rowParams, effectiveNetId);
1993
+ const existing = db.get<{ meta_json: string | null }>(rowSql, ...rowParams)?.meta_json ?? null;
1994
+ let base: Record<string, unknown> = {};
1995
+ if (existing) { try { const parsed = JSON.parse(existing); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) base = parsed; } catch {} }
1996
+ return JSON.stringify({ ...base, reply_attachments: attachmentsResult.attachments });
1997
+ })();
1998
+ const updateParams: any[] = [replyStatus, text, replyTaskMetaJson, in_reply_to];
1999
+ let updateSql = `UPDATE tasks
2000
+ SET status = ?1,
2001
+ result = ?2,
2002
+ completed_at = datetime('now'),
2003
+ meta_json = COALESCE(?3, meta_json)
2004
+ WHERE task_id = ?4 AND status IN ('created', 'delivered', 'acked', 'running')`;
2005
+ if (peerCapabilityRequired && callerTokenIsNetwork && taskBefore) {
2006
+ if (taskBefore.to_node_id) {
2007
+ updateParams.push(callerNodeId);
2008
+ updateSql += ` AND to_node_id = ?${updateParams.length}`;
2009
+ } else {
2010
+ updateParams.push(taskBefore.to_name);
2011
+ updateSql += ` AND to_node_id IS NULL AND to_name = ?${updateParams.length}`;
2012
+ }
2013
+ }
1053
2014
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1054
2015
  const result = db.run(updateSql, updateParams);
1055
2016
  if (result.changes === 0) {
1056
- console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
1057
- return false;
2017
+ throw new Error(`reply_atomic_cas_failed:${in_reply_to}`);
1058
2018
  }
1059
- return true;
2019
+ syncScheduledRunForTask(in_reply_to, effectiveNetId);
2020
+ // #1548 —— 终态回复是最强的活性证据:一个还标着 blocked 的发送方在此回到 idle。
2021
+ // 只出 blocked;working 可能真在忙别的任务,不碰。
2022
+ releaseBlockedSession(from_session, effectiveNetId);
2023
+ return { ok: true as const, replyLogged: true };
1060
2024
  }
1061
- return false;
2025
+ return { ok: true as const, replyLogged: false };
1062
2026
  });
1063
2027
 
1064
- if (in_reply_to && !replyLogged) {
2028
+ if (!replyOutcome.ok) {
2029
+ const taskStatus = "taskStatus" in replyOutcome ? replyOutcome.taskStatus : undefined;
2030
+ const messages: Record<string, string> = {
2031
+ reply_task_not_found: `cannot apply reply: task not found (${in_reply_to})`,
2032
+ reply_task_terminal: `cannot apply reply: task is already terminal (${taskStatus})`,
2033
+ peer_reply_node_token_required: "atomic peer reply requires a node token",
2034
+ peer_reply_unsupported: "recipient or caller does not support atomic peer replies",
2035
+ peer_reply_origin_not_node: "original task sender is not a node; use send_reply",
2036
+ reply_node_identity_unbound: "cannot apply reply: node identity is not token-bound",
2037
+ reply_task_not_owned: "cannot apply reply: task is not owned by this node",
2038
+ reply_target_mismatch: "cannot apply reply: target is not the original task sender",
2039
+ };
1065
2040
  return {
1066
2041
  content: [{
1067
2042
  type: "text" as const,
1068
2043
  text: JSON.stringify({
1069
2044
  ok: false,
1070
- error: "reply_not_applied",
1071
- message: "reply was not applied to task",
2045
+ error: replyOutcome.error,
2046
+ message: messages[replyOutcome.error],
1072
2047
  in_reply_to,
2048
+ ...(taskStatus ? { task_status: taskStatus } : {}),
1073
2049
  reply_queued: false,
1074
2050
  }),
1075
2051
  }],
1076
2052
  };
1077
2053
  }
2054
+ const replyLogged = replyOutcome.replyLogged;
1078
2055
 
1079
2056
  // Log event after commit (outside transaction)
1080
2057
  if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
@@ -1111,16 +2088,71 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1111
2088
  }
1112
2089
  }
1113
2090
 
1114
- const session = scopedSessionStatus(alias, effectiveNetId);
1115
- pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
2091
+ const session = scopedSessionStatus(replyTargetAlias, effectiveNetId);
2092
+ pushEvent(replyTargetAlias, { type: "new_reply", inbox_count: pendingInboxCount(replyTargetAlias, effectiveNetId), from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
2093
+ // #461 network observer summary — ids + routing only, no reply text.
2094
+ pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to ?? null, message_id: id, from: from_session, to: replyTargetAlias, status: replyStatus });
2095
+
2096
+ // #498 compatibility tripwire. Legacy send_reply is intentionally kept
2097
+ // for old agents and Dashboard callers during rollout, but it is not the
2098
+ // capability-negotiated atomic peer primitive. Warn only for an actual
2099
+ // agent target; Dashboard hub/api replies remain quiet. New agents call
2100
+ // send_peer_reply and never see this warning.
2101
+ const targetIsAgent = replyTargetNodeId !== null
2102
+ && replyTargetAlias !== "hub"
2103
+ && replyTargetAlias !== "api";
2104
+ const warning = !peerCapabilityRequired && targetIsAgent
2105
+ ? `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.)`
2106
+ : undefined;
2107
+
2108
+ // #507 — echo attachments READ BACK FROM DB (lead 2b5f6634): the point
2109
+ // of the echo is to prove attachments actually landed in storage, not
2110
+ // to reflect the in-memory variable we tried to write. Reading
2111
+ // `attachmentsResult.attachments` here would still show `ok:true` +
2112
+ // full echo if the UPDATE failed with `changes=0` (e.g. task moved to
2113
+ // terminal between the pre-check and the transaction). SELECT from
2114
+ // inbox.meta_json (always written, uses the id we just generated) so
2115
+ // the echo means "these attachments are on disk now". Absent field
2116
+ // when the caller sent no attachments — reverse-(e) invariant: a
2117
+ // no-attachment call returns the exact same response shape as before
2118
+ // this PR. Persisted-attachments field is only added when the caller
2119
+ // actually asked for attachments.
2120
+ let attachmentsSaved: unknown[] | null = null;
2121
+ if (attachmentsResult.attachments.length > 0) {
2122
+ const persistedRow = db.get<{ meta_json: string | null }>(
2123
+ "SELECT meta_json FROM inbox WHERE id = ?1", [id]
2124
+ );
2125
+ const persistedMeta = parseMetaJson(persistedRow?.meta_json ?? null);
2126
+ attachmentsSaved = persistedMeta && typeof persistedMeta === "object" && Array.isArray((persistedMeta as any).attachments)
2127
+ ? (persistedMeta as any).attachments
2128
+ : [];
2129
+ }
1116
2130
 
1117
2131
  return {
1118
2132
  content: [{
1119
2133
  type: "text" as const,
1120
- text: JSON.stringify({ ok: true, message_id: id, session_status: session?.status ?? "unknown" }),
2134
+ text: JSON.stringify({
2135
+ ok: true,
2136
+ message_id: id,
2137
+ session_status: session?.status ?? "unknown",
2138
+ ...(warning ? { warning } : {}),
2139
+ ...(attachmentsSaved !== null ? { attachments_saved: attachmentsSaved } : {}),
2140
+ }),
1121
2141
  }],
1122
2142
  };
1123
- }
2143
+ };
2144
+
2145
+ server.tool(
2146
+ "send_reply",
2147
+ "Reply to a Dashboard/UI-originated task. Also serves as the terminal legacy fallback when atomic peer reply capability is unavailable.",
2148
+ replyToolSchema,
2149
+ (args) => handleReply(args, false),
2150
+ );
2151
+ server.tool(
2152
+ "send_peer_reply",
2153
+ "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.",
2154
+ replyToolSchema,
2155
+ (args) => handleReply(args, true),
1124
2156
  );
1125
2157
 
1126
2158
  // ── V2: send_ack (不入 inbox,仅更新状态) ──
@@ -1130,9 +2162,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1130
2162
  {
1131
2163
  task_id: z.string().min(1).max(200).describe("Task ID to acknowledge"),
1132
2164
  from_session: z.string().max(200).optional(),
2165
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1133
2166
  },
1134
- async ({ task_id, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1135
- const effectiveNetId = getNetworkId(null);
2167
+ async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2168
+ const effectiveNetId = getNetworkId(netId);
1136
2169
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1137
2170
  console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
1138
2171
  const updateParams: any[] = [task_id];
@@ -1156,9 +2189,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1156
2189
  {
1157
2190
  task_id: z.string().min(1).max(200).describe("Task ID to retry"),
1158
2191
  from_session: z.string().max(200).optional(),
2192
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1159
2193
  },
1160
- async ({ task_id, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1161
- const effectiveNetId = getNetworkId(null);
2194
+ async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2195
+ const effectiveNetId = getNetworkId(netId);
1162
2196
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1163
2197
  console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
1164
2198
  // Find the original task
@@ -1172,6 +2206,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1172
2206
  if (!["failed", "expired", "cancelled"].includes(task.status)) {
1173
2207
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task status is ${task.status}, not retryable` }) }] };
1174
2208
  }
2209
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 4/6).
2210
+ {
2211
+ const lc = assertNodeActive(task.to_name, effectiveNetId ?? task.network_id ?? null);
2212
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
2213
+ }
1175
2214
  db.transaction(() => {
1176
2215
  // Reset task status
1177
2216
  const updateParams: any[] = [task_id];
@@ -1179,17 +2218,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1179
2218
  WHERE task_id = ?1`;
1180
2219
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1181
2220
  db.run(updateSql, updateParams);
2221
+ syncScheduledRunForTask(task_id, effectiveNetId ?? task.network_id ?? null);
1182
2222
  // Re-queue in inbox with new ID (original ID may already exist)
1183
2223
  const retryInboxId = uuidv4();
1184
2224
  db.run(
1185
- `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id)
1186
- VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7)`,
1187
- [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]
2225
+ `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id)
2226
+ VALUES (?1, ?2, ?3, ?4, 'task', ?5, ?6, ?7, 'reply', ?8)`,
2227
+ [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]
1188
2228
  );
1189
2229
  });
1190
2230
  logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
1191
2231
  // SSE push (unconditional — channel is keyed by alias, not network)
1192
- pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session }, effectiveNetId ?? task.network_id ?? null);
2232
+ 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);
2233
+ pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: task.to_name, status: "delivered", priority: task.priority });
1193
2234
  return {
1194
2235
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
1195
2236
  };
@@ -1229,19 +2270,64 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1229
2270
  from_name: z.string().max(200).optional().describe("Filter by sender"),
1230
2271
  from_node_id: z.string().max(200).optional().describe("Filter by immutable sender node_id"),
1231
2272
  network_id: z.string().max(200).optional().describe("Filter by network"),
2273
+ before_created_at: z.string().max(64).optional().describe("Pagination cursor timestamp"),
2274
+ before_task_id: z.string().max(200).optional().describe("Pagination cursor task id"),
2275
+ durable_cursor: z.boolean().optional().describe("Require immutable node-scoped cursor protocol"),
2276
+ durable_terminal_cursor: z.boolean().optional().describe("Read terminal outbound journal in monotonic order"),
2277
+ after_terminal_seq: z.number().int().min(0).optional().describe("Exclusive terminal journal watermark"),
1232
2278
  limit: z.number().min(1).max(100).optional().default(20),
1233
2279
  },
1234
- async ({ alias, status, from_name, from_node_id, network_id: netId, limit }) => {
2280
+ 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 }) => {
1235
2281
  const readScope = resolveReadScope(netId);
1236
2282
  if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
1237
- 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";
2283
+ if (durable_cursor) {
2284
+ if (!callerTokenIsNetwork) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_required" }) }] };
2285
+ const boundNodeId = callerTokenId && enforceNetworkId
2286
+ ? db.get<{ bound_node_id: string | null }>(
2287
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
2288
+ callerTokenId,
2289
+ enforceNetworkId,
2290
+ )?.bound_node_id ?? null
2291
+ : null;
2292
+ if (!boundNodeId || from_node_id !== boundNodeId) {
2293
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "from_node_id_identity_mismatch" }) }] };
2294
+ }
2295
+ }
2296
+ if (durable_terminal_cursor) {
2297
+ if (!durable_cursor) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "durable_cursor_required" }) }] };
2298
+ const params: any[] = [enforceNetworkId, from_node_id, after_terminal_seq ?? 0, limit];
2299
+ const tasks = db.all(
2300
+ `SELECT t.task_id, t.from_node_id, t.from_name, t.to_node_id, t.to_name, t.priority,
2301
+ t.status, t.content, t.result, t.created_at, t.runtime_submitted_at,
2302
+ t.consumed_at, t.thread_id, t.turn_id, t.completed_at, e.terminal_seq
2303
+ FROM task_terminal_events e
2304
+ JOIN tasks t ON t.task_id = e.task_id
2305
+ WHERE COALESCE(e.network_id, 'default') = COALESCE(?1, 'default')
2306
+ AND e.from_node_id = ?2 AND e.terminal_seq > ?3
2307
+ ORDER BY e.terminal_seq ASC LIMIT ?4`,
2308
+ ...params,
2309
+ );
2310
+ return { content: [{ type: "text" as const, text: JSON.stringify({
2311
+ ok: true,
2312
+ capability: "list_tasks.immutable-terminal-sequence.v2",
2313
+ tasks,
2314
+ count: tasks.length,
2315
+ next_terminal_seq: tasks.length ? tasks[tasks.length - 1]?.terminal_seq : (after_terminal_seq ?? 0),
2316
+ has_more: tasks.length === limit,
2317
+ }) }] };
2318
+ }
2319
+ 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";
1238
2320
  const params: any[] = [];
1239
2321
  sql = addReadScope(sql, params, readScope);
1240
2322
  if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
1241
2323
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
1242
2324
  if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
1243
2325
  if (from_node_id) { sql += ` AND from_node_id = ?${params.length + 1}`; params.push(from_node_id); }
1244
- sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
2326
+ if (before_created_at && before_task_id) {
2327
+ sql += ` AND (created_at < ?${params.length + 1} OR (created_at = ?${params.length + 1} AND task_id < ?${params.length + 2}))`;
2328
+ params.push(before_created_at, before_task_id);
2329
+ }
2330
+ sql += ` ORDER BY created_at DESC, task_id DESC LIMIT ?${params.length + 1}`;
1245
2331
  params.push(limit);
1246
2332
  const tasks = db.all(sql, ...params);
1247
2333
 
@@ -1255,7 +2341,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1255
2341
  return {
1256
2342
  content: [{
1257
2343
  type: "text" as const,
1258
- text: JSON.stringify({ ok: true, tasks, count: tasks.length, stats }),
2344
+ text: JSON.stringify({
2345
+ ok: true,
2346
+ ...(durable_cursor ? { capability: "list_tasks.immutable-node-cursor.v1" } : {}),
2347
+ tasks,
2348
+ count: tasks.length,
2349
+ next_cursor: tasks.length === limit ? {
2350
+ before_created_at: tasks[tasks.length - 1]?.created_at,
2351
+ before_task_id: tasks[tasks.length - 1]?.task_id,
2352
+ } : null,
2353
+ stats,
2354
+ }),
1259
2355
  }],
1260
2356
  };
1261
2357
  }
@@ -1269,24 +2365,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1269
2365
  task_id: z.string().min(1).max(200).describe("Task ID to cancel"),
1270
2366
  reason: z.string().max(1000).optional().describe("Cancellation reason"),
1271
2367
  from_session: z.string().max(200).optional(),
2368
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1272
2369
  },
1273
- async ({ task_id, reason, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1274
- const effectiveNetId = getNetworkId(null);
2370
+ async ({ task_id, reason, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2371
+ const effectiveNetId = getNetworkId(netId);
1275
2372
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1276
2373
  console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
1277
2374
  const updateParams: any[] = [reason || "cancelled by " + from_session, task_id];
1278
2375
  let updateSql = `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
1279
2376
  WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`;
1280
2377
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1281
- const result = db.run(updateSql, updateParams);
1282
- // Also ack the inbox entry to prevent agent from picking it up
1283
- if (result.changes > 0) {
1284
- const inboxParams: any[] = [task_id];
1285
- let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
1286
- inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
1287
- db.run(inboxSql, inboxParams);
1288
- logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
1289
- }
2378
+ const result = db.transaction(() => {
2379
+ const updated = db.run(updateSql, updateParams);
2380
+ // Also ack the inbox entry to prevent agent from picking it up.
2381
+ if (updated.changes > 0) {
2382
+ const inboxParams: any[] = [task_id];
2383
+ let inboxSql = "UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0";
2384
+ inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
2385
+ db.run(inboxSql, inboxParams);
2386
+ syncScheduledRunForTask(task_id, effectiveNetId);
2387
+ }
2388
+ return updated;
2389
+ });
2390
+ if (result.changes > 0) logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
1290
2391
  return {
1291
2392
  content: [{ type: "text" as const, text: JSON.stringify({ ok: result.changes > 0, task_id, cancelled: result.changes > 0 }) }],
1292
2393
  };
@@ -1301,9 +2402,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1301
2402
  task_id: z.string().min(1).max(200).describe("Task ID to reassign"),
1302
2403
  new_alias: z.string().min(1).max(200).describe("Target agent alias"),
1303
2404
  from_session: z.string().max(200).optional(),
2405
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1304
2406
  },
1305
- async ({ task_id, new_alias, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1306
- const effectiveNetId = getNetworkId(null);
2407
+ async ({ task_id, new_alias, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2408
+ const effectiveNetId = getNetworkId(netId);
1307
2409
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1308
2410
  console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
1309
2411
  const taskParams: any[] = [task_id];
@@ -1319,10 +2421,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1319
2421
  const reassignedAlias = canonical.alias;
1320
2422
  const target = resolveDeliveryTarget(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
1321
2423
  const newNodeId = target.state === "not_found" ? null : (target.session?.node_id ?? null);
2424
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 5/6).
2425
+ {
2426
+ const lc = assertNodeActive(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
2427
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
2428
+ }
1322
2429
  db.transaction(() => {
1323
2430
  // Ack old inbox to prevent original agent from picking it up
1324
2431
  const inboxParams: any[] = [task_id];
1325
- let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
2432
+ let inboxSql = "UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0";
1326
2433
  inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
1327
2434
  db.run(inboxSql, inboxParams);
1328
2435
 
@@ -1332,23 +2439,181 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1332
2439
  db.run(updateSql, updateParams);
1333
2440
 
1334
2441
  const newInboxId = uuidv4();
1335
- 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)",
1336
- [newInboxId, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
2442
+ 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)",
2443
+ [newInboxId, task_id, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
1337
2444
  });
1338
2445
  logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${reassignedAlias}`);
1339
- 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);
2446
+ 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);
2447
+ pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: reassignedAlias, status: "delivered", priority: task.priority });
1340
2448
  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 } : {}) }) }] };
1341
2449
  }
1342
2450
  );
1343
2451
 
1344
2452
  server.tool(
1345
- "broadcast",
1346
- "Send a message to multiple sessions.",
2453
+ "send_desktop_message",
2454
+ "Push a message to a user's active Desktop/web clients in one network. Targets user identity, not node alias.",
1347
2455
  {
2456
+ to_user_id: z.string().min(1).max(200).optional().describe("Target user_id. Mutually checked with to_username when both are supplied."),
2457
+ to_username: z.string().min(1).max(50).optional().describe("Target username. Mutually checked with to_user_id when both are supplied."),
2458
+ title: z.string().max(200).optional(),
1348
2459
  message: z.string().min(1).max(10000),
1349
- filter_server: z.string().max(200).optional(),
1350
- filter_status: z.string().max(50).optional(),
1351
- network_id: z.string().max(200).optional().describe("Broadcast within a specific network"),
2460
+ severity: z.enum(["info", "success", "warning", "error"]).optional().default("info"),
2461
+ kind: z.string().min(1).max(80).optional().default("agent_message"),
2462
+ meta: z.record(z.string(), z.unknown()).optional(),
2463
+ from_session: z.string().max(200).optional(),
2464
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens; ntok stays bound to its network)."),
2465
+ },
2466
+ async ({ to_user_id, to_username, title, message, severity, kind, meta, from_session: _fromIn, network_id: netId }) => {
2467
+ const fromMismatch = fromIdentityMismatchReply(_fromIn);
2468
+ if (fromMismatch) return fromMismatch;
2469
+ const from_session = defaultFrom(_fromIn);
2470
+ const effectiveNetId = getNetworkId(netId);
2471
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
2472
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
2473
+
2474
+ if (!to_user_id && !to_username) {
2475
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_required", message: "to_user_id or to_username is required" }) }] };
2476
+ }
2477
+
2478
+ const byId = to_user_id
2479
+ ? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE user_id = ?1", to_user_id)
2480
+ : null;
2481
+ const byName = to_username
2482
+ ? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE username = ?1", to_username)
2483
+ : null;
2484
+
2485
+ if (to_user_id && !byId) {
2486
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_user_id" }) }] };
2487
+ }
2488
+ if (to_username && !byName) {
2489
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_username" }) }] };
2490
+ }
2491
+ if (byId && byName && byId.user_id !== byName.user_id) {
2492
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_mismatch", to_user_id, to_username }) }] };
2493
+ }
2494
+
2495
+ const target = byId ?? byName;
2496
+ if (!target) {
2497
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found" }) }] };
2498
+ }
2499
+
2500
+ const targetRole = getUserNetworkRole(target.user_id, effectiveNetId);
2501
+ if (!targetRole) {
2502
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_in_network", network_id: effectiveNetId }) }] };
2503
+ }
2504
+
2505
+ const messageId = `dm_${uuidv4()}`;
2506
+ const event = {
2507
+ type: "desktop_message",
2508
+ message_id: messageId,
2509
+ kind,
2510
+ from: from_session,
2511
+ title: title || null,
2512
+ message,
2513
+ severity,
2514
+ created_at: new Date().toISOString(),
2515
+ ...(meta && Object.keys(meta).length ? { meta } : {}),
2516
+ };
2517
+
2518
+ // #1459 ① P2 —— 审计与收件持久化必须同生共死:只落其中一半,就会出现
2519
+ // 「审计说发过、而用户永远收不到」或者反过来。放进同一个事务。
2520
+ db.transaction(() => {
2521
+ db.run(
2522
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
2523
+ VALUES (?1, ?2, 'send_desktop_message', 'user', ?3, ?4, ?5)`,
2524
+ [
2525
+ enforceUserId || null,
2526
+ callerAlias || null,
2527
+ target.user_id,
2528
+ JSON.stringify({
2529
+ message_id: messageId,
2530
+ to_username: target.username,
2531
+ from: from_session,
2532
+ severity,
2533
+ kind,
2534
+ title: title || null,
2535
+ meta_keys: meta && typeof meta === "object" ? Object.keys(meta) : [],
2536
+ }),
2537
+ effectiveNetId,
2538
+ ],
2539
+ );
2540
+ // 🔴 `ON CONFLICT(message_id) DO NOTHING`,而不是 `INSERT OR IGNORE`:
2541
+ // 两者对"重投同一个 message_id"效果相同(幂等,不产生重复消息),
2542
+ // 但 OR IGNORE 会**连同其它约束错误一起吞掉** —— 我在实现本段时就
2543
+ // 撞上了:`kind` 为 NULL 触发 NOT NULL,OR IGNORE 静默丢弃整行,
2544
+ // 而工具照样返回 `persisted: true`。**一条消息凭空消失且报告成功**,
2545
+ // 正是本 issue 要消灭的那个形状。
2546
+ // DO NOTHING 只针对主键冲突,别的约束照常抛出、事务回滚。
2547
+ //
2548
+ // 另:普通 INSERT 会让重试抛主键冲突(无害重试变 500);
2549
+ // OR REPLACE 会覆盖旧行、把用户已 ack 的状态冲掉。都不取。
2550
+ db.run(
2551
+ `INSERT INTO user_inbox
2552
+ (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json)
2553
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
2554
+ ON CONFLICT(message_id) DO NOTHING`,
2555
+ [
2556
+ messageId,
2557
+ effectiveNetId ?? null,
2558
+ target.user_id,
2559
+ from_session,
2560
+ // 🔴 不依赖 zod 的 .default():直接调用 handler 的调用方(测试、
2561
+ // 未来的内部调用)拿不到 zod 默认值,会把 NULL 写进 NOT NULL 列。
2562
+ // 列上虽有 DEFAULT,但**显式传 NULL 会覆盖列默认值**。
2563
+ kind ?? "agent_message",
2564
+ title || null,
2565
+ message,
2566
+ severity ?? "info",
2567
+ // 存储侧走与 inbox 一致的跨主机脱敏;读取侧另有一道 token 形状
2568
+ // 脱敏(规格 ③ redact-at-read),两层分别防不同的东西。
2569
+ meta && typeof meta === "object" ? normalizeMetaJson(meta) : null,
2570
+ ],
2571
+ );
2572
+ });
2573
+ // #1459 / #1563 — pushUserEvent 在没有订阅者时静默 return。
2574
+ // 🔴 这段注释原先写「本工具既不写 inbox、也没有 user 级回读路径 ⇒ dashboard
2575
+ // 关着时消息永久没了」。**那半句已经不成立** —— 上面 20 行就在
2576
+ // `INSERT INTO user_inbox`,且 `/api/messages?scope=user` 能回读
2577
+ // (desktop-message-seam.test.ts 有真实 send → 真实 GET 的端到端断言)。
2578
+ // 留着它会把 debug 未读数的人直接带向错误的一层。
2579
+ //
2580
+ // 这里先把**丢失变可见**:`ok` 仍表示「请求被接受、审计已落库」,
2581
+ // 新增 `delivered` 表示「此刻真的有人收到」。判据取自订阅者注册表,
2582
+ // 不是猜的。持久化 + 重连补投是另一条(按 user_id 寻址的新收件表,#1459)。
2583
+ //
2584
+ // 🔴 `reason` 报的是**观测到的事实**(此刻没有活订阅者),不是它的后果。
2585
+ // 今天「没有活订阅者」确实等于「丢了」,但持久化落地之后同一个事实会
2586
+ // 变成「已入库、等重连补投」。把值取成 no_live_subscriber 而不是
2587
+ // "lost"/"dropped",就是为了那天不用改写历史含义 —— 届时按需细分
2588
+ // (如 lost vs queued)即可,现在不预先把语义焊死。
2589
+ // 🔴 push 必须在事务**提交之后**:放进事务里的话,活订阅者可能先收到
2590
+ // 事件、而行还没提交;一旦回滚,用户就看到了一条数据库里不存在的消息。
2591
+ const delivered = hasUserSubscribers(effectiveNetId, target.user_id);
2592
+ pushUserEvent(effectiveNetId, target.user_id, event);
2593
+
2594
+ return { content: [{ type: "text" as const, text: JSON.stringify({
2595
+ ok: true,
2596
+ message_id: messageId,
2597
+ delivered,
2598
+ // #1459 ① P2 —— 有了持久化之后,「此刻没人在听」不再等于「丢了」。
2599
+ // `delivered` 说的是实时投递、`persisted` 说的是它已经入库等重连补投。
2600
+ // `reason` 仍报**观测到的事实**而不是它的后果,所以这个中间态没有说错话。
2601
+ persisted: true,
2602
+ ...(delivered ? {} : { reason: "no_live_subscriber" }),
2603
+ delivered_to_user_id: target.user_id,
2604
+ network_id: effectiveNetId,
2605
+ }) }] };
2606
+ },
2607
+ );
2608
+
2609
+ server.tool(
2610
+ "broadcast",
2611
+ "Send a message to multiple sessions.",
2612
+ {
2613
+ message: z.string().min(1).max(10000),
2614
+ filter_server: z.string().max(200).optional(),
2615
+ filter_status: z.string().max(50).optional(),
2616
+ network_id: z.string().max(200).optional().describe("Broadcast within a specific network"),
1352
2617
  },
1353
2618
  async ({ message, filter_server, filter_status, network_id: netId }) => {
1354
2619
  const effectiveNetId = getNetworkId(netId);
@@ -1362,26 +2627,39 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1362
2627
 
1363
2628
  const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
1364
2629
  const ids: string[] = [];
2630
+ // #1440 ① — the push loop must walk the recipients that actually
2631
+ // got an inbox row, not every candidate target. Walking `targets`
2632
+ // announced a broadcast to nodes the lifecycle guard had just
2633
+ // skipped, i.e. nodes with nothing to fetch.
2634
+ const delivered: Array<{ alias: string; netId: string | null }> = [];
1365
2635
 
1366
2636
  for (const t of targets) {
2637
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 6/6).
2638
+ // Broadcast skips non-active recipients silently rather than
2639
+ // failing the entire send — broadcast semantics are best-effort
2640
+ // per-recipient and a stopped node simply gets nothing.
2641
+ const netId = effectiveNetId ?? t.network_id ?? null;
2642
+ const lc = assertNodeActive(t.alias, netId);
2643
+ if (!lc.ok) continue;
1367
2644
  const id = uuidv4();
1368
2645
  db.run(
1369
2646
  `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
1370
2647
  VALUES (?1, ?2, ?3, 'broadcast', 'normal', ?4, 'hub', ?5)`,
1371
- [id, t.alias, t.node_id ?? null, message, effectiveNetId ?? t.network_id ?? null]
2648
+ [id, t.alias, t.node_id ?? null, message, netId]
1372
2649
  );
1373
2650
  ids.push(id);
2651
+ delivered.push({ alias: t.alias, netId });
1374
2652
  }
1375
2653
 
1376
- for (const t of targets) {
1377
- pushEvent(t.alias, { type: "broadcast", inbox_count: 1 }, effectiveNetId ?? t.network_id ?? null);
2654
+ for (const d of delivered) {
2655
+ pushEvent(d.alias, { type: "broadcast", inbox_count: pendingInboxCount(d.alias, d.netId) }, d.netId);
1378
2656
  }
1379
2657
 
1380
2658
  return {
1381
2659
  content: [
1382
2660
  {
1383
2661
  type: "text" as const,
1384
- text: JSON.stringify({ ok: true, recipients: targets.length, message_ids: ids }),
2662
+ text: JSON.stringify({ ok: true, recipients: ids.length, message_ids: ids }),
1385
2663
  },
1386
2664
  ],
1387
2665
  };
@@ -1474,15 +2752,34 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1474
2752
  "update_node_config",
1475
2753
  "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.",
1476
2754
  {
1477
- node_id: z.string().min(1).max(200).describe("Target node ID (not alias). Must be in caller's network."),
2755
+ ...NODE_ID_ALIAS_FIELDS,
1478
2756
  base_revision: z.number().int().min(0).describe("Current revision per the dashboard's last GET — 409 if hub's current revision differs."),
1479
2757
  patch: z.object({
1480
2758
  model: z.string().max(200).optional(),
1481
- flags: z.record(z.unknown()).optional(),
2759
+ flags: z.record(z.string(), z.unknown()).optional(),
2760
+ // #260 P5 — channel enable/disable. Restart-tier field (agent-node
2761
+ // reads config.channels once at boot to fork per-channel workers,
2762
+ // so the swap takes effect via process restart). Not
2763
+ // SECURITY_SENSITIVE — this is a lifecycle op, gated by SEC-1
2764
+ // (network scope) only.
2765
+ //
2766
+ // Deliberately `z.array(z.unknown()).max(16)` (mirrors flags's
2767
+ // `z.record(z.string(), z.unknown())`): trust nothing at the wire boundary,
2768
+ // narrow the same way `narrowChannelsPatch` narrows raw untrusted
2769
+ // JSON (typeof + allowlist + dedup + case-fold). A strict
2770
+ // `z.array(z.string())` would fail-fast on a single non-string
2771
+ // entry, but the wire contract wants junk silently dropped so a
2772
+ // dashboard fat-finger doesn't turn into a 400 the user has to
2773
+ // interpret. validatePatch then re-rejects if the caller bypassed
2774
+ // narrowing.
2775
+ channels: z.array(z.unknown()).max(16).optional(),
1482
2776
  }).describe("Fields to update. Empty patch → no-op (use restart_node for that)."),
1483
2777
  network_id: z.string().max(200).optional(),
1484
2778
  },
1485
- async ({ node_id: nodeId, base_revision: baseRev, patch, network_id: clientNetId }) => {
2779
+ async ({ node_id, child_node_id, base_revision: baseRev, patch, network_id: clientNetId }) => {
2780
+ const idArg = resolveNodeIdArg({ node_id, child_node_id });
2781
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
2782
+ const nodeId = idArg.node_id;
1486
2783
  const effectiveNetId = getNetworkId(clientNetId);
1487
2784
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
1488
2785
 
@@ -1496,6 +2793,50 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1496
2793
 
1497
2794
  const model = typeof patch.model === "string" ? patch.model : undefined;
1498
2795
  const flags = (patch.flags && typeof patch.flags === "object") ? patch.flags as Record<string, unknown> : {};
2796
+ // Narrow untrusted `channels` at the boundary (typeof + allowlist +
2797
+ // dedup + case-fold), and distinguish two very different cases
2798
+ // that both narrow to `[]`:
2799
+ // (a) `patch.channels === []` (explicit "disable all editable
2800
+ // channels"). Downstream must proceed and write channels=[]
2801
+ // so the node's next restart forks no workers.
2802
+ // (b) `patch.channels === ["commhub"]` or similar — the caller
2803
+ // sent items but every single one was invalid (dashboard PR
2804
+ // #31 still ships commhub, and a typo like "telegarm" would
2805
+ // hit the same path). Downstream MUST NOT treat this as
2806
+ // (a) — silently converting to disable-all would nuke the
2807
+ // user's existing telegram/feishu workers.
2808
+ //
2809
+ // The reject error is `channels_all_invalid` so the dashboard
2810
+ // can surface the mismatch instead of showing a false success.
2811
+ let channels: string[] | undefined = undefined;
2812
+ if (patch.channels !== undefined) {
2813
+ if (!Array.isArray(patch.channels)) {
2814
+ // Zod already permits arrays only; belt+braces if it drifts.
2815
+ return {
2816
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_patch", field: "channels", reason: "must be an array" }) }],
2817
+ };
2818
+ }
2819
+ const narrowed = narrowChannelsPatch(patch.channels) ?? [];
2820
+ if (patch.channels.length === 0) {
2821
+ channels = []; // (a) explicit disable-all
2822
+ } else if (narrowed.length === 0) {
2823
+ // (b) every entry was invalid — refuse to write. Distinct
2824
+ // error so the dashboard can surface the failure.
2825
+ return {
2826
+ content: [{
2827
+ type: "text" as const,
2828
+ text: JSON.stringify({
2829
+ ok: false,
2830
+ error: "channels_all_invalid",
2831
+ requested: patch.channels,
2832
+ message: "every requested channel is unknown or unsupported; use an explicit empty array to disable-all",
2833
+ }),
2834
+ }],
2835
+ };
2836
+ } else {
2837
+ channels = narrowed;
2838
+ }
2839
+ }
1499
2840
 
1500
2841
  // SEC-2 (final policy 2026-06-28) — security-sensitive flags
1501
2842
  // (permissionMode / dangerouslySkipPermissions / teammateMode)
@@ -1522,7 +2863,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1522
2863
  };
1523
2864
  }
1524
2865
 
1525
- const validationFail = validatePatch(model, flags);
2866
+ const validationFail = validatePatch(model, flags, channels);
1526
2867
  if (validationFail) {
1527
2868
  return {
1528
2869
  content: [{
@@ -1600,8 +2941,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1600
2941
 
1601
2942
  // Compute apply_mode + persist + push doorbell.
1602
2943
  const updateId = `cu_${uuidv4()}`;
1603
- const applyMode = computeApplyMode(model, flags);
1604
- const patchJson = JSON.stringify({ ...(model !== undefined ? { model } : {}), flags });
2944
+ const applyMode = computeApplyMode(model, flags, channels);
2945
+ const patchJson = JSON.stringify({
2946
+ ...(model !== undefined ? { model } : {}),
2947
+ flags,
2948
+ ...(channels !== undefined ? { channels } : {}),
2949
+ });
1605
2950
  const networkId = node.network_id || "default";
1606
2951
  db.run(
1607
2952
  `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)`,
@@ -1739,14 +3084,251 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1739
3084
  },
1740
3085
  );
1741
3086
 
3087
+ // ── app#225 — 节点规则文件(AGENTS.md / CLAUDE.md)远程读写 ──
3088
+ //
3089
+ // 形状照抄 RFC-024 config-apply:客户端调 read_node_rules_file /
3090
+ // write_node_rules_file 落一行 node_rules_requests + 门铃 {type:"rules_file"};
3091
+ // 节点收到门铃后 get_rules_file_request 拉(和 get_config_update 同一道
3092
+ // F-A 闸:网络 token + alias),做完 ack_rules_file_request 回报;客户端
3093
+ // 轮询 get_rules_file_result。
3094
+ //
3095
+ // 🔴 安全(#225 验收第 5 条):整条链路**没有路径参数**。文件名由节点按
3096
+ // 自己的 RUNTIME 决定(claude → CLAUDE.md,其余 → AGENTS.md),目录固定是
3097
+ // 节点进程的 cwd(agent-node/src/runtime/rules-file.ts)。hub 只传 op +
3098
+ // content,客户端连文件名都指定不了,所以不存在「借路径参数写任意文件」。
3099
+ // 读和写都要 canWrite:规则文件是节点行为的一部分,读它等于读节点配置。
3100
+ const RULES_FILE_MAX_BYTES = 256 * 1024;
3101
+ const RULES_REQUEST_STALE_MS = 60_000;
3102
+
3103
+ const enqueueRulesFileRequest = (
3104
+ op: "read" | "write",
3105
+ a: { node_id?: string; child_node_id?: string; network_id?: string; content?: string },
3106
+ ) => {
3107
+ const idArg = resolveNodeIdArg({ node_id: a.node_id, child_node_id: a.child_node_id });
3108
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
3109
+ const nodeId = idArg.node_id;
3110
+ const effectiveNetId = getNetworkId(a.network_id);
3111
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, `${op} rules file`);
3112
+
3113
+ const { row: node, sec1Ok } = resolveTargetNode(nodeId, effectiveNetId);
3114
+ if (!node) {
3115
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: nodeId }) }] };
3116
+ }
3117
+ if (!sec1Ok) {
3118
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node", message: "node belongs to another network" }) }] };
3119
+ }
3120
+ if (op === "write") {
3121
+ if (typeof a.content !== "string") {
3122
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_content", reason: "content must be a string" }) }] };
3123
+ }
3124
+ const bytes = Buffer.byteLength(a.content, "utf8");
3125
+ if (bytes > RULES_FILE_MAX_BYTES) {
3126
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "content_too_large", bytes, max_bytes: RULES_FILE_MAX_BYTES }) }] };
3127
+ }
3128
+ }
3129
+
3130
+ // 单飞 + 陈旧回收,同 update_node_config 的 F-B:节点掉线时旧行永远
3131
+ // 非终态,不回收就把这个节点锁死。
3132
+ const inFlight = db.get<{ request_id: string; created_at: number; pulled_at: number | null }>(
3133
+ "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') ORDER BY created_at DESC LIMIT 1",
3134
+ nodeId,
3135
+ );
3136
+ if (inFlight) {
3137
+ const age = Date.now() - (inFlight.pulled_at ?? inFlight.created_at);
3138
+ if (age <= RULES_REQUEST_STALE_MS) {
3139
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_in_flight", existing_request_id: inFlight.request_id, age_ms: age }) }] };
3140
+ }
3141
+ db.run(
3142
+ "UPDATE node_rules_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3",
3143
+ [Date.now(), `superseded after ${age}ms (> ${RULES_REQUEST_STALE_MS}ms) — node did not answer`, inFlight.request_id],
3144
+ );
3145
+ }
3146
+
3147
+ const requestId = `rf_${uuidv4()}`;
3148
+ const networkId = node.network_id || "default";
3149
+ db.run(
3150
+ `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)`,
3151
+ [requestId, nodeId, networkId, op, op === "write" ? a.content! : null, Date.now(), callerTokenId || "unknown"],
3152
+ );
3153
+ pushEvent(node.alias, { type: "rules_file", request_id: requestId }, networkId);
3154
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, op }) }] };
3155
+ };
3156
+
3157
+ server.tool(
3158
+ "read_node_rules_file",
3159
+ "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.",
3160
+ { ...NODE_ID_ALIAS_FIELDS, network_id: z.string().max(200).optional() },
3161
+ async ({ node_id, child_node_id, network_id }) => enqueueRulesFileRequest("read", { node_id, child_node_id, network_id }),
3162
+ );
3163
+
3164
+ server.tool(
3165
+ "write_node_rules_file",
3166
+ "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.",
3167
+ {
3168
+ ...NODE_ID_ALIAS_FIELDS,
3169
+ content: z.string().max(RULES_FILE_MAX_BYTES).describe("Full new file content (UTF-8). The node writes it atomically."),
3170
+ network_id: z.string().max(200).optional(),
3171
+ },
3172
+ async ({ node_id, child_node_id, content, network_id }) => enqueueRulesFileRequest("write", { node_id, child_node_id, content, network_id }),
3173
+ );
3174
+
3175
+ server.tool(
3176
+ "get_rules_file_request",
3177
+ "Node pulls its oldest pending rules-file request (called from agent-node when the SSE rules_file doorbell arrives). app#225.",
3178
+ {},
3179
+ async () => {
3180
+ // 同 get_config_update 的 F-A 闸:必须是网络 token + alias。
3181
+ if (!callerTokenIsNetwork || !enforceNetworkId) {
3182
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
3183
+ }
3184
+ if (!callerAlias) {
3185
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
3186
+ }
3187
+ const node = db.get<any>(
3188
+ "SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
3189
+ callerAlias,
3190
+ enforceNetworkId,
3191
+ );
3192
+ if (!node) {
3193
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request: null }) }] };
3194
+ }
3195
+ const req = db.get<any>(
3196
+ "SELECT request_id, op, content FROM node_rules_requests WHERE node_id = ?1 AND status = 'pending' ORDER BY created_at ASC LIMIT 1",
3197
+ node.node_id,
3198
+ );
3199
+ if (!req) {
3200
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request: null }) }] };
3201
+ }
3202
+ db.run(
3203
+ "UPDATE node_rules_requests SET status = 'in_progress', pulled_at = ?1 WHERE request_id = ?2 AND status = 'pending'",
3204
+ [Date.now(), req.request_id],
3205
+ );
3206
+ return {
3207
+ content: [{
3208
+ type: "text" as const,
3209
+ text: JSON.stringify({
3210
+ ok: true,
3211
+ request: {
3212
+ request_id: req.request_id,
3213
+ op: req.op,
3214
+ ...(req.op === "write" ? { content: req.content ?? "" } : {}),
3215
+ },
3216
+ }),
3217
+ }],
3218
+ };
3219
+ },
3220
+ );
3221
+
3222
+ server.tool(
3223
+ "ack_rules_file_request",
3224
+ "Node reports the outcome of a rules-file request — done (with content for reads) or failed (with error). app#225.",
3225
+ {
3226
+ request_id: z.string().min(1).max(200),
3227
+ status: z.enum(["done", "failed"]),
3228
+ file_name: z.string().max(64).optional(),
3229
+ exists: z.boolean().optional(),
3230
+ content: z.string().max(RULES_FILE_MAX_BYTES).optional(),
3231
+ error: z.string().max(2000).optional(),
3232
+ },
3233
+ async ({ request_id: requestId, status, file_name: fileName, exists, content, error: ackError }) => {
3234
+ if (!callerTokenIsNetwork || !enforceNetworkId) {
3235
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
3236
+ }
3237
+ if (!callerAlias) {
3238
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
3239
+ }
3240
+ const node = db.get<any>(
3241
+ "SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
3242
+ callerAlias,
3243
+ enforceNetworkId,
3244
+ );
3245
+ if (!node) {
3246
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "alias_unknown" }) }] };
3247
+ }
3248
+ const req = db.get<any>(
3249
+ "SELECT request_id, node_id, status FROM node_rules_requests WHERE request_id = ?1",
3250
+ requestId,
3251
+ );
3252
+ // 跨租户闸:只能 ack 自己的请求;别人的当不存在处理。
3253
+ if (!req || req.node_id !== node.node_id) {
3254
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "unknown_or_foreign_request" }) }] };
3255
+ }
3256
+ if (req.status === "done" || req.status === "failed" || req.status === "timeout") {
3257
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "already_terminal", current_status: req.status }) }] };
3258
+ }
3259
+ db.run(
3260
+ `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`,
3261
+ [
3262
+ status,
3263
+ Date.now(),
3264
+ fileName ?? null,
3265
+ typeof exists === "boolean" ? (exists ? 1 : 0) : null,
3266
+ status === "done" ? (content ?? null) : null,
3267
+ status === "failed" ? (ackError || "node reported failure without a reason") : null,
3268
+ requestId,
3269
+ ],
3270
+ );
3271
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, status }) }] };
3272
+ },
3273
+ );
3274
+
3275
+ server.tool(
3276
+ "get_rules_file_result",
3277
+ "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.",
3278
+ { request_id: z.string().min(1).max(200), network_id: z.string().max(200).optional() },
3279
+ async ({ request_id: requestId, network_id: clientNetId }) => {
3280
+ const effectiveNetId = getNetworkId(clientNetId);
3281
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "read rules file result");
3282
+ const row = db.get<any>(
3283
+ "SELECT request_id, node_id, network_id, op, status, file_name, file_exists, result_content, error, created_at, pulled_at, acked_at FROM node_rules_requests WHERE request_id = ?1",
3284
+ requestId,
3285
+ );
3286
+ // SEC-1:结果行的网络必须等于调用方作用域,否则当不存在。
3287
+ if (!row || row.network_id !== (effectiveNetId || "default")) {
3288
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found", request_id: requestId }) }] };
3289
+ }
3290
+ let status: string = row.status;
3291
+ const ageMs = Date.now() - (row.pulled_at ?? row.created_at);
3292
+ if ((status === "pending" || status === "in_progress") && ageMs > RULES_REQUEST_STALE_MS) {
3293
+ status = "timeout";
3294
+ db.run(
3295
+ "UPDATE node_rules_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3 AND status IN ('pending', 'in_progress')",
3296
+ [Date.now(), `node did not answer within ${RULES_REQUEST_STALE_MS}ms (offline, or running an agent-node without app#225 support)`, requestId],
3297
+ );
3298
+ row.error = `node did not answer within ${RULES_REQUEST_STALE_MS}ms (offline, or running an agent-node without app#225 support)`;
3299
+ }
3300
+ return {
3301
+ content: [{
3302
+ type: "text" as const,
3303
+ text: JSON.stringify({
3304
+ ok: true,
3305
+ request_id: row.request_id,
3306
+ node_id: row.node_id,
3307
+ op: row.op,
3308
+ status,
3309
+ file_name: row.file_name ?? null,
3310
+ exists: row.file_exists === null || row.file_exists === undefined ? null : row.file_exists === 1,
3311
+ ...(status === "done" && row.op === "read" ? { content: row.result_content ?? "" } : {}),
3312
+ error: row.error ?? null,
3313
+ age_ms: ageMs,
3314
+ }),
3315
+ }],
3316
+ };
3317
+ },
3318
+ );
3319
+
3320
+
1742
3321
  server.tool(
1743
3322
  "restart_node",
1744
3323
  "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).",
1745
3324
  {
1746
- node_id: z.string().min(1).max(200),
3325
+ ...NODE_ID_ALIAS_FIELDS,
1747
3326
  network_id: z.string().max(200).optional(),
1748
3327
  },
1749
- async ({ node_id: nodeId, network_id: clientNetId }) => {
3328
+ async ({ node_id, child_node_id, network_id: clientNetId }) => {
3329
+ const idArg = resolveNodeIdArg({ node_id, child_node_id });
3330
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
3331
+ const nodeId = idArg.node_id;
1750
3332
  const effectiveNetId = getNetworkId(clientNetId);
1751
3333
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
1752
3334
 
@@ -1778,10 +3360,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1778
3360
  }
1779
3361
  const updateId = `cu_${uuidv4()}`;
1780
3362
  const networkId = node.network_id || "default";
1781
- db.run(
1782
- `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)`,
1783
- [updateId, nodeId, networkId, node.config_revision || 0, Date.now(), callerTokenId || "unknown"],
1784
- );
3363
+ // RFC-027 PR1.2a latent fix (#346 ack): restart_node must reset
3364
+ // lifecycle_state to 'active'. Otherwise a node that was previously
3365
+ // stop_node'd → 'stopped' would, after restart, stay marked
3366
+ // 'stopped' in the nodes table → the 6 MCP + 2 REST inbox guards
3367
+ // would refuse every routing attempt → node silently unreachable
3368
+ // (no error to operator, just no traffic). Pair the schema flip
3369
+ // with the config_updates INSERT inside one tx so we don't
3370
+ // half-commit if the dispatch INSERT throws.
3371
+ db.transaction(() => {
3372
+ db.run(
3373
+ `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)`,
3374
+ [updateId, nodeId, networkId, node.config_revision || 0, Date.now(), callerTokenId || "unknown"],
3375
+ );
3376
+ db.run(
3377
+ `UPDATE nodes SET lifecycle_state = 'active' WHERE node_id = ?1`,
3378
+ [nodeId],
3379
+ );
3380
+ });
1785
3381
  pushEvent(node.alias, { type: "restart", update_id: updateId }, networkId);
1786
3382
  return {
1787
3383
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update_id: updateId, apply_mode: "restart_only" }) }],
@@ -1814,6 +3410,143 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1814
3410
  throw e;
1815
3411
  };
1816
3412
 
3413
+ // RFC-026 §9.2.1 / #338 PR2 — list_host_supervisors.
3414
+ // Surfaces host_supervisor daemons in the caller's network with
3415
+ // online flag + declared capabilities. Replaces the prior `node_daemon_`
3416
+ // prefix heuristic (dashboard /api/anet/node-create) + the role-extract
3417
+ // path on /api/nodes (#337). Member脱敏 strips host_telemetry IP /
3418
+ // cpu / mem (per RFC-026 §6 #3 — daemons can leak internal topology
3419
+ // to non-admin readers). Revoked daemon ntoks filtered out via
3420
+ // join on api_tokens.revoked_at.
3421
+ server.tool(
3422
+ "list_host_supervisors",
3423
+ "List host_supervisor daemon nodes in the caller's network (online status + runtimes_supported + allowed_secret_keys + telemetry; member-脱敏). RFC-026 §9.2.1.",
3424
+ {
3425
+ network_id: z.string().max(200).optional(),
3426
+ },
3427
+ async ({ network_id: clientNetId }) => {
3428
+ // SEC-1 — use resolveReadScope, NOT getNetworkId. getNetworkId is
3429
+ // for write-tool helpers and pairs with `canWrite`; READ tools that
3430
+ // bypass canWrite and trust getNetworkId leak across tenants
3431
+ // (PR2 v1 BLOCKER per 通信龙 audit — utok_ caller with
3432
+ // enforceNetworkId=null could pass any network_id and read daemon
3433
+ // names + allowed_secret_keys for tenants they're not a member of).
3434
+ // resolveReadScope checks network_members for the user + denies
3435
+ // on non-membership, mirroring REST resolveRestNetworkScope.
3436
+ const readScope = resolveReadScope(clientNetId);
3437
+ if (readScope.denied) {
3438
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
3439
+ }
3440
+ // Caller role for 脱敏 decision (admin/owner = full, others = masked).
3441
+ // Use the resolved scope's networkId (the verified one), not the
3442
+ // unchecked clientNetId.
3443
+ const scopedNetId = readScope.networkId ?? null;
3444
+ const callerRole = enforceUserId && scopedNetId
3445
+ ? getUserNetworkRole(enforceUserId, scopedNetId)
3446
+ : null;
3447
+ // Network-bound tokens get full telemetry within their bound network
3448
+ // (already gate-locked by enforceNetworkId path of resolveReadScope).
3449
+ const isPrivileged = callerRole === "admin" || callerRole === "owner" || (!enforceUserId);
3450
+
3451
+ // Pull candidate daemons. Active-token EXISTS subquery handles BOTH
3452
+ // revoked daemon ntoks (revoked_at set) AND DELETEd token rows
3453
+ // (the standard revokeToken path deletes the row, not flagging
3454
+ // revoked_at — PR2 v1 SHOULD-FIX per 通信龙 audit). EXISTS naturally
3455
+ // dedupes if a daemon ever had multiple tokens (e.g. rotation; nit ⚪).
3456
+ let sql = `
3457
+ SELECT
3458
+ n.node_id, n.alias, n.hostname, n.network_id,
3459
+ n.runtimes_supported, n.allowed_secret_keys,
3460
+ n.created_at, n.updated_at,
3461
+ s.last_seen_at AS session_last_seen,
3462
+ s.status AS session_status,
3463
+ s.cpu_cores AS session_cpu_cores,
3464
+ s.mem_total_gb AS session_mem_total_gb,
3465
+ s.ip AS session_ip,
3466
+ n.config_snapshot
3467
+ FROM nodes n
3468
+ LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
3469
+ WHERE EXISTS (
3470
+ SELECT 1 FROM api_tokens t
3471
+ WHERE t.network_id = n.network_id
3472
+ AND t.name = 'node:' || n.alias
3473
+ AND t.revoked_at IS NULL
3474
+ )
3475
+ `;
3476
+ const sqlParams: any[] = [];
3477
+ sql = addReadScope(sql, sqlParams, readScope, "n.network_id");
3478
+ sql += ` ORDER BY n.updated_at DESC`;
3479
+ const rows = db.all<Record<string, any>>(sql, ...sqlParams);
3480
+
3481
+ // Filter to role=host_supervisor (read from config_snapshot;
3482
+ // schema-promoted columns runtimes_supported/allowed_secret_keys
3483
+ // are pre-extracted but role isn't a first-class column).
3484
+ const nowMs = Date.now();
3485
+ // 心跳周期是 3 分钟(agent-node/src/cli.ts 的 report_status 定时器),
3486
+ // 窗口必须大于它,否则每次心跳后的 60s~180s 之间必然抖成 offline
3487
+ // (通信评审牛 #1279 独审④)。取 5min,与仓内既有 stale 口径一致。
3488
+ const ONLINE_MS = 5 * 60_000;
3489
+ const daemons = rows
3490
+ .map(r => {
3491
+ let snapRole: string | null = null;
3492
+ if (r.config_snapshot) {
3493
+ try {
3494
+ const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
3495
+ snapRole = typeof parsed?.role === "string" ? parsed.role : null;
3496
+ } catch { /* malformed snapshot — role stays null */ }
3497
+ }
3498
+ return { row: r, role: snapRole };
3499
+ })
3500
+ .filter(({ role }) => role === "host_supervisor")
3501
+ .map(({ row: r }) => {
3502
+ // online = sessions.last_seen_at within ONLINE_MS
3503
+ let online = false;
3504
+ let lastSeenAt: string | null = null;
3505
+ if (r.session_last_seen) {
3506
+ lastSeenAt = r.session_last_seen;
3507
+ const t = parseDbTimestampMs(r.session_last_seen);
3508
+ if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
3509
+ }
3510
+ // Parse self-declare arrays (default to [] for pre-PR2 daemons)
3511
+ let runtimes: string[] = [];
3512
+ let secrets: string[] = [];
3513
+ try {
3514
+ if (r.runtimes_supported) {
3515
+ const parsed = JSON.parse(r.runtimes_supported);
3516
+ runtimes = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
3517
+ }
3518
+ } catch { /* malformed — empty */ }
3519
+ try {
3520
+ if (r.allowed_secret_keys) {
3521
+ const parsed = JSON.parse(r.allowed_secret_keys);
3522
+ secrets = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
3523
+ }
3524
+ } catch { /* malformed — empty */ }
3525
+ // host_telemetry — member脱敏 drops IP/cpu/mem
3526
+ const telemetry: Record<string, unknown> = {
3527
+ alert_level: online ? "green" : "gray",
3528
+ };
3529
+ if (isPrivileged) {
3530
+ telemetry.cpu_cores = r.session_cpu_cores ?? null;
3531
+ telemetry.mem_gb = r.session_mem_total_gb ?? null;
3532
+ telemetry.ip_internal = r.session_ip ?? null;
3533
+ }
3534
+ return {
3535
+ daemon_node_id: r.node_id,
3536
+ alias: r.alias,
3537
+ hostname: r.hostname,
3538
+ online,
3539
+ last_seen_at: lastSeenAt,
3540
+ runtimes_supported: runtimes,
3541
+ allowed_secret_keys: secrets,
3542
+ host_telemetry: telemetry,
3543
+ };
3544
+ });
3545
+
3546
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, daemons, count: daemons.length }) }] };
3547
+ },
3548
+ );
3549
+
1817
3550
  // §2.5 step 1+2 — dashboard-facing tool. Validates spec, mint
1818
3551
  // child-ntok, stash env_blob in pendingEnvBlobs Map, write request
1819
3552
  // row (metadata only — no env_blob in SQL), pushEvent doorbell.
@@ -1825,8 +3558,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1825
3558
  node_spec: z.object({
1826
3559
  name: z.string().min(1).max(64),
1827
3560
  runtime: z.string().min(1).max(64),
1828
- model: z.string().min(1).max(100),
1829
- flags: z.record(z.unknown()).optional(),
3561
+ model: z.string().min(1).max(100).optional().nullable(),
3562
+ flags: z.record(z.string(), z.unknown()).optional(),
1830
3563
  env_refs: z.array(z.string().max(64)).optional(),
1831
3564
  channels: z.array(z.unknown()).optional(),
1832
3565
  }),
@@ -1855,22 +3588,115 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1855
3588
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
1856
3589
  }
1857
3590
 
3591
+ // #1353 Fix ① — daemon self-declared "I can't create nodes right now"
3592
+ // is now a dispatch-time gate, not just a stored fact. Before this,
3593
+ // daemon reported `daemon_capabilities.can_create_nodes=false` (via
3594
+ // #1371 + #1377), hub stored it in config_snapshot, and then no
3595
+ // consumer read it — dispatch still returned `{ok:true, request_id}`
3596
+ // and pushed an SSE doorbell to the broken daemon. That's the exact
3597
+ // "在线但静默失败" case the issue title reports.
3598
+ //
3599
+ // Vincent's own 2026-08-28 comment on #1353 authorizes this shape:
3600
+ // "daemon 侧确知不可用时,hub 应当**拒绝派发**并返回可操作的错误,
3601
+ // 而不是发一个注定失败的 doorbell".
3602
+ //
3603
+ // 🔴 Pre-#1371 compat: a daemon that has NOT reported
3604
+ // `can_create_nodes` at all leaves the field UNDEFINED in the
3605
+ // snapshot. Do NOT fail-closed on undefined — that would silently
3606
+ // start rejecting every daemon on the old (preview.10 through .55
3607
+ // roughly) shape. Only `=== false` gates; `=== true` and `undefined`
3608
+ // both pass through (new-guard-matches-existing-stance).
3609
+ try {
3610
+ const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
3611
+ const caps = snap?.daemon_capabilities;
3612
+ if (caps && caps.can_create_nodes === false) {
3613
+ const reason: string = (typeof caps.create_nodes_blocked_reason === "string"
3614
+ && caps.create_nodes_blocked_reason.length > 0)
3615
+ ? caps.create_nodes_blocked_reason
3616
+ : "anet_bin_unknown";
3617
+ // #1545 —— 光说 `blocked_reason` 不够:**那个判断是什么时候做出来的**
3618
+ // 决定了下一步完全不同的两件事。
3619
+ // 刚测的 blocked ⇒ 去那台机器按 reason 修
3620
+ // 三周前测的 blocked ⇒ 那台 daemon 是**开机算一次就永久缓存**的旧版本
3621
+ // (agent-node ≤ 2.5.0-preview.54),它可能早就好了 ——
3622
+ // 先重启/升级它,再谈修 pin
3623
+ // 而在拒绝这一刻,用户正好在决定"去修哪台机器"。
3624
+ //
3625
+ // 🔴 年龄未知时给的是**显式 null + 一个说明为什么的枚举**,不是省略这两个键。
3626
+ // 省略读起来像"没有这个问题",而调用方(常常是个 agent)会默认它是刚测的
3627
+ // —— 那是朝「更可信」方向撒谎,比不给更糟。
3628
+ const rawAge = caps.create_capability_observed_ms_ago;
3629
+ const reportedAgeOk = typeof rawAge === "number" && Number.isFinite(rawAge)
3630
+ && rawAge >= 0 && rawAge <= 365 * 24 * 60 * 60 * 1000;
3631
+ // 🔴 daemon 报的是「测完到**这份 report 发出**之间隔了多久」,不是「到现在」。
3632
+ // 两者差一个心跳间隔(3 分钟),而对一个已经失联的 daemon 差多少都可能。
3633
+ // 直接把它当成"多久以前测的"渲染,会**朝更新鲜的方向**低报 ——
3634
+ // 正是这一格要防的那个方向。所以在这里补上心跳那一段:
3635
+ // 绝对年龄 = (now − last_seen_at) + daemon 报的时长
3636
+ // hub 的钟出绝对时间,daemon 只提供时长(它自己的钟偏移污染不了这个数)。
3637
+ //
3638
+ // 这是冷路径(只有被拒时才走),多一次小查询不影响心跳路径。
3639
+ let heartbeatAgeMs: number | null = null;
3640
+ try {
3641
+ const sess = db.get<{ last_seen_at: string | null }>(
3642
+ "SELECT last_seen_at FROM sessions WHERE node_id = ?1 ORDER BY last_seen_at DESC LIMIT 1",
3643
+ daemon_node_id,
3644
+ );
3645
+ // #1650 — 这一列是 TEXT(`datetime('now')`),UTC 但不带时区标记。
3646
+ // Date.parse 会按**本机时区**解析,误差 = 主机偏移(生产 hub 在 UTC+8
3647
+ // 时这个诊断数字整整偏 8 小时)。走 parseHubTimestamp 显式按 UTC 解。
3648
+ const t = parseHubTimestamp(sess?.last_seen_at);
3649
+ if (t !== null) heartbeatAgeMs = Math.max(0, Date.now() - t);
3650
+ } catch { /* 取不到就是取不到 —— 下面如实说 unknown,不猜 0 */ }
3651
+
3652
+ // 🔴 三种取值,不能塌成两种:
3653
+ // known 两半都有,可以给绝对年龄
3654
+ // unknown_legacy_daemon 那台 daemon 根本没报这一格(旧版本)
3655
+ // unknown_no_heartbeat_time 它报了,但 hub 这边没有可用的心跳时间
3656
+ // ⇒ **补不出绝对年龄**,而不是"年龄是 0"
3657
+ const ageKnown = reportedAgeOk && heartbeatAgeMs !== null;
3658
+ return { content: [{ type: "text" as const, text: JSON.stringify({
3659
+ ok: false,
3660
+ error: "daemon_cannot_create_nodes",
3661
+ blocked_reason: reason,
3662
+ // 到**现在**为止,这个判断做出来有多久了(毫秒)。null = 补不出来。
3663
+ capability_observed_ms_ago: ageKnown ? heartbeatAgeMs! + (rawAge as number) : null,
3664
+ capability_age: ageKnown
3665
+ ? "known"
3666
+ : (reportedAgeOk ? "unknown_no_heartbeat_time" : "unknown_legacy_daemon"),
3667
+ // 🔴 这里**不复制那张 code → 修法命令 的表**。它只有一个作者,在
3668
+ // `@sleep2agi/agent-network` 的 daemon-capability-display —— 抄一份到 hub,
3669
+ // 两份就会各自漂移,而「hub 教的修法」和「CLI 教的修法」不一致时,
3670
+ // 没有任何东西会红。这里只把人指到那一份。
3671
+ // (同 `blocked_reason` 是 z.enum 而不是自由文本的立场:hub 出**代码**,
3672
+ // 渲染留给客户端。)
3673
+ remediation_hint: "run `anet daemon list` where that daemon is configured — it prints the exact, paste-able fix command for this blocked_reason",
3674
+ daemon_node_id,
3675
+ }) }] };
3676
+ }
3677
+ } catch { /* malformed snapshot: permissive, mirrors L3230 fallthrough */ }
3678
+
1858
3679
  // Read daemon's host_supervisor capability + allowlist from its
1859
- // last reported config_snapshot. P1 simplification: we accept
1860
- // (allowed_runtimes empty / allowed_secret_keys absent) as
1861
- // "accept any in the global enum"; P2 will tighten when daemon
1862
- // self-publishes its own allowlist via daemon_capabilities.
3680
+ // last reported config_snapshot. PR3 (#338) canonical path is
3681
+ // `daemon_capabilities.runtimes_supported` (RFC-026 §9.3).
3682
+ // Pre-PR3 daemons (preview.10 and earlier) place these at the
3683
+ // TOP level of the snapshot rather than nested — those reads
3684
+ // return undefined here and `daemonAllowedRuntimes` stays null
3685
+ // (permissive — no allowlist enforcement); they fall back to
3686
+ // §4.2.2 structural validation only, identical to pre-PR3
3687
+ // behavior. No regression on in-flight daemons.
1863
3688
  let daemonAllowList = new Set<string>();
1864
3689
  let daemonAllowedRuntimes: string[] | null = null;
1865
3690
  try {
1866
3691
  const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
1867
- if (snap?.daemon_capabilities?.allowed_secret_keys) {
1868
- daemonAllowList = new Set(snap.daemon_capabilities.allowed_secret_keys);
3692
+ const caps = snap?.daemon_capabilities;
3693
+ if (Array.isArray(caps?.allowed_secret_keys)) {
3694
+ daemonAllowList = new Set(caps.allowed_secret_keys);
1869
3695
  }
1870
- if (Array.isArray(snap?.daemon_capabilities?.allowed_runtimes)) {
1871
- daemonAllowedRuntimes = snap.daemon_capabilities.allowed_runtimes;
3696
+ if (Array.isArray(caps?.runtimes_supported)) {
3697
+ daemonAllowedRuntimes = caps.runtimes_supported;
1872
3698
  }
1873
- } catch { /* permissive P1 fallback */ }
3699
+ } catch { /* permissive fallback */ }
1874
3700
 
1875
3701
  // §4.2.2 — structural validation (catches name/runtime/model/
1876
3702
  // flag injection at the hub edge). Daemon repeats this; double
@@ -1976,7 +3802,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1976
3802
  VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
1977
3803
  [
1978
3804
  requestId, daemon_node_id, node_spec.name, networkIdForChild,
1979
- node_spec.runtime, node_spec.model, JSON.stringify(node_spec.flags || {}),
3805
+ node_spec.runtime, node_spec.model ?? null, JSON.stringify(node_spec.flags || {}),
1980
3806
  JSON.stringify(envKeys), childTokenId, Date.now(), callerTokenId || "unknown",
1981
3807
  ],
1982
3808
  );
@@ -1986,9 +3812,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1986
3812
  // SSE handler resolves the rest via MCP call.
1987
3813
  pushEvent(daemon.alias, { type: "create_node", request_id: requestId }, networkIdForChild);
1988
3814
 
1989
- // §4.5 audit — dispatch succeeded
3815
+ // §4.5 audit — request queued and doorbell attempted. pushEvent is
3816
+ // fire-and-forget, so actual SSE delivery is intentionally not claimed.
1990
3817
  auditCreateNode({
1991
- action: "create_node_dispatched",
3818
+ action: "create_node_dispatch_attempted",
1992
3819
  user_id: enforceUserId,
1993
3820
  network_id: networkIdForChild,
1994
3821
  target_id: requestId,
@@ -1996,7 +3823,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1996
3823
  daemon_node_id,
1997
3824
  child_name: node_spec.name,
1998
3825
  runtime: node_spec.runtime,
1999
- model: node_spec.model,
3826
+ model: node_spec.model ?? null,
2000
3827
  flag_keys: Object.keys(node_spec.flags || {}),
2001
3828
  env_keys: envKeys,
2002
3829
  },
@@ -2008,6 +3835,78 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2008
3835
  },
2009
3836
  );
2010
3837
 
3838
+ // #1362 — daemon-side compensation for missed SSE doorbells. pushEvent is
3839
+ // intentionally best-effort; after a daemon connects or reconnects, it pulls
3840
+ // any still-pending requests bound to its own daemon token.
3841
+ server.tool(
3842
+ "list_my_pending_create_requests",
3843
+ "Daemon lists pending create-node requests bound to itself for SSE reconnect compensation. RFC-026/#1362.",
3844
+ {},
3845
+ async () => {
3846
+ const callerDaemon = resolveCallerDaemonTokenBound();
3847
+ if (!callerDaemon.ok) {
3848
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
3849
+ }
3850
+ const rows = db.all<{ request_id: string; child_name: string; created_at: number }>(
3851
+ `SELECT request_id, child_name, created_at
3852
+ FROM node_create_requests
3853
+ WHERE daemon_node_id = ?1
3854
+ AND network_id = ?2
3855
+ AND status = 'pending'
3856
+ ORDER BY created_at ASC`,
3857
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
3858
+ );
3859
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: rows.length, requests: rows }) }] };
3860
+ },
3861
+ );
3862
+
3863
+ // #1448 — stop/delete/start 门铃的 SSE 重连补偿,镜像 create 的
3864
+ // list_my_pending_create_requests(#1394)。pushEvent(push.ts) 是纯内存
3865
+ // fan-out:派发门铃那刻若 daemon 无 live SSE 订阅者(hub 重启/网络抖/boot
3866
+ // 窗口)就静默丢弃、不入队,于是 node_stop_requests/node_start_requests 的行
3867
+ // 永远停在 pending、节点卡死 stopping/deleting/starting。daemon 每次 SSE
3868
+ // connected 调本工具把漏掉的门铃拉回来重放(reconcilePendingLifecycleRequestsOnConnect)。
3869
+ //
3870
+ // 统一一个工具返回两张表的 pending 行(stop 表用 action 区分 stop/delete),
3871
+ // 省一次往返。鉴权同 create:token-bound daemon 身份(alias 不是安全边界),
3872
+ // 只返回绑定到本 daemon + 本网络的 pending 行。
3873
+ server.tool(
3874
+ "list_my_pending_lifecycle_requests",
3875
+ "Daemon lists pending stop/delete/start requests bound to itself for SSE reconnect compensation. Mirrors list_my_pending_create_requests (#1394/#1448).",
3876
+ {},
3877
+ async () => {
3878
+ const callerDaemon = resolveCallerDaemonTokenBound();
3879
+ if (!callerDaemon.ok) {
3880
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
3881
+ }
3882
+ const stop_requests = db.all<{ request_id: string; action: string; child_node_id: string; child_alias: string; created_at: number }>(
3883
+ `SELECT request_id, action, child_node_id, child_alias, created_at
3884
+ FROM node_stop_requests
3885
+ WHERE daemon_node_id = ?1
3886
+ AND network_id = ?2
3887
+ AND status = 'pending'
3888
+ ORDER BY created_at ASC`,
3889
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
3890
+ );
3891
+ const start_requests = db.all<{ request_id: string; child_node_id: string; child_alias: string; created_at: number }>(
3892
+ `SELECT request_id, child_node_id, child_alias, created_at
3893
+ FROM node_start_requests
3894
+ WHERE daemon_node_id = ?1
3895
+ AND network_id = ?2
3896
+ AND status = 'pending'
3897
+ ORDER BY created_at ASC`,
3898
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
3899
+ );
3900
+ return { content: [{ type: "text" as const, text: JSON.stringify({
3901
+ ok: true,
3902
+ stop_count: stop_requests.length,
3903
+ start_count: start_requests.length,
3904
+ stop_requests,
3905
+ start_requests,
3906
+ }) }] };
3907
+ },
3908
+ );
3909
+
2011
3910
  // §2.5 step 3 — daemon-facing pull. Returns full spec + env_blob +
2012
3911
  // child_ntok in one shot. Map evicted on take (one-shot consume).
2013
3912
  //
@@ -2060,7 +3959,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2060
3959
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "env_blob_unavailable" }) }] };
2061
3960
  }
2062
3961
  // Hydrate spec from row.
2063
- const specRow = db.get<{ child_name: string; runtime: string; model: string; flags_json: string }>(
3962
+ const specRow = db.get<{ child_name: string; runtime: string; model: string | null; flags_json: string }>(
2064
3963
  `SELECT child_name, runtime, model, flags_json FROM node_create_requests WHERE request_id = ?1`,
2065
3964
  request_id,
2066
3965
  );
@@ -2100,14 +3999,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2100
3999
  // record fork-side info.
2101
4000
  server.tool(
2102
4001
  "ack_create_request",
2103
- "Daemon acks a create-node request (called after fork). status='started' or 'failed'. RFC-026.",
4002
+ "Daemon acks a create-node request (called after fork). status='started' | 'failed' | 'rejected' | 'runtime_capability_check_failed'. RFC-026 §9.3 D2.",
2104
4003
  {
2105
4004
  request_id: z.string().min(1).max(200),
2106
- status: z.enum(["started", "failed", "rejected"]),
4005
+ // RFC-026 §9.3 D2 — runtime_capability_check_failed signals the
4006
+ // daemon spawned the child OK but it died within FAIL_FAST_MS
4007
+ // (5s in agent-node v2.5.0-preview.11+), indicating a
4008
+ // declaration↔reality gap on this daemon's runtimes_supported.
4009
+ // Treated terminal like 'failed' but fires a distinct audit_log
4010
+ // action so dashboards can highlight "lying daemons" separately
4011
+ // from generic spawn failures.
4012
+ status: z.enum(["started", "failed", "rejected", "runtime_capability_check_failed"]),
2107
4013
  error: z.string().max(1000).optional(),
2108
4014
  child_pid: z.number().int().optional(),
4015
+ runtime: z.string().max(64).optional(), // populated by daemon when status=runtime_capability_check_failed
2109
4016
  },
2110
- async ({ request_id, status, error: ackError, child_pid: _pid }) => {
4017
+ async ({ request_id, status, error: ackError, child_pid: _pid, runtime: ackRuntime }) => {
2111
4018
  const callerDaemon = resolveCallerDaemonTokenBound();
2112
4019
  if (!callerDaemon.ok) {
2113
4020
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
@@ -2133,7 +4040,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2133
4040
  );
2134
4041
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "awaiting_register" }) }] };
2135
4042
  }
2136
- // failed / rejected — revoke child-ntok + mark request terminal
4043
+ // failed / rejected / runtime_capability_check_failed — revoke
4044
+ // child-ntok + mark request terminal.
2137
4045
  if (row.child_token_id) {
2138
4046
  db.run(`UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = ?1 AND revoked_at IS NULL`, [row.child_token_id]);
2139
4047
  }
@@ -2141,10 +4049,817 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2141
4049
  `UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3 WHERE request_id = ?4 AND status IN ('pending', 'delivered')`,
2142
4050
  [status, ackError || null, ackedAt, request_id],
2143
4051
  );
4052
+ // RFC-026 §9.3 D2 — surface declaration↔reality gap on a
4053
+ // distinct audit_log action so dashboards / operators can spot
4054
+ // chronically-lying daemons separate from generic spawn-failed.
4055
+ if (status === "runtime_capability_check_failed") {
4056
+ auditCreateNode({
4057
+ action: "daemon_capability_lied",
4058
+ user_id: null,
4059
+ network_id: row.network_id,
4060
+ target_id: request_id,
4061
+ detail: {
4062
+ daemon_node_id: row.daemon_node_id,
4063
+ runtime: ackRuntime || null,
4064
+ error: ackError ? ackError.slice(0, 500) : null,
4065
+ acked_at: ackedAt,
4066
+ },
4067
+ });
4068
+ }
2144
4069
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
2145
4070
  },
2146
4071
  );
2147
4072
 
4073
+ // ─── RFC-027 §2 — stop/delete node lifecycle ───────────────────────
4074
+ //
4075
+ // Two user-facing MCP tools (stop_node, delete_node) + two daemon-
4076
+ // facing tools (get_stop_request, ack_stop_request). State machine
4077
+ // per §2.3 — nodes.lifecycle_state ∈ {active, stopping, stopped,
4078
+ // deleting}. row-gone implies deleted. Security per §4:
4079
+ // §4.1 SEC-1: trust-root SQL join, not getNetworkId
4080
+ // §4.2 D6: delete_node refuses target.role==host_supervisor
4081
+ // §4.3 D4: in-flight inbox default-refuse + force+audit
4082
+ // §4.4 D7: daemon writes backup chmod 700, sweeper真删
4083
+ // §4.5 D8: audit_log in same SQLite tx as state UPDATE
4084
+ //
4085
+ // The dispatcher logic shared by stop_node + delete_node lives in
4086
+ // dispatchStopOrDelete; the two tool handlers thin-wrap it with
4087
+ // their respective action discriminator.
4088
+ type DispatchAction = "stop" | "delete";
4089
+ type DispatchArgs = {
4090
+ action: DispatchAction;
4091
+ child_node_id: string;
4092
+ daemon_node_id: string;
4093
+ force: boolean;
4094
+ grace_seconds: number;
4095
+ delete_config: boolean;
4096
+ confirm_alias?: string;
4097
+ };
4098
+ // RFC-027 PR2 prereq — auto-resolve daemon_node_id from child_node_id.
4099
+ // The dashboard (and most callers of stop_node / delete_node) shouldn't
4100
+ // need to track the daemon→child mapping themselves; the hub has it on
4101
+ // node_create_requests at child creation time. Returns null when no
4102
+ // creation record exists (orphan node row OR pre-RFC-026 node) — caller
4103
+ // must then pass daemon_node_id explicitly. node_id derivation:
4104
+ // `node_${request_id.replace(/^cr_/,"")}` (see create-node-daemon.ts
4105
+ // and PR1 BLOCKER-1 fix), so we reverse it: `cr_${node_id.slice(5)}`.
4106
+ const resolveDaemonForChild = (child_node_id: string): string | null => {
4107
+ if (!child_node_id.startsWith("node_")) return null;
4108
+ const requestId = `cr_${child_node_id.slice(5)}`;
4109
+ const row = db.get<{ daemon_node_id: string }>(
4110
+ `SELECT daemon_node_id FROM node_create_requests WHERE request_id = ?1`,
4111
+ requestId,
4112
+ );
4113
+ return row?.daemon_node_id ?? null;
4114
+ };
4115
+
4116
+ /**
4117
+ * 为什么这里要分两种「解析不到 daemon」(#196 / app 仓)。
4118
+ *
4119
+ * `resolveDaemonForChild` 对两件完全不同的事都返回 `null`:
4120
+ * ① id 不以 `node_` 开头 —— 这个节点**根本不是 daemon 创建的**,
4121
+ * 是有人在某台机器上直接 `anet node start` 起来的。hub 上没有任何
4122
+ * daemon 可以代它执行停止,**这条路径对它在概念上就不成立**。
4123
+ * ② id 以 `node_` 开头,但 `node_create_requests` 里查不到那一行 ——
4124
+ * 它可能确实是 daemon 建的,只是记录缺失,这时「显式传 daemon_node_id」
4125
+ * 是一条真的走得通的路。
4126
+ *
4127
+ * 🔴 原先两种情况打印同一句 `pass daemon_node_id explicitly`。对 ① 来说
4128
+ * **这句建议的前提是假的** —— 没有 daemon 可传,用户按它做只会走进死路。
4129
+ * 2026-08-28 对生产 hub 实测:218 个节点里 **207 个**是 ① 这种。
4130
+ *
4131
+ * 信息在源头就存在(那个 `startsWith("node_")` 判断),只是被丢在了返回值里。
4132
+ */
4133
+ const explainUnresolvableDaemon = (child_node_id: string) =>
4134
+ child_node_id.startsWith("node_")
4135
+ ? {
4136
+ ok: false, error: "daemon_not_resolvable",
4137
+ message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
4138
+ }
4139
+ : {
4140
+ ok: false, error: "not_daemon_managed",
4141
+ message:
4142
+ `node ${child_node_id} was not created by a daemon (it was started by hand with ` +
4143
+ `\`anet node start\` on some machine), so no daemon on the Hub can stop it. ` +
4144
+ `Run \`anet node stop <alias>\` on that machine instead. ` +
4145
+ `Daemon-created children have ids beginning with \`node_\`; this one does not.`,
4146
+ };
4147
+
4148
+ const dispatchStopOrDelete = (args: DispatchArgs, clientNetId?: string | null) => {
4149
+ // §4.1 — SEC-1 trust-root join: resolve target node row WITHIN the
4150
+ // caller's scope. resolveReadScope returns 'denied' if the caller
4151
+ // doesn't belong to clientNetId; for the row-existence test we
4152
+ // re-join with member's networks so an attacker can't probe by
4153
+ // node_id from a network they don't belong to.
4154
+ const scope = resolveReadScope(clientNetId);
4155
+ if (scope.denied) {
4156
+ return { ok: false, error: "forbidden_cross_tenant", message: scope.denied };
4157
+ }
4158
+ const nodeQ: any[] = [args.child_node_id];
4159
+ let nodeSql = `SELECT node_id, alias, network_id, lifecycle_state, config_snapshot, runtimes_supported
4160
+ FROM nodes WHERE node_id = ?1`;
4161
+ nodeSql = addReadScope(nodeSql, nodeQ, scope);
4162
+ const node = db.get<{
4163
+ node_id: string;
4164
+ alias: string;
4165
+ network_id: string;
4166
+ lifecycle_state: string | null;
4167
+ config_snapshot: string | null;
4168
+ }>(nodeSql, ...nodeQ);
4169
+ if (!node) return { ok: false, error: "forbidden_cross_tenant", message: "node not found in your networks" };
4170
+
4171
+ // Caller must hold a write-capable role on the resolved network
4172
+ // (admin/owner/member; viewer denied). canWrite walks getUserNetworkRole.
4173
+ if (!canWrite(node.network_id)) {
4174
+ return { ok: false, error: "permission_denied", message: "viewer role cannot stop/delete nodes" };
4175
+ }
4176
+
4177
+ // §4.2 D6 — delete refuses targets whose role is host_supervisor.
4178
+ // Stop is allowed against any node per RFC table (host_supervisor's
4179
+ // stop is functionally a no-op anyway: the daemon's children_map
4180
+ // only tracks its child nodes, never the daemon itself).
4181
+ //
4182
+ // PR1 SF-4 (#345 review) — fail-CLOSED on role read. The first cut
4183
+ // parsed config_snapshot and treated parse-fail / missing-field as
4184
+ // role=null which slipped through the gate. A daemon row with a
4185
+ // corrupt snapshot would then be deletable via delete_node. Fix:
4186
+ // - read role via two independent paths (snapshot.role + the
4187
+ // indexed `runtimes_supported` column whose presence ≈ daemon)
4188
+ // - if EITHER path indicates host_supervisor → refuse
4189
+ // - if snapshot parsed but role missing AND the node has any
4190
+ // daemon-shape evidence (non-null runtimes_supported column,
4191
+ // present in any node_create_requests as daemon_node_id) →
4192
+ // refuse defensively. The defense-in-depth daemon-side
4193
+ // children_map miss is the second layer; the hub gate must
4194
+ // itself fail-closed when role is ambiguous.
4195
+ if (args.action === "delete") {
4196
+ let role: string | null = null;
4197
+ let snapshotParseFailed = false;
4198
+ try {
4199
+ const snap = node.config_snapshot ? JSON.parse(node.config_snapshot) : null;
4200
+ role = typeof snap?.role === "string" ? snap.role : null;
4201
+ } catch { snapshotParseFailed = true; }
4202
+ const ambiguous = snapshotParseFailed || (role == null);
4203
+ // Independent corroborating check: nodes flagged as daemons by
4204
+ // create_node dispatchers will show up as daemon_node_id in
4205
+ // node_create_requests. Costs one indexed query.
4206
+ let looksLikeDaemon = false;
4207
+ if (ambiguous) {
4208
+ const m = db.get<{ n: number }>(
4209
+ `SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1`,
4210
+ node.node_id,
4211
+ );
4212
+ if ((m?.n ?? 0) > 0) looksLikeDaemon = true;
4213
+ }
4214
+ if (role === "host_supervisor" || (ambiguous && looksLikeDaemon)) {
4215
+ return {
4216
+ ok: false, error: "cannot_delete_daemon_via_delete_node",
4217
+ message: snapshotParseFailed
4218
+ ? "node config_snapshot unreadable; node is referenced as a daemon — refuse delete defensively (use delete_daemon path)"
4219
+ : "host_supervisor daemons must be removed via the dedicated delete_daemon path (RFC-027.5)",
4220
+ };
4221
+ }
4222
+ }
4223
+
4224
+ // delete_node confirm_alias gate: dashboard's二次确认 input must
4225
+ // match the actual alias byte-for-byte. Hub rejects mismatched
4226
+ // input even if dashboard UI claims it's disabled.
4227
+ if (args.action === "delete") {
4228
+ if (typeof args.confirm_alias !== "string" || args.confirm_alias !== node.alias) {
4229
+ return { ok: false, error: "confirm_alias_mismatch", message: "confirm_alias must equal the node's alias" };
4230
+ }
4231
+ }
4232
+
4233
+ // Daemon must exist + be in the same network. We do NOT enforce
4234
+ // daemon.role==host_supervisor here: a child whose creator daemon
4235
+ // got demoted should still be stoppable. The daemon will refuse
4236
+ // with noop_not_my_child if children_map doesn't know it.
4237
+ //
4238
+ // PR1.2 e2e catch (BLOCKER): pushEvent keys SSE by clientKey ≈
4239
+ // `${networkId}:${sessionName}`, and agent-node registers its SSE
4240
+ // connection under its ALIAS (not its node_id). The create_node
4241
+ // doorbell at line ~2197 correctly pushes via `daemon.alias`;
4242
+ // stop_node was pushing via `daemon_node_id` so every doorbell
4243
+ // missed every SSE client → daemon never woke up → request_id
4244
+ // stayed 'pending' forever → child never reaped. The 24 unit tests
4245
+ // in stop-delete-node.test.ts all mock pushEvent and inject the
4246
+ // post-doorbell handler call directly, so single-unit coverage
4247
+ // couldn't surface this — exactly the failure class 通信龙 said
4248
+ // the docker e2e gate exists to catch (BLOCKER-1 同源). Load the
4249
+ // alias along with node_id + network_id and dispatch to the alias.
4250
+ const daemon = db.get<{ node_id: string; alias: string; network_id: string }>(
4251
+ `SELECT node_id, alias, network_id FROM nodes WHERE node_id = ?1`, args.daemon_node_id,
4252
+ );
4253
+ if (!daemon) return { ok: false, error: "daemon_not_found" };
4254
+ if (daemon.network_id !== node.network_id) {
4255
+ // Cross-tenant child↔daemon mismatch shouldn't be possible in
4256
+ // normal flow but is a SEC-1 hardening — refuse loud.
4257
+ return { ok: false, error: "daemon_cross_tenant", message: "daemon and child are in different networks" };
4258
+ }
4259
+
4260
+ // #1448 finding-4 — daemon 必须是该 child 的**权威创建者**,镜像 start_node 的
4261
+ // daemon_child_mismatch。此前 stop/delete 接受调用方传入的 daemon_node_id,只校验
4262
+ // 它存在 + 同网,不验它真的创建了这个 child。同网调用方传错 daemon_node_id → 门铃
4263
+ // 路由到错 daemon。🔴 f3(#1453) 合入后更糟:错 daemon 无该 child 的 map entry → 走
4264
+ // 收敛分支 sweep(错机器 pgrep 空) + ack stopped → hub 假收敛 stopped、child 仍在
4265
+ // 正确机器上跑(旧 noop 至少留下可见的卡死态)。网络 scope 只挡跨租户,网内是 footgun。
4266
+ //
4267
+ // 用「确定性不匹配」判据:authority 已知(child 有 create 记录)且不等 → 拒。不对
4268
+ // authority==null(create 记录被裁剪的旧 child)加拒,避免给它们的 stop/delete 引入
4269
+ // 新失败面——handler 的 daemon_node_id??resolveDaemonForChild 已兜住无法解析的情形。
4270
+ const authoritativeDaemonId = resolveDaemonForChild(args.child_node_id);
4271
+ if (authoritativeDaemonId && authoritativeDaemonId !== args.daemon_node_id) {
4272
+ return { ok: false, error: "daemon_child_mismatch", message: "daemon_node_id is not this child's authoritative creator daemon" };
4273
+ }
4274
+
4275
+ // State machine gate.
4276
+ const state = node.lifecycle_state ?? "active";
4277
+ if (args.action === "stop" && state !== "active") {
4278
+ return { ok: false, error: state === "stopping" ? "node_already_stopping" : "node_not_active", current_state: state };
4279
+ }
4280
+ if (args.action === "delete" && state === "deleting") {
4281
+ // #1286 —— stale-deleting 重派出口。
4282
+ //
4283
+ // 在 .40 埋点修复之前,daemon 丢失 ack 会让行**永远**停在 deleting:
4284
+ // 重发 delete 被这里挡住,force=true 也一样 —— 状态机没有出口,
4285
+ // 2026-08-28 实测两行卡死(acc-…-3373 / probe-tl2),API 层面无解。
4286
+ //
4287
+ // 出口的三个条件,缺一不放行:
4288
+ // ① 调用方显式 force=true(不改默认行为 —— 正常并发的重复 delete 仍被拒)
4289
+ // ② 该 child 最近一条 delete 请求不是终态(pending/dispatched)
4290
+ // —— 终态说明 daemon 刚 ack 过,行马上会转移,不该重派
4291
+ // ③ 那条请求已经晾了超过 STALE_DELETING_MS —— 🔴 不是"刚派发就能 force":
4292
+ // 给正常的 doorbell→ack 往返留时间,否则 force 变成竞态放大器。
4293
+ const STALE_DELETING_MS = 5 * 60_000;
4294
+ const last = db.get<{ request_id: string; status: string; created_at: number }>(
4295
+ `SELECT request_id, status, created_at FROM node_stop_requests
4296
+ WHERE child_node_id = ?1 AND action = 'delete'
4297
+ ORDER BY created_at DESC LIMIT 1`, node.node_id,
4298
+ );
4299
+ const lastIsStale = !!last
4300
+ && (last.status === "pending" || last.status === "dispatched")
4301
+ && (Date.now() - last.created_at) > STALE_DELETING_MS;
4302
+ if (!(args.force === true && (lastIsStale || !last))) {
4303
+ return {
4304
+ ok: false, error: "node_already_deleting", current_state: state,
4305
+ // 🔴 告诉调用方出口在哪,而不是让他反复撞同一面墙。
4306
+ ...(last ? { last_request_id: last.request_id, last_request_status: last.status } : {}),
4307
+ hint: last && (last.status === "pending" || last.status === "dispatched")
4308
+ ? `再等一下;若超过 ${STALE_DELETING_MS / 60000} 分钟仍未收敛,带 force=true 重试可重新派发`
4309
+ : "上一条请求已终态,行应即将转移;稍后重查",
4310
+ };
4311
+ }
4312
+ // 放行:走正常派发路径,生成新 request_id。旧请求行保留(审计线索),
4313
+ // daemon 侧对未知 request 的 get_stop_request 会拿到 request_not_found,无副作用。
4314
+ }
4315
+ if (args.action === "delete" && state === "stopping") {
4316
+ // Can't start delete while a stop is mid-flight — race-prone.
4317
+ return { ok: false, error: "node_stopping_in_progress", current_state: state };
4318
+ }
4319
+
4320
+ // §4.3 D4 — in-flight inbox check. Count unacked tasks routed to
4321
+ // this node's alias (inbox routing uses session_name == alias).
4322
+ // Default refuse with the count surfaced; force=true overrides
4323
+ // and triggers the forced_stop_with_in_flight audit row.
4324
+ // SF-5 (#345 review): legacy inbox rows may have network_id=NULL
4325
+ // and would silently miss a network_id=?2 strict equality match.
4326
+ // Use COALESCE so a NULL inbox row is counted against the same
4327
+ // network as the target node (which is the only safe default
4328
+ // — pre-multi-network rows existed in single-network mode).
4329
+ const inFlightRow = db.get<{ n: number }>(
4330
+ `SELECT COUNT(*) AS n FROM inbox WHERE session_name = ?1 AND acked = 0
4331
+ AND COALESCE(network_id, ?2) = ?2`,
4332
+ node.alias, node.network_id,
4333
+ );
4334
+ const inFlight = inFlightRow?.n ?? 0;
4335
+ if (inFlight > 0 && !args.force) {
4336
+ return { ok: false, error: "node_busy_in_flight", in_flight_count: inFlight, hint: "set force=true to override (audit logged)" };
4337
+ }
4338
+
4339
+ // ── all gates passed; create request + transition state + push doorbell, transactionally ──
4340
+ const requestId = generateId("sr");
4341
+ const now = Date.now();
4342
+ const newState = args.action === "stop" ? "stopping" : "deleting";
4343
+
4344
+ // #1448 finding-5 — 记下该 child **当前活着的** ntok 的 token_id,让 ack 精确按
4345
+ // token_id 撤销,而非按 name='node:<alias>' 广撤。后者会在「同 alias 节点在这次
4346
+ // delete 未收敛的窗口内被重建(拿到同名新 token)」时,把新 token 一并误撤。这里
4347
+ // 在派发时刻定住身份;若此刻没有活 token(异常),存 null,ack 退回按 name 撤(旧语义)。
4348
+ const childTokenRow = db.get<{ token_id: string }>(
4349
+ `SELECT token_id FROM api_tokens WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
4350
+ node.network_id, `node:${node.alias}`,
4351
+ );
4352
+ const childTokenId = childTokenRow?.token_id ?? null;
4353
+
4354
+ // §4.5 D8 — lifecycle UPDATE + audit INSERTs + request INSERT atomic.
4355
+ // PR1.1: use db.transaction() (SQLiteAdapter wraps better-sqlite3's
4356
+ // native transaction; PgAdapter ships a real BEGIN/COMMIT/ROLLBACK
4357
+ // shim). Lets us drop the open-coded BEGIN/COMMIT + makes the
4358
+ // SF-2 failure-injection test (mock db.run throw inside callback)
4359
+ // reliable across both backends.
4360
+ try {
4361
+ db.transaction(() => {
4362
+ db.run(
4363
+ `INSERT INTO node_stop_requests
4364
+ (request_id, network_id, daemon_node_id, child_node_id, child_alias, child_token_id, action,
4365
+ delete_config, grace_seconds, force, in_flight_at_dispatch, created_by_token,
4366
+ status, created_at)
4367
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'pending', ?13)`,
4368
+ [
4369
+ requestId, node.network_id, args.daemon_node_id, node.node_id, node.alias, childTokenId, args.action,
4370
+ args.delete_config ? 1 : 0, args.grace_seconds, args.force ? 1 : 0, inFlight,
4371
+ callerTokenId || "unknown", now,
4372
+ ],
4373
+ );
4374
+ db.run(
4375
+ `UPDATE nodes SET lifecycle_state = ?1 WHERE node_id = ?2`,
4376
+ [newState, node.node_id],
4377
+ );
4378
+ // §4.5 D8 — audit dispatch action. Uses the STRICT variant so a
4379
+ // failed audit INSERT propagates and triggers ROLLBACK (PR1 SF-2
4380
+ // review catch — auditCreateNode's swallow-and-warn would have
4381
+ // committed the lifecycle UPDATE without an audit row, leaving
4382
+ // exactly the "deleted but no audit" window §4.5 closes).
4383
+ auditCreateNodeStrict({
4384
+ action: args.action === "stop" ? "stop_node_dispatched" : "delete_node_dispatched",
4385
+ user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
4386
+ detail: {
4387
+ child_node_id: node.node_id, child_alias: node.alias,
4388
+ daemon_node_id: args.daemon_node_id, action: args.action,
4389
+ force: args.force, delete_config: args.delete_config,
4390
+ grace_seconds: args.grace_seconds, in_flight_at_dispatch: inFlight,
4391
+ lifecycle_state_before: state, lifecycle_state_after: newState,
4392
+ ts_request: now,
4393
+ },
4394
+ });
4395
+ if (args.force && inFlight > 0) {
4396
+ auditCreateNodeStrict({
4397
+ action: "forced_stop_with_in_flight",
4398
+ user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
4399
+ detail: { in_flight_count: inFlight, child_alias: node.alias },
4400
+ });
4401
+ }
4402
+ }); // end db.transaction
4403
+ } catch (e: any) {
4404
+ return { ok: false, error: "dispatch_tx_failed", message: e?.message || String(e) };
4405
+ }
4406
+
4407
+ // SSE doorbell — daemon will pull via get_stop_request.
4408
+ // PR1.2 e2e fix: route by daemon.alias (see SELECT above for the
4409
+ // BLOCKER explanation). Mirrors create_node's pushEvent target at
4410
+ // line ~2197.
4411
+ pushEvent(daemon.alias, { type: "stop_node", request_id: requestId }, node.network_id);
4412
+ return {
4413
+ ok: true, request_id: requestId, action: args.action,
4414
+ lifecycle_state: newState, in_flight_at_dispatch: inFlight,
4415
+ };
4416
+ };
4417
+
4418
+ server.tool(
4419
+ "stop_node",
4420
+ "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).",
4421
+ {
4422
+ ...NODE_ID_ALIAS_FIELDS,
4423
+ // PR2 prereq: dashboard rarely knows daemon_node_id directly.
4424
+ // When omitted, hub resolves from the original creation record
4425
+ // (node_create_requests.daemon_node_id keyed by this child).
4426
+ daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
4427
+ force: z.boolean().optional().default(false),
4428
+ grace_seconds: z.number().int().min(5).max(60).optional().default(10),
4429
+ network_id: z.string().max(200).optional(),
4430
+ },
4431
+ async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, force, grace_seconds, network_id: clientNetId }) => {
4432
+ const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
4433
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
4434
+ const child_node_id = idArg.node_id;
4435
+ const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
4436
+ if (!resolved) {
4437
+ return { content: [{ type: "text" as const,
4438
+ text: JSON.stringify(explainUnresolvableDaemon(child_node_id)) }] };
4439
+ }
4440
+ const r = dispatchStopOrDelete(
4441
+ { action: "stop", child_node_id, daemon_node_id: resolved, force: force ?? false,
4442
+ grace_seconds: grace_seconds ?? 10, delete_config: false },
4443
+ clientNetId,
4444
+ );
4445
+ return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
4446
+ },
4447
+ );
4448
+
4449
+ server.tool(
4450
+ "delete_node",
4451
+ "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.",
4452
+ {
4453
+ ...NODE_ID_ALIAS_FIELDS,
4454
+ daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
4455
+ confirm_alias: z.string().min(1).max(200),
4456
+ force: z.boolean().optional().default(false),
4457
+ grace_seconds: z.number().int().min(5).max(60).optional().default(10),
4458
+ delete_config: z.boolean().optional().default(true),
4459
+ network_id: z.string().max(200).optional(),
4460
+ },
4461
+ async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, confirm_alias, force, grace_seconds, delete_config, network_id: clientNetId }) => {
4462
+ const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
4463
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
4464
+ const child_node_id = idArg.node_id;
4465
+ const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
4466
+ if (!resolved) {
4467
+ return { content: [{ type: "text" as const,
4468
+ text: JSON.stringify(explainUnresolvableDaemon(child_node_id)) }] };
4469
+ }
4470
+ const r = dispatchStopOrDelete(
4471
+ { action: "delete", child_node_id, daemon_node_id: resolved, force: force ?? false,
4472
+ grace_seconds: grace_seconds ?? 10, delete_config: delete_config ?? true, confirm_alias },
4473
+ clientNetId,
4474
+ );
4475
+ return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
4476
+ },
4477
+ );
4478
+
4479
+ server.tool(
4480
+ "get_stop_request",
4481
+ "Daemon pulls a pending stop/delete request (called when SSE stop_node doorbell arrives). RFC-027 §2.4.",
4482
+ {
4483
+ request_id: z.string().min(1).max(200),
4484
+ },
4485
+ async ({ request_id }) => {
4486
+ const callerDaemon = resolveCallerDaemonTokenBound();
4487
+ if (!callerDaemon.ok) {
4488
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4489
+ }
4490
+ const row = db.get<{
4491
+ daemon_node_id: string; status: string; network_id: string;
4492
+ child_node_id: string; child_alias: string; action: string;
4493
+ delete_config: number; grace_seconds: number; force: number;
4494
+ }>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, action,
4495
+ delete_config, grace_seconds, force
4496
+ FROM node_stop_requests WHERE request_id = ?1`, request_id);
4497
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
4498
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
4499
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
4500
+ }
4501
+ if (row.network_id !== callerDaemon.networkId) {
4502
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
4503
+ }
4504
+ // Stamp delivered_at on first pull (idempotent — only if still pending).
4505
+ db.run(
4506
+ `UPDATE node_stop_requests SET status = 'delivered', delivered_at = ?1
4507
+ WHERE request_id = ?2 AND status = 'pending'`,
4508
+ [Date.now(), request_id],
4509
+ );
4510
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4511
+ ok: true,
4512
+ request_id,
4513
+ child_node_id: row.child_node_id,
4514
+ child_alias: row.child_alias,
4515
+ action: row.action,
4516
+ delete_config: row.delete_config === 1,
4517
+ grace_seconds: row.grace_seconds,
4518
+ force: row.force === 1,
4519
+ }) }] };
4520
+ },
4521
+ );
4522
+
4523
+ server.tool(
4524
+ "ack_stop_request",
4525
+ "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.",
4526
+ {
4527
+ request_id: z.string().min(1).max(200),
4528
+ status: z.enum(["stopped", "stop_failed", "noop_not_my_child"]),
4529
+ exit_signal: z.string().max(16).optional(),
4530
+ backup_path: z.string().max(500).optional(),
4531
+ error: z.string().max(1000).optional(),
4532
+ },
4533
+ async ({ request_id, status, exit_signal, backup_path, error: ackError }) => {
4534
+ // #1358 —— 🔴 在此之前,这个工具的**每一条出口都是静默的**:到达不记、
4535
+ // 三条拒绝不记、成功收尾也不记。而隔壁 create-node 的收尾**是记的**
4536
+ // (`✓ create-node finalize`)。这种不对称最坑人:读日志的人看到 create 有、
4537
+ // delete 没有,第一反应是「delete 没发生」—— 但两者的日志覆盖面本来就不同,
4538
+ // 那条推论没有依据。排查 #1286 时我自己就差点这样读。
4539
+ console.log(`[commhub] ← ack_stop_request request=${request_id} status=${status}${exit_signal ? ` exit_signal=${exit_signal}` : ""}`);
4540
+ const callerDaemon = resolveCallerDaemonTokenBound();
4541
+ if (!callerDaemon.ok) {
4542
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4543
+ }
4544
+ const row = db.get<{
4545
+ daemon_node_id: string; status: string; network_id: string;
4546
+ child_node_id: string; child_alias: string; child_token_id: string | null; action: string;
4547
+ in_flight_at_dispatch: number; force: number;
4548
+ }>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, child_token_id, action,
4549
+ in_flight_at_dispatch, force
4550
+ FROM node_stop_requests WHERE request_id = ?1`, request_id);
4551
+ if (!row) {
4552
+ console.warn(`[commhub] ✕ ack_stop_request rejected: request_not_found request=${request_id}`);
4553
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
4554
+ }
4555
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
4556
+ console.warn(`[commhub] ✕ ack_stop_request rejected: not_your_request request=${request_id} row_daemon=${row.daemon_node_id} caller=${callerDaemon.daemonNodeId}`);
4557
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
4558
+ }
4559
+ if (row.network_id !== callerDaemon.networkId) {
4560
+ console.warn(`[commhub] ✕ ack_stop_request rejected: cross_network_request request=${request_id} row_net=${row.network_id} caller_net=${callerDaemon.networkId}`);
4561
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
4562
+ }
4563
+ const now = Date.now();
4564
+
4565
+ // §4.5 D8 — lifecycle UPDATE + request UPDATE + audit INSERT
4566
+ // (+ DELETE for delete action) atomic via db.transaction().
4567
+ // PR1.1: replaces hand-rolled BEGIN..COMMIT so PG adapter and
4568
+ // SQLite both get correct rollback semantics from one code path.
4569
+ try {
4570
+ db.transaction(() => {
4571
+ db.run(
4572
+ `UPDATE node_stop_requests SET status = ?1, error = ?2, exit_signal = ?3,
4573
+ backup_path = ?4, acked_at = ?5
4574
+ WHERE request_id = ?6`,
4575
+ [status, ackError || null, exit_signal || null, backup_path || null, now, request_id],
4576
+ );
4577
+ if (status === "stopped" && row.action === "stop") {
4578
+ db.run(`UPDATE nodes SET lifecycle_state = 'stopped' WHERE node_id = ?1`, [row.child_node_id]);
4579
+ auditCreateNodeStrict({
4580
+ action: "stop_node_completed",
4581
+ user_id: null, network_id: row.network_id, target_id: request_id,
4582
+ detail: {
4583
+ child_node_id: row.child_node_id, child_alias: row.child_alias,
4584
+ exit_signal: exit_signal || null, ts_daemon_ack: now,
4585
+ lifecycle_state_after: "stopped",
4586
+ },
4587
+ });
4588
+ } else if (status === "stopped" && row.action === "delete") {
4589
+ // Revoke the child's ntok, then DELETE the nodes row.
4590
+ // #1448 finding-5 — 精确按派发时定住的 child_token_id 撤(镜像
4591
+ // ack_create_request 的 token_id 精撤)。旧行(升级前派发,child_token_id
4592
+ // 为 NULL)fallback 回按 name='node:<alias>' 撤——旧语义,不引入回归。
4593
+ // 按 token_id 撤避免了「同 alias 在本次 delete 窗口内被重建、拿到同名新
4594
+ // token」时把新 token 一并误撤。
4595
+ if (row.child_token_id) {
4596
+ db.run(
4597
+ `UPDATE api_tokens SET revoked_at = datetime('now')
4598
+ WHERE token_id = ?1 AND revoked_at IS NULL`,
4599
+ [row.child_token_id],
4600
+ );
4601
+ } else {
4602
+ db.run(
4603
+ `UPDATE api_tokens SET revoked_at = datetime('now')
4604
+ WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
4605
+ [row.network_id, `node:${row.child_alias}`],
4606
+ );
4607
+ }
4608
+ db.run(`DELETE FROM nodes WHERE node_id = ?1`, [row.child_node_id]);
4609
+ auditCreateNodeStrict({
4610
+ action: "delete_node_completed",
4611
+ user_id: null, network_id: row.network_id, target_id: request_id,
4612
+ detail: {
4613
+ child_node_id: row.child_node_id, child_alias: row.child_alias,
4614
+ exit_signal: exit_signal || null, backup_path: backup_path || null,
4615
+ ts_daemon_ack: now, lifecycle_state_after: "deleted",
4616
+ },
4617
+ });
4618
+ } else if (status === "stop_failed") {
4619
+ db.run(`UPDATE nodes SET lifecycle_state = 'stop_failed' WHERE node_id = ?1`, [row.child_node_id]);
4620
+ // No completion audit; the failure error is on the request row.
4621
+ }
4622
+ // noop_not_my_child: leave lifecycle_state as-is; the hub-side
4623
+ // sweeper / next reconciliation will pick it up. Don't transition.
4624
+ });
4625
+ } catch (e: any) {
4626
+ console.warn(`[commhub] ✕ ack_stop_request finalize_tx_failed request=${request_id} action=${row.action} child=${row.child_alias}: ${e?.message || e}`);
4627
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "finalize_tx_failed", message: e?.message || String(e) }) }] };
4628
+ }
4629
+
4630
+ // 🔴 关键的是最后那半句「据此做了什么」。#1286 里 hub 的代码路径经核对是
4631
+ // 正确的,缺的正是「它到底走没走那条分支」—— 只说「收到了 ack」回答不了。
4632
+ const did = status === "stopped" && row.action === "delete"
4633
+ ? "revoked ntok + DELETE FROM nodes"
4634
+ : status === "stopped" && row.action === "stop"
4635
+ ? "lifecycle_state=stopped"
4636
+ : status === "stop_failed"
4637
+ ? "lifecycle_state=stop_failed"
4638
+ : "no lifecycle transition (noop_not_my_child)";
4639
+ console.log(`[commhub] ✓ ${row.action}-node finalize: request=${request_id} child=${row.child_alias} (${row.child_node_id}) status=${status} → ${did}`);
4640
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
4641
+ },
4642
+ );
4643
+
4644
+ // start_node is deliberately daemon-mediated. A stopped child has no SSE
4645
+ // consumer of its own, so restart_node's self-doorbell cannot revive it.
4646
+ server.tool(
4647
+ "start_node",
4648
+ "Start a stopped child through its host supervisor daemon. daemon_node_id is auto-resolved from the original create request.",
4649
+ {
4650
+ ...NODE_ID_ALIAS_FIELDS,
4651
+ daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
4652
+ network_id: z.string().max(200).optional(),
4653
+ },
4654
+ async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, network_id: clientNetId }) => {
4655
+ const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
4656
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
4657
+ const child_node_id = idArg.node_id;
4658
+ const resolvedDaemonId = daemon_node_id ?? resolveDaemonForChild(child_node_id);
4659
+ if (!resolvedDaemonId) {
4660
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4661
+ ok: false, error: "daemon_not_resolvable",
4662
+ message: "no create request maps this child to a daemon",
4663
+ }) }] };
4664
+ }
4665
+ const scope = resolveReadScope(clientNetId);
4666
+ if (scope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "forbidden_cross_tenant" }) }] };
4667
+ const q: any[] = [child_node_id];
4668
+ const child = db.get<{ node_id: string; alias: string; network_id: string; lifecycle_state: string | null }>(
4669
+ addReadScope(`SELECT node_id, alias, network_id, lifecycle_state FROM nodes WHERE node_id = ?1`, q, scope), ...q,
4670
+ );
4671
+ if (!child) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "forbidden_cross_tenant" }) }] };
4672
+ if (!canWrite(child.network_id)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
4673
+ // #1448 finding-2 — stale-starting reaper,对齐 update_node_config / restart_node
4674
+ // 的 60s single-flight stale-supersede。start 只受理 'stopped';一旦卡 'starting'
4675
+ // (门铃丢 / daemon 在 UPDATE 'starting' 后死),config/restart 有 reaper、delete 有
4676
+ // 5min force,唯独 start 裸奔 → 下面的 gate 永远拦、也无人把节点拉回 stopped →
4677
+ // 永久卡 'starting'。出口:'starting' 且最近一条 in-flight start 请求晾过阈值 → 标
4678
+ // 旧请求 timeout 超越、放行重派;未过阈值则拒 node_already_starting(带 age)。
4679
+ const priorState = child.lifecycle_state ?? "active";
4680
+ if (priorState !== "stopped") {
4681
+ if (priorState !== "starting") {
4682
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_stopped", current_state: priorState }) }] };
4683
+ }
4684
+ const STALE_STARTING_MS = 60_000;
4685
+ const inFlight = db.get<{ request_id: string; created_at: number; delivered_at: number | null; acked_at: number | null }>(
4686
+ `SELECT request_id, created_at, delivered_at, acked_at FROM node_start_requests
4687
+ WHERE child_node_id = ?1 AND status IN ('pending', 'delivered')
4688
+ ORDER BY created_at DESC LIMIT 1`, child.node_id,
4689
+ );
4690
+ if (inFlight) {
4691
+ // Age anchor = COALESCE(acked_at, delivered_at, created_at),同 config reaper:
4692
+ // 已 pull/已 ack 的活着但慢的 start 会刷新存活时钟,不被误 reap。
4693
+ const ageAnchor = inFlight.acked_at ?? inFlight.delivered_at ?? inFlight.created_at;
4694
+ const age = Date.now() - ageAnchor;
4695
+ if (age <= STALE_STARTING_MS) {
4696
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4697
+ ok: false, error: "node_already_starting",
4698
+ existing_request_id: inFlight.request_id, age_ms: age,
4699
+ hint: `再等一下;超过 ${STALE_STARTING_MS / 1000}s 仍卡 starting,重试 start 会重新派发`,
4700
+ }) }] };
4701
+ }
4702
+ // Stale — 标旧请求 timeout(autocommit,先于下面 tx 的新 INSERT,解除
4703
+ // node_start_requests 对 pending/delivered 的 child 唯一约束),放行重派。
4704
+ db.run(
4705
+ `UPDATE node_start_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3`,
4706
+ [Date.now(), `superseded by new start after ${age}ms stale (> ${STALE_STARTING_MS}ms threshold)`, inFlight.request_id],
4707
+ );
4708
+ }
4709
+ // else: 'starting' 但无 in-flight 行(异常残留态)——照样放行重派以自愈。
4710
+ }
4711
+ // The caller may provide daemon_node_id, but it cannot override the
4712
+ // authoritative child->daemon relationship recorded at creation.
4713
+ const authoritativeDaemonId = resolveDaemonForChild(child_node_id);
4714
+ if (!authoritativeDaemonId || authoritativeDaemonId !== resolvedDaemonId) {
4715
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_child_mismatch" }) }] };
4716
+ }
4717
+ const daemon = db.get<{ node_id: string; alias: string; network_id: string }>(
4718
+ `SELECT node_id, alias, network_id FROM nodes WHERE node_id = ?1`, resolvedDaemonId,
4719
+ );
4720
+ if (!daemon) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found" }) }] };
4721
+ if (daemon.network_id !== child.network_id) {
4722
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_cross_tenant" }) }] };
4723
+ }
4724
+ const requestId = generateId("str");
4725
+ const now = Date.now();
4726
+ try {
4727
+ db.transaction(() => {
4728
+ db.run(
4729
+ `INSERT INTO node_start_requests
4730
+ (request_id, network_id, daemon_node_id, child_node_id, child_alias,
4731
+ created_by_token, status, created_at)
4732
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7)`,
4733
+ [requestId, child.network_id, daemon.node_id, child.node_id, child.alias, callerTokenId || "unknown", now],
4734
+ );
4735
+ // #1448 finding-2 — 也接受从 'starting' 重派(stale-supersede 后原态仍是
4736
+ // starting);两种入态都合法地转/留到 'starting'。
4737
+ db.run(`UPDATE nodes SET lifecycle_state = 'starting' WHERE node_id = ?1 AND lifecycle_state IN ('stopped', 'starting')`, [child.node_id]);
4738
+ auditCreateNodeStrict({
4739
+ action: "start_node_dispatched", user_id: enforceUserId,
4740
+ network_id: child.network_id, target_id: requestId,
4741
+ detail: { child_node_id: child.node_id, child_alias: child.alias, daemon_node_id: daemon.node_id,
4742
+ lifecycle_state_before: priorState, lifecycle_state_after: "starting", ts_request: now },
4743
+ });
4744
+ });
4745
+ } catch (e: any) {
4746
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "dispatch_tx_failed", message: e?.message || String(e) }) }] };
4747
+ }
4748
+ pushEvent(daemon.alias, { type: "start_node", request_id: requestId }, child.network_id);
4749
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, lifecycle_state: "starting" }) }] };
4750
+ },
4751
+ );
4752
+
4753
+ server.tool(
4754
+ "get_start_request",
4755
+ "Host supervisor pulls an authenticated pending start request.",
4756
+ { request_id: z.string().min(1).max(200) },
4757
+ async ({ request_id }) => {
4758
+ const callerDaemon = resolveCallerDaemonTokenBound();
4759
+ if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4760
+ const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
4761
+ `SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id = ?1`, request_id,
4762
+ );
4763
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
4764
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
4765
+ if (row.network_id !== callerDaemon.networkId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
4766
+ if (!['pending', 'delivered'].includes(row.status)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_startable", status: row.status }) }] };
4767
+ db.run(`UPDATE node_start_requests SET status='delivered', delivered_at=?1 WHERE request_id=?2 AND status='pending'`, [Date.now(), request_id]);
4768
+ 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 }) }] };
4769
+ },
4770
+ );
4771
+
4772
+ server.tool(
4773
+ "ack_start_request",
4774
+ "Host supervisor reports start completion or failure.",
4775
+ {
4776
+ request_id: z.string().min(1).max(200),
4777
+ status: z.enum(["started", "start_failed"]),
4778
+ child_pid: z.number().int().positive().optional(),
4779
+ error: z.string().max(1000).optional(),
4780
+ },
4781
+ async ({ request_id, status, child_pid, error: ackError }) => {
4782
+ const callerDaemon = resolveCallerDaemonTokenBound();
4783
+ if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4784
+ const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
4785
+ `SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id=?1`, request_id,
4786
+ );
4787
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
4788
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
4789
+ if (row.network_id !== callerDaemon.networkId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
4790
+ if (row.status === "started" || row.status === "start_failed") {
4791
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: row.status, idempotent: true }) }] };
4792
+ }
4793
+ if (!['pending', 'delivered'].includes(row.status)) {
4794
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_ackable", status: row.status }) }] };
4795
+ }
4796
+ if (status === "started" && !child_pid) {
4797
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "child_pid_required" }) }] };
4798
+ }
4799
+ const now = Date.now();
4800
+ try {
4801
+ db.transaction(() => {
4802
+ 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]);
4803
+ db.run(`UPDATE nodes SET lifecycle_state=?1 WHERE node_id=?2`, [status === "started" ? "active" : "stopped", row.child_node_id]);
4804
+ if (status === "started") auditCreateNodeStrict({
4805
+ action: "start_node_completed", network_id: row.network_id, target_id: request_id,
4806
+ detail: { child_node_id: row.child_node_id, child_alias: row.child_alias, child_pid, lifecycle_state_after: "active", ts_daemon_ack: now },
4807
+ });
4808
+ });
4809
+ } catch (e: any) {
4810
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "finalize_tx_failed", message: e?.message || String(e) }) }] };
4811
+ }
4812
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
4813
+ },
4814
+ );
4815
+
4816
+ // RFC-027 PR1.1 — list_my_children: daemon-only query. Returns the
4817
+ // {child_node_id, alias, lifecycle_state} tuples for nodes whose
4818
+ // active create-request has this daemon as daemon_node_id. Used at
4819
+ // daemon boot to rebuild the in-memory childrenMap (PR1 dropped
4820
+ // every entry on daemon restart → stop/delete silently no-op'd).
4821
+ //
4822
+ // Network-scope is the daemon's own (resolveCallerDaemonTokenBound
4823
+ // already binds tokenIsNetwork to a single network). No SEC-1 leak:
4824
+ // returns ONLY children whose request landed under this daemon's
4825
+ // token + network.
4826
+ server.tool(
4827
+ "list_my_children",
4828
+ "Daemon pulls the alias + node_id list of children it spawned (for childrenMap rebuild after restart). RFC-027 PR1.1.",
4829
+ {},
4830
+ async () => {
4831
+ const callerDaemon = resolveCallerDaemonTokenBound();
4832
+ if (!callerDaemon.ok) {
4833
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4834
+ }
4835
+ // node_create_requests carries the canonical authoritative
4836
+ // child→daemon mapping (the row id IS the request, and the
4837
+ // child node_id derives from it deterministically via
4838
+ // `node_${request_id.replace(/^cr_/, "")}`). Filter to children
4839
+ // that completed registration (have a nodes row) so we don't
4840
+ // ask the daemon to pgrep for children that never came up.
4841
+ const rows = db.all<{ request_id: string; child_name: string; child_node_id: string; lifecycle_state: string | null }>(
4842
+ `SELECT ncr.request_id, ncr.child_name,
4843
+ ('node_' || substr(ncr.request_id, 4)) AS child_node_id,
4844
+ n.lifecycle_state
4845
+ FROM node_create_requests ncr
4846
+ LEFT JOIN nodes n ON n.node_id = ('node_' || substr(ncr.request_id, 4))
4847
+ WHERE ncr.daemon_node_id = ?1
4848
+ AND ncr.network_id = ?2
4849
+ AND ncr.status IN ('succeeded', 'delivered')
4850
+ AND n.node_id IS NOT NULL
4851
+ AND COALESCE(n.lifecycle_state, 'active') NOT IN ('stopped', 'stop_failed')`,
4852
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
4853
+ );
4854
+ const children = rows.map(r => ({
4855
+ child_node_id: r.child_node_id,
4856
+ alias: r.child_name,
4857
+ lifecycle_state: r.lifecycle_state ?? "active",
4858
+ }));
4859
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: children.length, children }) }] };
4860
+ },
4861
+ );
4862
+
2148
4863
  // ── RFC-028 P1 — Provider & Model Registry + connectivity probe ──
2149
4864
  // Background timers (probe GC + sweeper) idempotent; safe to call
2150
4865
  // per registerTools.
@@ -2373,6 +5088,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2373
5088
  const willReplaceModels = patch.models !== undefined;
2374
5089
  const newModelIds: string[] = [];
2375
5090
 
5091
+ // No-op early return (通信牛 nit): all patch fields matched the
5092
+ // existing row values + no models supplied → no real change to
5093
+ // persist. Returning here means we don't write an empty-diff
5094
+ // audit row (audit noise). Audit_log INSERT must only fire when
5095
+ // something actually changed.
5096
+ if (sets.length === 0 && !willReplaceModels) {
5097
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id, no_changes: true, hint: "patch fields matched existing row, no-op" }) }] };
5098
+ }
5099
+
2376
5100
  try {
2377
5101
  db.exec("BEGIN");
2378
5102
  if (sets.length > 0) {
@@ -2411,10 +5135,6 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2411
5135
  throw e;
2412
5136
  }
2413
5137
 
2414
- if (Object.keys(diff).length === 0 && !willReplaceModels) {
2415
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id, no_changes: true, hint: "patch fields matched existing row, no-op" }) }] };
2416
- }
2417
-
2418
5138
  return { content: [{ type: "text" as const, text: JSON.stringify({
2419
5139
  ok: true, provider_id,
2420
5140
  fields_changed: Object.keys(diff),
@@ -2624,6 +5344,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2624
5344
  export interface UpsertNodeWithSec1GuardInput {
2625
5345
  node_id: string;
2626
5346
  callerNetworkId: string | null;
5347
+ callerUserId?: string | null;
5348
+ callerTokenId?: string | null;
2627
5349
  node_name?: string | null;
2628
5350
  alias?: string | null;
2629
5351
  runtime?: string | null;
@@ -2636,19 +5358,60 @@ export interface UpsertNodeWithSec1GuardInput {
2636
5358
  }
2637
5359
  export type UpsertNodeOutcome =
2638
5360
  | { result: "inserted" | "updated"; node_id: string }
2639
- | { result: "refused"; reason: "cross_network"; existingNet: string | null; callerNet: string | null }
5361
+ | { result: "refused"; reason: "cross_network" | "token_node_mismatch" | "owner_mismatch"; existingNet: string | null; callerNet: string | null }
2640
5362
  | { result: "skipped"; reason: "missing_node_id" };
2641
5363
 
2642
5364
  const _norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x);
2643
5365
 
5366
+ /**
5367
+ * A capability that changes peer delivery semantics is trusted only when the
5368
+ * authenticating ntok is immutably bound to the exact reported node. Legacy
5369
+ * alias-only tokens may still heartbeat, but cannot opt a node into #698.
5370
+ */
5371
+ export function trustedConfigSnapshotForNode(
5372
+ snapshot: unknown | null | undefined,
5373
+ callerTokenId: string | null | undefined,
5374
+ nodeId: string,
5375
+ ): unknown | null | undefined {
5376
+ if (!snapshot || typeof snapshot !== "object") return snapshot;
5377
+ const copy = { ...(snapshot as Record<string, unknown>) };
5378
+ const token = callerTokenId
5379
+ ? db.get<{ bound_node_id: string | null }>(
5380
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1",
5381
+ callerTokenId,
5382
+ )
5383
+ : null;
5384
+ if (token?.bound_node_id !== nodeId) delete copy.peer_reply_inbox_capable;
5385
+ return copy;
5386
+ }
5387
+
2644
5388
  export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): UpsertNodeOutcome {
2645
5389
  if (!input.node_id) return { result: "skipped", reason: "missing_node_id" };
2646
- const existing = db.get<{ network_id: string | null }>(
2647
- "SELECT network_id FROM nodes WHERE node_id = ?1",
5390
+ const existing = db.get<{ network_id: string | null; owner_user_id: string | null }>(
5391
+ "SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1",
2648
5392
  input.node_id,
2649
5393
  );
2650
5394
  const callerNet = input.callerNetworkId;
2651
5395
 
5396
+ // RFC-036 — when a token was minted with a node_id binding, a heartbeat may
5397
+ // report only that exact node. Legacy unbound ntok rows remain compatible,
5398
+ // but they can never consume owner-gated schedule intents.
5399
+ if (input.callerTokenId) {
5400
+ const token = db.get<{ bound_node_id: string | null; user_id: string; network_id: string | null }>(
5401
+ "SELECT bound_node_id, user_id, network_id FROM api_tokens WHERE token_id = ?1",
5402
+ input.callerTokenId,
5403
+ );
5404
+ if (token?.bound_node_id && token.bound_node_id !== input.node_id) {
5405
+ return { result: "refused", reason: "token_node_mismatch", existingNet: existing?.network_id ?? null, callerNet };
5406
+ }
5407
+ if (token?.network_id && _norm(token.network_id) !== _norm(callerNet)) {
5408
+ return { result: "refused", reason: "cross_network", existingNet: existing?.network_id ?? null, callerNet };
5409
+ }
5410
+ }
5411
+ if (existing?.owner_user_id && input.callerUserId !== existing.owner_user_id) {
5412
+ return { result: "refused", reason: "owner_mismatch", existingNet: existing.network_id, callerNet };
5413
+ }
5414
+
2652
5415
  // Legacy / first-write paths: row missing OR network_id NULL → claim.
2653
5416
  const isLegacy = !existing
2654
5417
  || existing.network_id === null
@@ -2694,10 +5457,38 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
2694
5457
  callerNet ?? null,
2695
5458
  ],
2696
5459
  );
2697
- if (input.config_snapshot) {
5460
+ const trustedSnapshot = trustedConfigSnapshotForNode(
5461
+ input.config_snapshot,
5462
+ input.callerTokenId,
5463
+ input.node_id,
5464
+ );
5465
+ if (trustedSnapshot) {
5466
+ // RFC-026 §9.3 / #338 PR2+PR3 — promote daemon self-declare fields
5467
+ // to first-class indexable columns alongside the snapshot blob.
5468
+ // The snapshot stays the source of truth for non-list reads; the
5469
+ // columns exist so `list_host_supervisors` doesn't JSON.parse on
5470
+ // every call. typeof-narrow per
5471
+ // per team rule (typeof-narrow extracted JSON fields at the boundary) — zod narrowed but
5472
+ // input.config_snapshot is typed `unknown` here.
5473
+ //
5474
+ // PR3 nit ①: read from nested `daemon_capabilities.*` (canonical
5475
+ // per RFC §9.3 + matches existing hub create_node reads at
5476
+ // tools.ts:2010/2075). PR2 ate from top-level keys, which the
5477
+ // hub create_node path never read → max_concurrent_children
5478
+ // backpressure was dead config + allowlist enforcement bypassed.
5479
+ const snap = trustedSnapshot as Record<string, unknown> | null;
5480
+ const caps = (snap?.daemon_capabilities ?? null) as Record<string, unknown> | null;
5481
+ const runtimesRaw = caps?.runtimes_supported;
5482
+ const allowedRaw = caps?.allowed_secret_keys;
5483
+ const runtimesJson = Array.isArray(runtimesRaw) && runtimesRaw.every(s => typeof s === "string")
5484
+ ? JSON.stringify(runtimesRaw)
5485
+ : null;
5486
+ const allowedJson = Array.isArray(allowedRaw) && allowedRaw.every(s => typeof s === "string")
5487
+ ? JSON.stringify(allowedRaw)
5488
+ : null;
2698
5489
  db.run(
2699
- `UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
2700
- [JSON.stringify(input.config_snapshot), input.node_id],
5490
+ `UPDATE nodes SET config_snapshot = ?1, runtimes_supported = ?2, allowed_secret_keys = ?3 WHERE node_id = ?4`,
5491
+ [JSON.stringify(trustedSnapshot), runtimesJson, allowedJson, input.node_id],
2701
5492
  );
2702
5493
  // RFC-024 — finalize any pending/restarting update whose target
2703
5494
  // patch is now reflected in the snapshot. Closes the
@@ -2707,7 +5498,7 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
2707
5498
  // update_id to call ack_config_update(applied) itself. Hub does
2708
5499
  // it on the new child's behalf by content-matching the patch
2709
5500
  // against the reported snapshot.
2710
- finalizePendingMatchingUpdates(input.node_id, input.config_snapshot);
5501
+ finalizePendingMatchingUpdates(input.node_id, trustedSnapshot);
2711
5502
  }
2712
5503
  // RFC-026 §2.5 step 4 — on every register/report_status, opportunistically
2713
5504
  // close out any pending create_node request whose child_name matches
@@ -2767,6 +5558,15 @@ export function finalizePendingMatchingUpdates(
2767
5558
 
2768
5559
  const snapModel: string | null | undefined = snapshot?.model;
2769
5560
  const snapFlags: Record<string, unknown> = (snapshot?.flags && typeof snapshot.flags === "object") ? snapshot.flags : {};
5561
+ // #260 P5 — snapshot.channels is the (sorted, bare-type) channel set
5562
+ // the node currently has forked. agent-node's buildConfigSnapshot
5563
+ // always emits it (even as []), so the "absent field" case here means
5564
+ // an older pre-#260-P5 agent-node — for those, don't finalize a
5565
+ // channels-carrying patch (would false-positive on the OTHER fields).
5566
+ const snapChannelsPresent = Array.isArray(snapshot?.channels);
5567
+ const snapChannels: string[] = snapChannelsPresent
5568
+ ? (snapshot.channels as unknown[]).filter((c): c is string => typeof c === "string")
5569
+ : [];
2770
5570
 
2771
5571
  const finalizedIds: string[] = [];
2772
5572
 
@@ -2791,6 +5591,35 @@ export function finalizePendingMatchingUpdates(
2791
5591
  if (snapFlags[k] !== v) { matches = false; break; }
2792
5592
  }
2793
5593
  }
5594
+ // #260 P5 — channels field content-match. Without this the
5595
+ // node's very first startup report_status matches (patch.flags
5596
+ // is `{}` for a channels-only update, so the flags loop is
5597
+ // trivially satisfied) and hub prematurely marks the update
5598
+ // applied, deleting the pending row + bumping config_revision
5599
+ // BEFORE the doorbell reaches the child. Codex catch on PR #411.
5600
+ //
5601
+ // Set-equality is enough because the patch side comes from
5602
+ // narrowChannelsPatch → already deduped + case-folded + in
5603
+ // EDITABLE_CHANNELS iteration order; the snapshot side comes
5604
+ // from buildConfigSnapshot which mirrors the same shape.
5605
+ if (matches && Array.isArray(patch.channels)) {
5606
+ if (!snapChannelsPresent) {
5607
+ // Pre-#260-P5 agent-node — no channels field in snapshot,
5608
+ // so we can't prove the apply landed. Skip finalize; hub's
5609
+ // F-B reaper still supersedes this eventually.
5610
+ matches = false;
5611
+ } else {
5612
+ const wanted = new Set<string>(patch.channels);
5613
+ const have = new Set<string>(snapChannels);
5614
+ if (wanted.size !== have.size) {
5615
+ matches = false;
5616
+ } else {
5617
+ for (const w of wanted) {
5618
+ if (!have.has(w)) { matches = false; break; }
5619
+ }
5620
+ }
5621
+ }
5622
+ }
2794
5623
  }
2795
5624
 
2796
5625
  if (!matches) continue;