@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.70

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 (162) 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/agent-access.ts +267 -0
  8. package/src/agent-acl-http.test.ts +510 -0
  9. package/src/agent-acl-slice3-http.test.ts +267 -0
  10. package/src/alias-filter.test.ts +72 -0
  11. package/src/alias-filter.ts +32 -0
  12. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  13. package/src/api-host-supervisors-fallback.test.ts +271 -0
  14. package/src/api-nodes-shape.test.ts +26 -10
  15. package/src/auth-sessions-http.test.ts +250 -0
  16. package/src/auth-validate.test.ts +5 -1
  17. package/src/auth.ts +348 -29
  18. package/src/avatar-validate.test.ts +163 -0
  19. package/src/avatar-validate.ts +103 -0
  20. package/src/blocked-exit-on-reply.test.ts +122 -0
  21. package/src/boot-side-effect.test.ts +124 -0
  22. package/src/config-apply-sec1.test.ts +107 -0
  23. package/src/config-apply-validate.test.ts +92 -0
  24. package/src/config-apply-validate.ts +81 -3
  25. package/src/create-node-blocked-gate.test.ts +356 -0
  26. package/src/create-node-tool-schema.test.ts +39 -0
  27. package/src/create-node-validate.test.ts +89 -1
  28. package/src/create-node-validate.ts +89 -7
  29. package/src/create-node-workdir.test.ts +255 -0
  30. package/src/create-node.ts +51 -1
  31. package/src/dashboard-slash-routing-http.test.ts +107 -0
  32. package/src/db-adapter-guard.test.ts +85 -0
  33. package/src/db-adapter.ts +64 -23
  34. package/src/db-timestamp.test.ts +38 -0
  35. package/src/db-timestamp.ts +15 -0
  36. package/src/db.ts +762 -33
  37. package/src/delivered-without-push.test.ts +140 -0
  38. package/src/desktop-message-seam.test.ts +150 -0
  39. package/src/desktop-message.test.ts +368 -0
  40. package/src/ext-token-shared.test.ts +60 -0
  41. package/src/ext-validation-regression.test.ts +131 -0
  42. package/src/external-schedule-edits-http.test.ts +321 -0
  43. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  44. package/src/external-schedule-edits.ts +327 -0
  45. package/src/file-download-authz-dev-open.test.ts +135 -0
  46. package/src/file-download-authz.test.ts +404 -0
  47. package/src/file-download-date-bucket.test.ts +436 -0
  48. package/src/file-network-scope-dev-open.test.ts +97 -0
  49. package/src/file-network-scope.test.ts +732 -0
  50. package/src/health-redaction.test.ts +642 -0
  51. package/src/http-gzip.test.ts +73 -0
  52. package/src/http-gzip.ts +60 -0
  53. package/src/hub-timestamp.test.ts +38 -0
  54. package/src/hub-timestamp.ts +27 -0
  55. package/src/human-dm.ts +149 -0
  56. package/src/inbox-count-sse.test.ts +310 -0
  57. package/src/inbox-count.ts +29 -0
  58. package/src/index.ts +15 -2331
  59. package/src/lifecycle-guard.ts +50 -0
  60. package/src/mcp-write-network-resolution.test.ts +429 -0
  61. package/src/messages-ack-by-agent-http.test.ts +142 -0
  62. package/src/network-name-http.test.ts +75 -0
  63. package/src/network-scope.ts +224 -0
  64. package/src/node-attrs-validate.ts +88 -0
  65. package/src/node-attrs.test.ts +217 -0
  66. package/src/node-create-request-status-http.test.ts +92 -0
  67. package/src/node-files-transport.test.ts +344 -0
  68. package/src/node-id-alias.test.ts +188 -0
  69. package/src/node-lifecycle-controllable.test.ts +64 -0
  70. package/src/node-lifecycle-controllable.ts +51 -0
  71. package/src/node-logs-transport.test.ts +263 -0
  72. package/src/node-request-retention.test.ts +281 -0
  73. package/src/node-request-retention.ts +120 -0
  74. package/src/node-skills-transport.test.ts +209 -0
  75. package/src/node-token-privilege-http.test.ts +149 -0
  76. package/src/observer-avatar-http.test.ts +429 -0
  77. package/src/observer-push.test.ts +252 -0
  78. package/src/package-version-consistency.test.ts +25 -0
  79. package/src/peer-reply-atomic.test.ts +455 -0
  80. package/src/peer-reply-capability.test.ts +70 -0
  81. package/src/push.ts +204 -7
  82. package/src/redact-tokens.test.ts +65 -0
  83. package/src/redact-tokens.ts +40 -0
  84. package/src/rename.ts +7 -2
  85. package/src/reply-target-derivation.test.ts +96 -0
  86. package/src/report-status-host-schema.test.ts +211 -0
  87. package/src/report-status-resume-id-handover.test.ts +150 -0
  88. package/src/require-explicit-test-db.ts +9 -0
  89. package/src/requirement-tags.ts +16 -0
  90. package/src/requirements-http.test.ts +708 -0
  91. package/src/requirements-mcp.test.ts +142 -0
  92. package/src/requirements-migrate.ts +83 -0
  93. package/src/requirements-priority-lowest.test.ts +114 -0
  94. package/src/requirements-upgrade-e2e.test.ts +126 -0
  95. package/src/requirements-upgrade-migration.test.ts +85 -0
  96. package/src/requirements.ts +865 -0
  97. package/src/resolve-port.test.ts +57 -0
  98. package/src/resolve-port.ts +44 -0
  99. package/src/response-charset.test.ts +130 -0
  100. package/src/rest-explicit-columns-http.test.ts +216 -0
  101. package/src/rest-identity.test.ts +129 -0
  102. package/src/rest-identity.ts +100 -0
  103. package/src/rest-projections.ts +68 -0
  104. package/src/rest-write-network-resolution.test.ts +133 -0
  105. package/src/restricted-files.ts +73 -0
  106. package/src/retention.ts +10 -1
  107. package/src/rules-file-duplicate-alias.test.ts +234 -0
  108. package/src/rules-file-session-target.test.ts +207 -0
  109. package/src/rules-file-transport.test.ts +237 -0
  110. package/src/runtime-label.test.ts +22 -0
  111. package/src/runtime-label.ts +21 -0
  112. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  113. package/src/scheduled-run-terminal.test.ts +268 -0
  114. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  115. package/src/scheduled-tasks-http.test.ts +574 -0
  116. package/src/scheduled-tasks.ts +596 -0
  117. package/src/send-dedup-request-id.test.ts +229 -0
  118. package/src/send-reply-agent-warning.test.ts +202 -0
  119. package/src/send-reply-alias-optional.test.ts +131 -0
  120. package/src/send-reply-attachments.test.ts +458 -0
  121. package/src/send_dedup.test.ts +54 -0
  122. package/src/send_dedup.ts +52 -6
  123. package/src/serve-error.test.ts +38 -0
  124. package/src/serve-error.ts +27 -0
  125. package/src/server.ts +4279 -0
  126. package/src/shared/external-schedule-contract.ts +60 -0
  127. package/src/side-thread-command-transport.test.ts +302 -0
  128. package/src/side-thread-command-transport.ts +318 -0
  129. package/src/side-thread-http-integration.test.ts +233 -0
  130. package/src/side-thread-http.ts +504 -0
  131. package/src/side-thread-production.test.ts +72 -0
  132. package/src/side-thread-production.ts +118 -0
  133. package/src/side-thread.test.ts +1067 -0
  134. package/src/side-thread.ts +2495 -0
  135. package/src/skillhub-http.test.ts +92 -0
  136. package/src/start-node.test.ts +109 -0
  137. package/src/stop-delete-node.test.ts +1164 -0
  138. package/src/task-auth-origin-http.test.ts +91 -0
  139. package/src/task-auth-origin.test.ts +24 -0
  140. package/src/task-auth-origin.ts +7 -0
  141. package/src/task-consumption.test.ts +482 -0
  142. package/src/task-diagnostic-http.test.ts +149 -0
  143. package/src/task-diagnostic.test.ts +115 -0
  144. package/src/task-diagnostic.ts +118 -0
  145. package/src/task-idempotency-mcp.test.ts +142 -0
  146. package/src/task-idempotency.test.ts +60 -0
  147. package/src/task-idempotency.ts +75 -0
  148. package/src/task-lifecycle-watcher.test.ts +204 -0
  149. package/src/task-lifecycle-watcher.ts +49 -0
  150. package/src/task-network-resolution-http.test.ts +141 -0
  151. package/src/tasks-pagination-http.test.ts +141 -0
  152. package/src/tools-list-json-schema.test.ts +66 -0
  153. package/src/tools.ts +3445 -257
  154. package/src/update-provider.test.ts +65 -0
  155. package/src/upload-early-reject-guard.test.ts +155 -0
  156. package/src/uploads-http.test.ts +97 -15
  157. package/src/uploads.test.ts +66 -0
  158. package/src/uploads.ts +130 -5
  159. package/src/user-inbox-by-agent-http.test.ts +161 -0
  160. package/src/user-inbox-netid-notnull.test.ts +122 -0
  161. package/src/user-inbox-read-http.test.ts +223 -0
  162. package/src/user-inbox-schema.test.ts +54 -0
package/src/tools.ts CHANGED
@@ -1,14 +1,25 @@
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 { addAgentNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
12
+ import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS } from "./agent-access.js";
13
+ import { restrictedMemberAttachmentsDenied } from "./restricted-files.js";
14
+ import { handleRequirementsRequest } from "./requirements.js";
6
15
  import {
7
16
  buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
8
17
  validateName as validateChildName,
9
18
  validateRuntime,
10
19
  validateModel,
11
20
  validateChannelsP1,
21
+ validateWorkdir,
22
+ daemonDefaultWorkdirRoot,
12
23
  validateEnvRefs,
13
24
  FLAG_KEYS,
14
25
  validateFlagValue,
@@ -22,12 +33,14 @@ import {
22
33
  startPendingEnvGcTimer,
23
34
  startSweeperTimer,
24
35
  auditCreateNode,
36
+ auditCreateNodeStrict,
25
37
  resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
26
38
  } from "./create-node.js";
27
39
  import {
28
40
  vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
29
41
  VaultError,
30
42
  } from "./vault.js";
43
+ import { stripHostLocalPathsForCrossHostSafe, validateAttachments } from "./uploads.js";
31
44
  import {
32
45
  validateBaseUrl as _validateBaseUrl,
33
46
  SUPPORTED_VENDORS,
@@ -44,11 +57,17 @@ import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias
44
57
  import {
45
58
  ALLOWED_FLAGS,
46
59
  SECURITY_SENSITIVE_FLAGS,
60
+ EDITABLE_CHANNELS,
47
61
  computeApplyMode,
48
62
  validatePatch,
63
+ narrowChannelsPatch,
49
64
  isAllowedToChangeFlag,
50
65
  } from "./config-apply-validate.js";
51
66
  import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
67
+ import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
68
+ import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
69
+ import { parseHubTimestamp } from "./hub-timestamp";
70
+ import { noteTerminalResultRead, purgeLogsResultNow, sweepNodeRequestContent } from "./node-request-retention.js";
52
71
 
53
72
  function ts(): string {
54
73
  return new Date().toTimeString().slice(0, 8);
@@ -61,10 +80,93 @@ function parseMetaJson(value: unknown): unknown | null {
61
80
 
62
81
  function normalizeMetaJson(meta: unknown): string | null {
63
82
  if (!meta || typeof meta !== "object") return null;
64
- try { return JSON.stringify(meta); } catch { return null; }
83
+ // #222 cross-host safety — strip `path` from meta.attachments[] when
84
+ // `file_id` is also present. Shared helper lives in uploads.ts so the
85
+ // REST handler in index.ts can apply the identical sanitization
86
+ // (otherwise REST and MCP transports would have different meta_json
87
+ // shapes for the same send).
88
+ try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
89
+ }
90
+
91
+ // ── #1281 — 子节点生命周期工具的参数名统一 ────────────────────────────
92
+ //
93
+ // stop_node / start_node / delete_node 历史上用 `child_node_id`(RFC-027),
94
+ // restart_node / update_node_config 用 `node_id`。同一个对象(子节点)两个名字,
95
+ // 调用方按一个工具的习惯给另一个传就吃 -32602。两侧都已是**已发布契约**
96
+ // (start_node 随 #1273 合入),且 hub API 在 RFC-030 混版滚动期老客户端还在
97
+ // 发老名——所以不能硬改名,改为**两个名字都接受**:
98
+ // · canonical = `node_id`(= DB 列 nodes.node_id、= create_node 的输出、
99
+ // restart/update 历史用名;「建完节点→再操作它」用同一个名才是傻瓜式)。
100
+ // · `child_node_id` 作为**兼容 alias 保留** + deprecation 注释;不加运行时
101
+ // warning(老客户端是设计内兼容,不是错误,别刷屏)。
102
+ // create_node 的 `daemon_node_id` 指宿主 daemon,是另一层语义,不在本次统一内。
103
+ //
104
+ // server.tool 收的是 ZodRawShape(裸对象),挂不了 .refine(),所以「至少一个
105
+ // 必填」+「两个都传须相等」在 handler 侧用 resolveNodeIdArg 兜——返回结构化
106
+ // ok:false 反而比 schema 层的 -32602 更友好。两个名字在 schema 层都做成
107
+ // optional + 宽松 .min(1).max(200)(不带 regex),使命名与校验强度同时统一:
108
+ // 对 restart/update 不变(本就宽松),对 stop/start/delete 是放宽(原 regex 会
109
+ // 在 schema 层挡掉 alias/杂串,放宽后落到查表返回 node_not_found,对合法调用
110
+ // 方无影响,只是错误形态更一致)。
111
+ const NODE_ID_ALIAS_FIELDS = {
112
+ node_id: z.string().min(1).max(200).optional()
113
+ .describe("Target child node ID (canonical, #1281). The value create_node returns. Must be in caller's network."),
114
+ child_node_id: z.string().min(1).max(200).optional()
115
+ .describe("DEPRECATED alias for node_id (#1281): kept for back-compat with pre-unification clients; prefer node_id."),
116
+ } as const;
117
+
118
+ /** #1281 — 把 node_id / child_node_id 收敛成单一内部 id。两个都传且不等 ⇒
119
+ * node_id_conflict(挡「node_id 填 A、child_node_id 填 B」的手滑,比单纯二选一强);
120
+ * 都不传 ⇒ node_id_required。纯函数,模块级导出以便单测直接验解析逻辑。 */
121
+ export function resolveNodeIdArg(a: { node_id?: string; child_node_id?: string }):
122
+ { ok: true; node_id: string } | { ok: false; error: string; message: string } {
123
+ const n = a.node_id, c = a.child_node_id;
124
+ if (n && c && n !== c) {
125
+ 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)" };
126
+ }
127
+ const id = n ?? c;
128
+ if (!id) {
129
+ 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)" };
130
+ }
131
+ return { ok: true, node_id: id };
132
+ }
133
+
134
+ function guardRestrictedMemberTools(server: McpServer, restrictedNets: string[], _userId: string): void {
135
+ const denied = (tool: string) => ({
136
+ content: [{
137
+ type: "text" as const,
138
+ text: JSON.stringify({
139
+ ok: false,
140
+ error: "agent_access_restricted",
141
+ message: `${tool} is not available to restricted members in this network; pass network_id of a network where you are not restricted, or ask a network admin for access`,
142
+ }),
143
+ }],
144
+ });
145
+ const wrap = (name: string, handler: (...callArgs: any[]) => any) => async (...callArgs: any[]) => {
146
+ if (!RESTRICTED_MEMBER_TOOLS.has(name)) {
147
+ const args = callArgs[0];
148
+ const raw = args && typeof args === "object" ? (args as Record<string, unknown>).network_id : undefined;
149
+ const net = typeof raw === "string" && raw.trim() ? raw.trim() : null;
150
+ if (!net || restrictedNets.includes(net)) return denied(name);
151
+ }
152
+ return handler(...callArgs);
153
+ };
154
+ const anyServer = server as any;
155
+ for (const method of ["tool", "registerTool"] as const) {
156
+ const original = anyServer[method].bind(server);
157
+ anyServer[method] = (...regArgs: any[]) => {
158
+ const last = regArgs.length - 1;
159
+ if (typeof regArgs[0] === "string" && typeof regArgs[last] === "function") regArgs[last] = wrap(regArgs[0], regArgs[last]);
160
+ return original(...regArgs);
161
+ };
162
+ }
65
163
  }
66
164
 
67
165
  export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null) {
166
+ // 多用户 Agent 权限:用户令牌调用者在哪些网络里是受限成员(只看授权 Agent)。
167
+ // 网络令牌不会走到这里 —— 受限成员的网络令牌在 resolveToken 就被拒了。
168
+ const restrictedNets = enforceUserId && !callerTokenIsNetwork && !enforceNetworkId ? restrictedNetworkIds(enforceUserId) : [];
169
+ if (restrictedNets.length) guardRestrictedMemberTools(server, restrictedNets, enforceUserId!);
68
170
  // Default from_session for outbound tools — extracted from the calling
69
171
  // token's binding (ntok_ → node alias, utok_ → username). Without this,
70
172
  // an agent's send_task call always claimed from='hub' and peer agents
@@ -87,30 +189,238 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
87
189
  }],
88
190
  };
89
191
  };
90
- // If enforceNetworkId is set, override any client-supplied network_id
91
- const getNetworkId = (clientNetId?: string | null) => enforceNetworkId ?? clientNetId ?? null;
192
+ // MCP-side auth context in the shape network-scope.ts helpers take.
193
+ // null = legacy global-token / open-dev mode (unscoped, allow-all).
194
+ const mcpAuthCtx = enforceUserId ? { userId: enforceUserId, networkId: enforceNetworkId ?? null } : null;
195
+
196
+ // If enforceNetworkId is set (ntok_), override any client-supplied
197
+ // network_id. #517: for utok_ with no explicit network_id, fall back to
198
+ // the user's single membership via the SAME singleNetworkId used by REST
199
+ // POST /api/task (network-scope.ts) — utok_ rows carry network_id=null by
200
+ // design, so without this fallback single-network nodes could read
201
+ // everything but write nothing. Multi-network stays null (ambiguous) and
202
+ // canWrite rejects with network_id_required.
203
+ const getNetworkId = (clientNetId?: string | null) => {
204
+ const explicit = enforceNetworkId ?? clientNetId ?? null;
205
+ if (explicit !== null || !enforceUserId) return explicit;
206
+ return singleNetworkId({ networkId: null, networkIds: getUserNetworkIds(enforceUserId) });
207
+ };
92
208
 
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.
209
+ // Check write access — delegates to the shared canRestWriteNetwork so MCP
210
+ // and REST cannot drift again (#517). isAdmin=false: MCP has no admin
211
+ // bypass today; keep behavior identical to before the extraction.
96
212
  const canWrite = (effectiveNetworkId?: string | null): boolean => {
97
- if (!enforceUserId) return true; // legacy global token mode, allow
98
213
  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
214
+ return canRestWriteNetwork(mcpAuthCtx, netId, false);
215
+ };
216
+ // 人类侧写入(私信):不看 Agent 授权,受限成员也可以。
217
+ const canWriteHuman = (effectiveNetworkId?: string | null): boolean =>
218
+ canRestWriteNetworkAsHuman(mcpAuthCtx, enforceNetworkId ?? effectiveNetworkId ?? null, false);
219
+ // 给 Agent 发任务 / 消息的准入。不受限:原判据。受限网络里:目标必须是授权且 can_message 的 Agent,
220
+ // 发件人固定为自己的用户名;目标不存在与没授权返回同一个错误,不留 alias 探测差异。
221
+ const agentSendDenied = (effectiveNetworkId: string | null, alias: string, clientFrom: string | undefined, action: "send_task" | "write", meta?: unknown) => {
222
+ if (!effectiveNetworkId || !restrictedNets.includes(effectiveNetworkId)) {
223
+ return canWrite(effectiveNetworkId) ? null : writeDeniedReply(effectiveNetworkId, action);
224
+ }
225
+ if (!canWriteHuman(effectiveNetworkId)) return writeDeniedReply(effectiveNetworkId, action);
226
+ const requestedFrom = clientFrom?.trim();
227
+ if (requestedFrom && requestedFrom !== callerAlias) {
228
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "from_session_identity_mismatch", message: "restricted members always send as their own username" }) }] };
229
+ }
230
+ const targetAlias = resolveCanonicalAlias(effectiveNetworkId, alias).alias;
231
+ const session = db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", targetAlias, effectiveNetworkId);
232
+ if (!session || !canMessageAgent(enforceUserId!, effectiveNetworkId, { alias: targetAlias, nodeId: session.node_id })) {
233
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "agent_not_granted", message: "this agent has not been granted to you; ask a network admin" }) }] };
234
+ }
235
+ const rawAttachments = meta && typeof meta === "object" ? (meta as { attachments?: unknown }).attachments : undefined;
236
+ if (restrictedMemberAttachmentsDenied(enforceUserId!, callerAlias ?? "", effectiveNetworkId, rawAttachments)) {
237
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "attachment_not_accessible" }) }] };
238
+ }
239
+ return null;
102
240
  };
103
241
 
242
+ // #517: name the REAL cause. The old catch-all blamed permissions for
243
+ // what was actually an unresolvable network, sending operators down the
244
+ // wrong debugging path (roles/membership) for hours.
104
245
  const writeDeniedReply = (effectiveNetworkId?: string | null, action = "write") => {
105
246
  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"
247
+ let error: string;
248
+ let message: string;
249
+ if (!netId) {
250
+ const memberships = enforceUserId ? getUserNetworkIds(enforceUserId) : [];
251
+ error = "network_id_required";
252
+ message = memberships.length === 0
253
+ ? "user token has no network memberships; join or create a network first"
254
+ : `user token spans ${memberships.length} networks; pass network_id explicitly (see /api/auth/me networks[].network_id)`;
255
+ } else if (enforceUserId && !getUserNetworkRole(enforceUserId, netId)) {
256
+ error = "access_denied";
257
+ message = "access denied to requested network (not a member)";
258
+ } else {
259
+ error = "permission_denied";
260
+ message = action === "send_task"
109
261
  ? "Viewer role cannot send tasks"
110
262
  : "Viewer role cannot write to this network";
111
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied", message }) }] };
263
+ }
264
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error, message }) }] };
112
265
  };
113
266
 
267
+ const skillHubReply = (value: Record<string, unknown>) => ({
268
+ content: [{ type: "text" as const, text: JSON.stringify(value) }],
269
+ });
270
+
271
+ // SkillHub is a network registry, not a public filesystem. Content is an
272
+ // immutable SKILL.md snapshot; identity comes only from the authenticated
273
+ // MCP principal (never from request fields).
274
+ server.tool(
275
+ "submit_skill",
276
+ "Submit an immutable SKILL.md version to the caller's network SkillHub. Node identity is token-bound. New submissions require review.",
277
+ {
278
+ slug: z.string().min(2).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
279
+ name: z.string().min(1).max(120),
280
+ description: z.string().max(1000).optional(),
281
+ version: z.string().min(1).max(40).regex(/^[0-9A-Za-z]+(?:[._-][0-9A-Za-z]+)*$/),
282
+ content: z.string().min(1).max(128 * 1024).refine(value => !value.includes("\0"), "content must not contain NUL bytes"),
283
+ network_id: z.string().max(200).optional(),
284
+ },
285
+ async ({ slug, name, description, version, content, network_id: clientNetId }) => {
286
+ const effectiveNetId = getNetworkId(clientNetId);
287
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
288
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
289
+ const sourceType = callerTokenIsNetwork ? "node" : "user";
290
+ if (sourceType === "node" && !callerAlias) return skillHubReply({ ok: false, error: "node_identity_unbound" });
291
+ const contentHash = createHash("sha256").update(content, "utf8").digest("hex");
292
+ const existing = db.get<any>(
293
+ `SELECT skill_id, content_hash, status FROM skillhub_skills
294
+ WHERE network_id = ?1 AND slug = ?2 AND version = ?3`,
295
+ effectiveNetId, slug, version,
296
+ );
297
+ if (existing) {
298
+ if (existing.content_hash === contentHash) {
299
+ return skillHubReply({ ok: true, idempotent: true, skill_id: existing.skill_id, status: existing.status });
300
+ }
301
+ return skillHubReply({ ok: false, error: "skill_version_conflict", hint: "publish changed content under a new version" });
302
+ }
303
+ const skillId = `skill_${uuidv4()}`;
304
+ try {
305
+ db.run(
306
+ `INSERT INTO skillhub_skills
307
+ (skill_id, network_id, slug, name, description, version, content, content_hash, status, source_type, source_alias, created_by_user)
308
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
309
+ [skillId, effectiveNetId, slug, name.trim(), description?.trim() || "", version, content, contentHash, sourceType, callerAlias, enforceUserId || null],
310
+ );
311
+ } catch (error: any) {
312
+ // Two submitters may pass the preflight SELECT together. Resolve the
313
+ // UNIQUE race into the same deterministic idempotent/conflict contract
314
+ // instead of leaking a storage exception as HTTP/MCP 500.
315
+ if (!/unique|duplicate key/i.test(error?.message || "")) throw error;
316
+ const winner = db.get<any>(
317
+ `SELECT skill_id, content_hash, status FROM skillhub_skills
318
+ WHERE network_id = ?1 AND slug = ?2 AND version = ?3`,
319
+ effectiveNetId, slug, version,
320
+ );
321
+ if (!winner) throw error;
322
+ if (winner.content_hash === contentHash) {
323
+ return skillHubReply({ ok: true, idempotent: true, skill_id: winner.skill_id, status: winner.status });
324
+ }
325
+ return skillHubReply({ ok: false, error: "skill_version_conflict", hint: "publish changed content under a new version" });
326
+ }
327
+ db.run(
328
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
329
+ VALUES (?1, ?2, 'skill_submit', 'skill', ?3, ?4, ?5)`,
330
+ [enforceUserId || null, callerAlias || null, skillId, JSON.stringify({ slug, version, source_type: sourceType }), effectiveNetId],
331
+ );
332
+ return skillHubReply({ ok: true, skill_id: skillId, status: "pending", source_type: sourceType, source_alias: callerAlias });
333
+ },
334
+ );
335
+
336
+ server.tool(
337
+ "list_skills",
338
+ "List published skills in a network. Owners/admins may include pending review items.",
339
+ {
340
+ network_id: z.string().max(200).optional(),
341
+ include_pending: z.boolean().optional(),
342
+ query: z.string().max(120).optional(),
343
+ limit: z.number().int().min(1).max(200).optional(),
344
+ },
345
+ async ({ network_id: clientNetId, include_pending, query, limit }) => {
346
+ const effectiveNetId = getNetworkId(clientNetId);
347
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "read");
348
+ const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
349
+ if (enforceUserId && !role) return writeDeniedReply(effectiveNetId, "read");
350
+ // An ntok_ belongs to a node even when it was minted by the network
351
+ // owner. Never inherit the owner's review power through that token.
352
+ const reviewer = !callerTokenIsNetwork && (role === "owner" || role === "admin");
353
+ const showPending = !!include_pending && reviewer;
354
+ const params: unknown[] = [effectiveNetId];
355
+ let sql = `SELECT skill_id, slug, name, description, version, status, source_type, source_alias,
356
+ created_at, updated_at, reviewed_at, review_note
357
+ FROM skillhub_skills WHERE network_id = ?1`;
358
+ if (!showPending) sql += ` AND status = 'published'`;
359
+ if (query?.trim()) {
360
+ params.push(`%${query.trim()}%`);
361
+ sql += ` AND (slug LIKE ?${params.length} OR name LIKE ?${params.length} OR description LIKE ?${params.length})`;
362
+ }
363
+ sql += ` ORDER BY updated_at DESC LIMIT ${limit ?? 100}`;
364
+ return skillHubReply({ ok: true, reviewer, skills: db.all(sql, ...params) });
365
+ },
366
+ );
367
+
368
+ server.tool(
369
+ "get_skill",
370
+ "Read one SkillHub SKILL.md. Pending content is visible only to owners/admins.",
371
+ { skill_id: z.string().regex(/^skill_[A-Za-z0-9_-]+$/).max(200), network_id: z.string().max(200).optional() },
372
+ async ({ skill_id, network_id: clientNetId }) => {
373
+ const effectiveNetId = getNetworkId(clientNetId);
374
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "read");
375
+ const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
376
+ if (enforceUserId && !role) return writeDeniedReply(effectiveNetId, "read");
377
+ const row = db.get<any>(
378
+ `SELECT skill_id, slug, name, description, version, content, status,
379
+ source_type, source_alias, created_at, updated_at, reviewed_at, review_note
380
+ FROM skillhub_skills WHERE skill_id = ?1 AND network_id = ?2`,
381
+ skill_id, effectiveNetId,
382
+ );
383
+ if (!row) return skillHubReply({ ok: false, error: "skill_not_found" });
384
+ const reviewer = !callerTokenIsNetwork && (role === "owner" || role === "admin");
385
+ if (row.status !== "published" && !reviewer) {
386
+ return skillHubReply({ ok: false, error: "skill_not_found" });
387
+ }
388
+ return skillHubReply({ ok: true, skill: row });
389
+ },
390
+ );
391
+
392
+ server.tool(
393
+ "review_skill",
394
+ "Publish or reject a pending SkillHub submission. Network owner/admin only.",
395
+ {
396
+ skill_id: z.string().regex(/^skill_[A-Za-z0-9_-]+$/).max(200),
397
+ decision: z.enum(["published", "rejected"]),
398
+ note: z.string().max(1000).optional(),
399
+ network_id: z.string().max(200).optional(),
400
+ },
401
+ async ({ skill_id, decision, note, network_id: clientNetId }) => {
402
+ const effectiveNetId = getNetworkId(clientNetId);
403
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
404
+ const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
405
+ if (callerTokenIsNetwork || (role !== "owner" && role !== "admin")) return skillHubReply({ ok: false, error: "skill_review_admin_required" });
406
+ const row = db.get<any>(`SELECT status FROM skillhub_skills WHERE skill_id = ?1 AND network_id = ?2`, skill_id, effectiveNetId);
407
+ if (!row) return skillHubReply({ ok: false, error: "skill_not_found" });
408
+ if (row.status !== "pending") return skillHubReply({ ok: false, error: "skill_not_pending", status: row.status });
409
+ const updated = db.run(
410
+ `UPDATE skillhub_skills SET status = ?1, review_note = ?2, reviewed_by_user = ?3,
411
+ reviewed_at = datetime('now'), updated_at = datetime('now') WHERE skill_id = ?4 AND network_id = ?5 AND status = 'pending'`,
412
+ [decision, note?.trim() || null, enforceUserId || null, skill_id, effectiveNetId],
413
+ );
414
+ if (updated.changes !== 1) return skillHubReply({ ok: false, error: "skill_not_pending" });
415
+ db.run(
416
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
417
+ VALUES (?1, ?2, 'skill_review', 'skill', ?3, ?4, ?5)`,
418
+ [enforceUserId || null, callerAlias || null, skill_id, JSON.stringify({ decision }), effectiveNetId],
419
+ );
420
+ return skillHubReply({ ok: true, skill_id, status: decision });
421
+ },
422
+ );
423
+
114
424
  const addScope = (sql: string, params: any[], networkId?: string | null, column = "network_id"): string => {
115
425
  if (!networkId) return sql;
116
426
  sql += ` AND ${column} = ?${params.length + 1}`;
@@ -118,49 +428,72 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
118
428
  return sql;
119
429
  };
120
430
 
121
- type ReadScope = { networkId?: string | null; networkIds?: string[] | null; denied?: string };
122
-
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);
431
+ /** #1548 —— 把一个还标着 blocked 的会话拉回 idle。只在「它刚发出终态回复 / 刚派了任务」这两个活性证据处调用。 */
432
+ const releaseBlockedSession = (alias: string | null | undefined, networkId: string | null | undefined): void => {
433
+ if (!alias) return;
434
+ const params: any[] = [alias];
435
+ let sql = "UPDATE sessions SET status = 'idle', updated_at = datetime('now') WHERE alias = ?1 AND status = 'blocked'";
436
+ sql = addScope(sql, params, networkId ?? null);
437
+ try {
438
+ db.run(sql, params);
439
+ } catch {
440
+ // 展示层的一次修正失败不能影响主路径。
441
+ }
129
442
  };
130
443
 
444
+ type ReadScope = RestNetworkScope;
445
+
446
+ // Delegates to the shared membership query (network-scope.ts) — was a
447
+ // byte-for-byte duplicate of getUserNetworkIds before #517.
448
+ const getReadableNetworkIds = (): string[] =>
449
+ enforceUserId ? getUserNetworkIds(enforceUserId) : [];
450
+
451
+ // 受限网络挂在 scope.agentRestriction 上:addReadScope(= addNetworkScope)对它们 fail-closed,
452
+ // 逐条审过的工具改用 addAgentNetworkScope / addOwnTrafficScope 按授权放行。
453
+ const withRestriction = (scope: ReadScope): ReadScope => {
454
+ if (!restrictedNets.length || !enforceUserId) return scope;
455
+ const inScope = scope.networkId ? [scope.networkId] : (scope.networkIds ?? []);
456
+ const restricted = restrictedNets.filter((id) => inScope.includes(id));
457
+ return restricted.length ? { ...scope, agentRestriction: { userId: enforceUserId, username: callerAlias ?? "", networkIds: restricted } } : scope;
458
+ };
131
459
  const resolveReadScope = (clientNetId?: string | null): ReadScope => {
132
460
  if (!enforceUserId) return { networkId: clientNetId ?? null, networkIds: null };
133
461
  if (enforceNetworkId) {
134
462
  const role = getUserNetworkRole(enforceUserId, enforceNetworkId);
135
- return role ? { networkId: enforceNetworkId, networkIds: null } : { denied: "not a member of token network" };
463
+ return role ? { networkId: enforceNetworkId, networkIds: null } : { networkId: null, networkIds: [], denied: "not a member of token network" };
136
464
  }
137
465
  if (clientNetId) {
138
466
  const role = getUserNetworkRole(enforceUserId, clientNetId);
139
- return role ? { networkId: clientNetId, networkIds: null } : { denied: "access denied to requested network" };
467
+ return role ? withRestriction({ networkId: clientNetId, networkIds: null }) : { networkId: null, networkIds: [], denied: "access denied to requested network" };
140
468
  }
141
- return { networkId: null, networkIds: getReadableNetworkIds() };
469
+ return withRestriction({ networkId: null, networkIds: getReadableNetworkIds() });
142
470
  };
143
471
 
144
- const addReadScope = (sql: string, params: any[], scope: ReadScope, column = "network_id"): string => {
145
- if (scope.networkId) {
146
- sql += ` AND ${column} = ?${params.length + 1}`;
147
- params.push(scope.networkId);
148
- return sql;
149
- }
150
- if (scope.networkIds) {
151
- if (scope.networkIds.length === 0) return `${sql} AND 1=0`;
152
- const placeholders = scope.networkIds.map((_, i) => `?${params.length + i + 1}`).join(", ");
153
- sql += ` AND ${column} IN (${placeholders})`;
154
- params.push(...scope.networkIds);
155
- }
156
- return sql;
157
- };
472
+ // RFC-027 §2.3 race-free invariant — assertNodeActive lives in
473
+ // server/src/lifecycle-guard.ts so REST handlers in server/src/index.ts
474
+ // can use the SAME code path. PR1.1 had it inline here; PR1.2a
475
+ // (#346 review catch) extracted because the closure scope made it
476
+ // unreachable from REST and left the §2.3 race open on dashboard
477
+ // Dispatch (POST /api/task + /api/broadcast). Per
478
+ // 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.
479
+
480
+ // 默认读作用域:对受限网络 fail-closed(见 network-scope.ts addNetworkScope)。
481
+ const addReadScope = (sql: string, params: any[], scope: ReadScope, column = "network_id"): string =>
482
+ addNetworkScope(sql, params, scope, column);
158
483
 
159
484
  type DeliveryTarget =
160
485
  | { state: "online"; alias: string; session: any }
161
486
  | { state: "offline"; alias: string; session: any; message: string }
162
487
  | { state: "not_found"; alias: string; message: string };
163
488
 
489
+ // Only call after the authenticated, network-scoped lookup resolved a
490
+ // concrete target. Error paths intentionally omit this identity object.
491
+ const actualTo = (target: Exclude<DeliveryTarget, { state: "not_found" }>, networkId?: string | null) => ({
492
+ alias: target.alias,
493
+ to_node_id: target.session?.node_id ?? null,
494
+ network_id: networkId ?? null,
495
+ });
496
+
164
497
  const scopedSessionStatus = (alias: string, networkId?: string | null) => {
165
498
  const params: any[] = [alias];
166
499
  let sql = "SELECT status, updated_at, last_seen_at, node_id FROM sessions WHERE alias = ?1";
@@ -261,9 +594,39 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
261
594
  model: z.string().max(200).optional().describe("AI model name"),
262
595
  node_name: z.string().max(200).optional().describe("Stable node display name (may differ from alias)"),
263
596
  network_id: z.string().max(200).optional().describe("Network this agent belongs to"),
597
+ // ╭─ 🔴 往 report_status 里加字段之前先读这一段(#1545 实测,zod 4.3.6)─────────╮
598
+ // │ **这份 schema 的子对象一半严一半松,而后果完全不同。**
599
+ // │
600
+ // │ 非 strict:host / process_telemetry / config_snapshot / daemon_capabilities
601
+ // │ `.strict()`:external_schedules(含其 schedules[] 元素)
602
+ // │ side_thread_capability(含其 exactBoundary)
603
+ // │
604
+ // │ 往**非 strict** 的对象里加一个 hub 还不认识的键 ⇒ zod **静默丢弃**。
605
+ // │ 节点不会掉线,但字段人间蒸发 —— 现场表现为「daemon 明明发了、hub 上没有」,
606
+ // │ 排查起来和 daemon 侧 bug 一模一样。
607
+ // │ 往**strict** 的对象里加同一个键 ⇒ `unrecognized_keys`,**整份 report_status 被拒**。
608
+ // │ 而 agent-node 的 register() 是 `await` 且无人 catch ⇒ **节点进程当场死掉**
609
+ // │ (#1225 就是这么全网躺倒的,见下面 host.ip 那段)。
610
+ // │
611
+ // │ 所以「新字段能不能 daemon 先发」**没有统一答案,取决于你往哪个子对象里加**:
612
+ // │ 非 strict → 可以先发(会丢,不会死);strict → hub 必须先合,否则升级即失联。
613
+ // │
614
+ // │ 🔴 别把这条读成「strict 更危险、都改成非 strict」。两者各有其位:
615
+ // │ external_schedules / side_thread_capability 是**有界快照**,strict 挡的是
616
+ // │ 「节点往里塞任意键」;daemon_capabilities 是**能力自述**,天然要向前兼容
617
+ // │ (旧 hub 见到新能力应当忽略而不是拒绝整份上报)。**加字段前先看清你在哪一边。**
618
+ // ╰──────────────────────────────────────────────────────────────────────────────╯
264
619
  host: z.object({
265
- hostname: z.string().max(200).optional(),
266
- ip: z.string().max(200).optional(),
620
+ // 🔴 `.nullable()` 不是防御性冗余,是**发送方声明的类型**:agent-node 的
621
+ // HostTelemetry 是 `ip: string | null`,`firstNonInternalIPv4()` 在没有
622
+ // 非回环 IPv4 的机器上返回 null(--network none 的容器、断网的笔记本、
623
+ // 部分 CI runner)。此前这里只有 `.optional()`,于是那台机器上的
624
+ // agent-node 一启动就收到 MCP -32602,而 register() 是 await 且无人 catch
625
+ // —— **整个节点进程当场死掉**,和 runtime 无关。
626
+ // 同一个对象里四个数值字段本来就写着 `.nullable().optional()`,
627
+ // 两个字符串字段是唯一的例外;正确写法一直在隔壁那一行。
628
+ hostname: z.string().max(200).nullable().optional(),
629
+ ip: z.string().max(200).nullable().optional(),
267
630
  cpu_load_1min: z.number().nullable().optional(),
268
631
  cpu_cores: z.number().nullable().optional(),
269
632
  mem_total_gb: z.number().nullable().optional(),
@@ -281,6 +644,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
281
644
  uptime_seconds: z.number().nullable().optional(),
282
645
  in_flight_count: z.number().nullable().optional(),
283
646
  }).optional().describe("Per-agent process telemetry reported by agent-node"),
647
+ external_schedules: z.object({
648
+ observed_at: z.string().datetime({ offset: true }).max(64),
649
+ schedules: z.array(z.object({
650
+ id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/),
651
+ name: z.string().min(1).max(200),
652
+ kind: z.enum(["cron", "systemd", "tmux", "playwright", "custom"]),
653
+ frequency: z.string().min(1).max(120),
654
+ last_run_at: z.string().datetime({ offset: true }).max(64).nullable(),
655
+ last_status: z.enum(["success", "failed", "running", "unknown"]),
656
+ last_error: z.string().max(500).nullable(),
657
+ next_run_at: z.string().datetime({ offset: true }).max(64).nullable(),
658
+ log_ref: z.string().min(1).max(255).nullable(),
659
+ enabled: z.boolean(),
660
+ // RFC-036 — only agent-node managed cron markers advertise write
661
+ // capability. Legacy/other kinds omit these fields and stay read-only.
662
+ editable: z.boolean().optional(),
663
+ revision: z.number().int().min(0).optional(),
664
+ }).strict()).max(64),
665
+ error: z.enum(["invalid_manifest", "unsafe_manifest", "read_failed"]).optional(),
666
+ }).strict().optional().describe("Bounded node-reported external schedule snapshot; never includes host paths or commands"),
284
667
  // RFC-024 B6 — masked snapshot of the node's effective config
285
668
  // (model + 6 dashboard-editable flags). Secrets ARE NOT in this
286
669
  // shape (env._envRef stays on host); the dashboard reads this
@@ -291,21 +674,126 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
291
674
  // can grey out remote-restart for them.
292
675
  config_snapshot: z.object({
293
676
  model: z.string().max(200).optional().nullable(),
294
- flags: z.record(z.unknown()).optional(),
677
+ flags: z.record(z.string(), z.unknown()).optional(),
295
678
  config_revision: z.number().int().min(0).optional(),
296
679
  config_update_capable: z.boolean().optional(),
680
+ peer_reply_inbox_capable: z.literal(true).optional(),
681
+ side_thread_capability: z.object({
682
+ supported: z.boolean(),
683
+ runtime: z.string().max(64),
684
+ runtimeVersion: z.string().max(64),
685
+ topology: z.string().max(64),
686
+ evidenceRevision: z.string().max(100),
687
+ mode: z.literal("native-exact-fork").optional(),
688
+ exactBoundary: z.object({ through: z.boolean(), before: z.boolean() }).strict().optional(),
689
+ reason: z.enum(["runtime", "version", "topology", "experimental-api", "exact-boundary"]).optional(),
690
+ }).strict().optional(),
297
691
  // RFC-026 P2 / #338 — daemon role surfaced for hub /api/nodes
298
692
  // discovery (#337 extracts this field). "host_supervisor" =
299
693
  // anet daemon. Default-stripping zod would drop this otherwise.
300
694
  role: z.string().max(64).optional().nullable(),
301
- // RFC-026 §9.3 — daemon self-declare. Promoted to first-class
302
- // columns by PR2 so list_host_supervisors reads them directly
303
- // (no per-call snapshot JSON parse). Soft caps avoid abuse.
304
- runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
305
- allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
695
+ // RFC-026 §9.3 / #338 PR3 — daemon self-declare nested under
696
+ // `daemon_capabilities` (canonical shape per existing hub reads
697
+ // at tools.ts:2010/2075 — PR1/PR2 placed these top-level, hub
698
+ // never saw them, max_concurrent_children stayed default + the
699
+ // allowlists stayed unenforced. PR3 nit ① per 通信龙).
700
+ // Soft caps avoid abuse via attacker daemon.
701
+ // 🔴 这个对象**故意不是 `.strict()`** —— 能力自述必须向前兼容:
702
+ // 旧 hub 见到新 daemon 报的新能力,应当忽略那一格,而不是拒掉整份 report
703
+ // 把节点踢下线。代价是新键在 hub 升级前会被**静默丢弃**(所以新字段仍然
704
+ // 应当 hub 先合)。同一份 schema 里 side_thread_capability / external_schedules
705
+ // 是 `.strict()` 的,后果不同 —— 见 `host:` 上方那段方框注释。
706
+ daemon_capabilities: z.object({
707
+ runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
708
+ allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
709
+ max_concurrent_children: z.number().int().min(1).max(1000).optional(),
710
+ // #1353 —— daemon **当下**能不能创建节点。与 runtimes_supported 不是一回事:
711
+ // 后者是声明(我支持哪些 runtime),这个是实际能力。一个 daemon 可以声明支持
712
+ // 三种 runtime,同时因为 ANET_BIN pin 解析不出来而一种也创建不了。
713
+ //
714
+ // 🔴 为什么 hub 需要看见它:pin 只存在于 /etc/anet-daemon/path.conf 或
715
+ // 显式开启的环境变量里,**重启不带环境就会丢**。丢了之后 daemon 照常注册、
716
+ // 在线、收 doorbell,hub 返回 ok:true + request_id —— 而节点永远不出现。
717
+ // 在此之前 hub 完全看不出区别,Dashboard 的「选服务器」还会把它列为可选。
718
+ can_create_nodes: z.boolean().optional(),
719
+ // 只收**代码**,不收原始报错文本 —— 上游 unsafePathHelp() 的消息里带完整机器路径,
720
+ // 而这个字段会走到 Dashboard。enum 而不是 string:一个自由文本字段等于给
721
+ // 「把路径塞进来」留了口子。
722
+ // #1353 —— 四类失败,修法完全不同:
723
+ // identity 重装 anet / unset ANET_BIN_ABS
724
+ // source 写 /etc/anet-daemon/path.conf(**要 sudo**)
725
+ // shape 换成 realpath
726
+ // permission **一行 chmod go-w**
727
+ // 混成一条会让人修错方向 —— 2026-08-28 我一天里撞到其中两类,看到的是同一条错误。
728
+ //
729
+ // 🔴 `anet_bin_pin_unresolved` **必须留着**:已经在跑的 agent-node@2.5.0-preview.40
730
+ // 发的就是这个值。zod 对象里任何一个字段验证失败会让**整个 report_status 被拒**,
731
+ // 不只是丢掉这一格 —— 那会让所有 .40 daemon 在 hub 上变成失联。
732
+ // 删一个 enum 值的代价,远大于多留一个。
733
+ // 🔴 仍然是 enum 不是 string:自由文本字段就是给机器路径留的口子,
734
+ // 而上游 e.message 里带的正是完整路径。
735
+ create_nodes_blocked_reason: z.enum([
736
+ "anet_bin_pin_unresolved", // 兼容:.40 及更早
737
+ "anet_bin_identity",
738
+ "anet_bin_source",
739
+ "anet_bin_shape",
740
+ "anet_bin_permission",
741
+ "anet_bin_unknown",
742
+ ]).optional(),
743
+ // #1545 —— 上面那一格**是什么时候测出来的**。
744
+ //
745
+ // 🔴 版本号一律带包名 + 完整版本:仓里三个包各自独立编号,裸的 `preview.NN`
746
+ // 同时是三个不同的时间点。此处曾写成「preview.67」——
747
+ // 那是 agent-network 的号,agent-node 根本没有 .67(npm 404,最高 .56)。
748
+ // 🔴 为什么必须有:**agent-node ≤ 2.5.0-preview.54** 的 `daemonCreateCapability()`
749
+ // 用一个进程级缓存(`_createCapCache`)只算一次。开机时 pin 坏 ⇒ 之后
750
+ // 永远上报 blocked(哪怕运维已经写好 path.conf);开机时好 ⇒ 之后把二进制
751
+ // chmod 掉也**永远上报 ready**。后者是朝「没问题」方向说谎。
752
+ // 而 hub 这边,一个 3 秒前测的 blocked 和一个三周前测的 blocked,
753
+ // 在 `last_seen_at` 上长得一模一样 —— 心跳是新的,那一格不是。
754
+ //
755
+ // 🔴 为什么是「多久以前」(时长)而不是绝对时间戳:这个值来自**节点自己的钟**。
756
+ // 时钟偏移下,绝对时间戳会算出一个既可能"永远新鲜"也可能"来自 1970"的年龄,
757
+ // 而且错的方向不可预测。时长对偏移免疫 —— hub 用自己的钟在收到时换算。
758
+ //
759
+ // 🔴 为什么不加 `.min()/.max()/.int()`:**这几个约束都会让整条 report_status 被拒**
760
+ // (zod 对象里任何一个已知字段验证失败 = 整份被拒,不是丢掉这一格),
761
+ // 而这一格是纯诊断信息,不值得拿一台节点的在线状态去换。
762
+ // 收得宽,**在读取处消毒**(见 /api/host-supervisors)。
763
+ // 这条不是我新立的:同一个 schema 上方 `anet_bin_pin_unresolved` 那条注释
764
+ // 记的就是同一次教训 —— 删一个 enum 值会让所有 .40 daemon 失联。
765
+ //
766
+ // 🔴 `.catch(null)` 不是装饰:光写 `z.number()` **仍然会拒 NaN**
767
+ // (实测:`z.number().nullable().optional()` 对 NaN 抛),而那意味着
768
+ // 一次算错的时长能让整台节点在 hub 上失联 —— 正是 #1225 里 host.ip
769
+ // 那个形状。`.catch(null)` 让这一格**在任何输入下都不可能拒掉整份 report**:
770
+ // 合法数字透传,其余一律变 null(读取侧当作「没报」)。
771
+ // 类型仍然是 number 而不是 unknown —— 别给一个 attacker daemon
772
+ // 留下"往快照里塞任意大对象"的口子(同 schema 上方 soft cap 的立场)。
773
+ //
774
+ // 不上报这一格 ≠ 0。旧 daemon 压根不发,读的人必须能把
775
+ // 「刚测的」「很久以前测的」「不知道」分成三件事说。
776
+ create_capability_observed_ms_ago: z.number().nullable().catch(null).optional(),
777
+ // app「新建节点」工作目录:daemon 的默认根。出现即表示该 daemon 认 node_spec.workdir。
778
+ // `.catch(undefined)`:同上一格的立场 —— 一个诊断/展示字段不许拒掉整份 report。
779
+ // 读取侧再按形状消毒(create-node-validate.ts daemonDefaultWorkdirRoot)。
780
+ default_workdir_root: z.string().max(1024).optional().catch(undefined),
781
+ }).optional(),
306
782
  }).optional().describe("RFC-024 — masked node config snapshot"),
783
+ // app#225 follow-up — the reporting process answers the `rules_file`
784
+ // doorbell (agent-node, and the claude-code channel server node-server).
785
+ // Top-level instead of config_snapshot: claude-code sessions usually have
786
+ // no `nodes` row, and config_snapshot is persisted per node_id. Only a
787
+ // node token bound to this same alias can set it; see the UPDATE below.
788
+ rules_file_capable: z.literal(true).optional(),
789
+ // Node skills view — same doorbell, ops skills_list / skill_read.
790
+ skills_capable: z.literal(true).optional(),
791
+ // Project folder view — same doorbell, ops files_list / file_read.
792
+ files_capable: z.literal(true).optional(),
793
+ // Node run-log view — same doorbell, op logs_tail.
794
+ logs_capable: z.literal(true).optional(),
307
795
  },
308
- 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 }) => {
796
+ async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap, rules_file_capable: rulesFileCapable, skills_capable: skillsCapable, files_capable: filesCapable, logs_capable: logsCapable }) => {
309
797
  const effectiveNetId = getNetworkId(netId);
310
798
  const sessionNetId = effectiveNetId ?? "default";
311
799
  if (!callerTokenIsNetwork || !enforceNetworkId) {
@@ -343,6 +831,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
343
831
  };
344
832
  }
345
833
  }
834
+ // #203 identity guard — network tokens must not silently rebind their
835
+ // own name via report_status. Without this, a runtime whose ALIAS
836
+ // drifted (env leak / wrong --alias / CurrentAliasResolver seeded from
837
+ // the wrong node_id) could rewrite api_tokens.name and cause every
838
+ // subsequent send_task from this token to be attributed to the drifted
839
+ // alias — the observed #203 symptom (grokB's send arriving as
840
+ // from=grokA). Only the legit rename path (rename.ts) may cross the
841
+ // token→alias binding. Symmetric to fromIdentityMismatchReply on the
842
+ // send side (test198).
843
+ if (callerTokenIsNetwork && callerAlias && !canonical.renamed && effectiveAlias !== callerAlias) {
844
+ return {
845
+ content: [{
846
+ type: "text" as const,
847
+ text: JSON.stringify({
848
+ ok: false,
849
+ error: "alias_identity_mismatch",
850
+ message: "report_status alias does not match the token-bound node alias; use anet node rename to change identity",
851
+ token_alias: callerAlias,
852
+ reported_alias: effectiveAlias,
853
+ }),
854
+ }],
855
+ };
856
+ }
346
857
  console.log(`[${ts()}] ${effectiveAlias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
347
858
  if (callerTokenIsNetwork && callerTokenId) {
348
859
  try {
@@ -367,6 +878,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
367
878
  const processCpuPct = typeof proc?.cpu_pct === "number" ? proc.cpu_pct : null;
368
879
  const processUptimeSeconds = typeof proc?.uptime_seconds === "number" ? proc.uptime_seconds : null;
369
880
  const processInFlightCount = typeof proc?.in_flight_count === "number" ? proc.in_flight_count : null;
881
+ const externalSchedulesJson = externalSchedules === undefined ? null : JSON.stringify(externalSchedules);
882
+ const trustedCurrentSnapshot = node_id
883
+ ? trustedConfigSnapshotForNode(cfgSnap ?? null, callerTokenId ?? null, node_id)
884
+ : null;
885
+ const peerReplyInboxCapable = !!(
886
+ trustedCurrentSnapshot
887
+ && typeof trustedCurrentSnapshot === "object"
888
+ && (trustedCurrentSnapshot as Record<string, unknown>).peer_reply_inbox_capable === true
889
+ );
370
890
  const statusHostTelemetry = host ? {
371
891
  hostname: hostHostname,
372
892
  ip: hostIp,
@@ -389,10 +909,30 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
389
909
 
390
910
  db.transaction(() => {
391
911
  // Only delete same-alias sessions within the same network
912
+ // 同一 alias 可以有两个上报者轮流说话(agent-node 自己 `sdk-<node>` +
913
+ // grok 共存里 TUI 的 MCP `grok-cli-<node>`)。下面这条 DELETE 会把「另一个
914
+ // resume_id」的那行整行删掉,再 INSERT 一行新的 —— ON CONFLICT 里那一串
915
+ // COALESCE 在这条路径上一次都不会触发,于是上报里没带的列(version / agent /
916
+ // hostname / 六个遥测字段 / registered_at …)全部变 NULL。2026-09-04 DEV 真机:
917
+ // grok-v1 working 时 version=.64,7 秒后 idle 就成了 NULL,registered_at 被改成
918
+ // 那一秒。先把要被删的那行读出来,INSERT 之后把仍为 NULL 的列从它接手 —— 让「换 resume_id」
919
+ // 和「同 resume_id 更新」对描述性列的语义一致:上报了就覆盖,没上报就保留。
920
+ // 状态类列(status / task / output / progress / score)不接手,那是本次上报的事实。
921
+ const handover = db.get<Record<string, unknown>>(
922
+ `SELECT tmux_name, server, ip, hostname, agent, project_dir, version, node_id, session_id, config_path,
923
+ channels, model, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb,
924
+ disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct,
925
+ process_uptime_seconds, process_in_flight_count, external_schedules, registered_at
926
+ FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3
927
+ ORDER BY updated_at DESC LIMIT 1`,
928
+ effectiveAlias, resume_id, sessionNetId,
929
+ ) ?? null;
930
+ const keep = <T,>(fresh: T | null | undefined, column: string): T | null =>
931
+ (fresh ?? (handover?.[column] as T | null | undefined) ?? null);
392
932
  db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [effectiveAlias, resume_id, sessionNetId]);
393
933
  db.run(
394
- `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)
395
- 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'))
934
+ `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)
935
+ 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'))
396
936
  ON CONFLICT(resume_id) DO UPDATE SET
397
937
  alias = COALESCE(?2, sessions.alias), tmux_name = COALESCE(?3, sessions.tmux_name),
398
938
  server = COALESCE(?4, sessions.server), ip = COALESCE(?5, sessions.ip),
@@ -417,9 +957,57 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
417
957
  process_cpu_pct = COALESCE(?31, sessions.process_cpu_pct),
418
958
  process_uptime_seconds = COALESCE(?32, sessions.process_uptime_seconds),
419
959
  process_in_flight_count = COALESCE(?33, sessions.process_in_flight_count),
960
+ external_schedules = COALESCE(?34, sessions.external_schedules),
961
+ peer_reply_inbox_capable = ?35,
420
962
  last_seen_at = datetime('now'), updated_at = datetime('now')`,
421
- [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]
963
+ [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]
422
964
  );
965
+ if (handover) {
966
+ // 换 resume_id 那条路径上 ON CONFLICT 不会触发;INSERT 完再把没上报(仍为 NULL)的
967
+ // 描述性列从被替换的那行接手。INSERT 语句与参数列表保持原样 —— test698 的变异
968
+ // 用 sed 钉着它的字节形状。
969
+ db.run(
970
+ `UPDATE sessions SET
971
+ tmux_name = COALESCE(tmux_name, ?2), server = COALESCE(server, ?3), ip = COALESCE(ip, ?4),
972
+ hostname = COALESCE(hostname, ?5), agent = COALESCE(agent, ?6), project_dir = COALESCE(project_dir, ?7),
973
+ version = COALESCE(version, ?8), node_id = COALESCE(node_id, ?9), session_id = COALESCE(session_id, ?10),
974
+ config_path = COALESCE(config_path, ?11), channels = COALESCE(channels, ?12), model = COALESCE(model, ?13),
975
+ cpu_load_1min = COALESCE(cpu_load_1min, ?14), cpu_cores = COALESCE(cpu_cores, ?15),
976
+ mem_total_gb = COALESCE(mem_total_gb, ?16), mem_used_gb = COALESCE(mem_used_gb, ?17),
977
+ mem_avail_gb = COALESCE(mem_avail_gb, ?18), disk_total_gb = COALESCE(disk_total_gb, ?19),
978
+ disk_used_gb = COALESCE(disk_used_gb, ?20), disk_avail_gb = COALESCE(disk_avail_gb, ?21),
979
+ process_rss_bytes = COALESCE(process_rss_bytes, ?22), process_rss_mb = COALESCE(process_rss_mb, ?23),
980
+ process_cpu_pct = COALESCE(process_cpu_pct, ?24), process_uptime_seconds = COALESCE(process_uptime_seconds, ?25),
981
+ process_in_flight_count = COALESCE(process_in_flight_count, ?26),
982
+ external_schedules = COALESCE(external_schedules, ?27),
983
+ registered_at = COALESCE(?28, registered_at)
984
+ WHERE resume_id = ?1`,
985
+ [resume_id, handover.tmux_name ?? null, handover.server ?? null, handover.ip ?? null, handover.hostname ?? null,
986
+ handover.agent ?? null, handover.project_dir ?? null, handover.version ?? null, handover.node_id ?? null,
987
+ handover.session_id ?? null, handover.config_path ?? null, handover.channels ?? null, handover.model ?? null,
988
+ handover.cpu_load_1min ?? null, handover.cpu_cores ?? null, handover.mem_total_gb ?? null, handover.mem_used_gb ?? null,
989
+ handover.mem_avail_gb ?? null, handover.disk_total_gb ?? null, handover.disk_used_gb ?? null, handover.disk_avail_gb ?? null,
990
+ handover.process_rss_bytes ?? null, handover.process_rss_mb ?? null, handover.process_cpu_pct ?? null,
991
+ handover.process_uptime_seconds ?? null, handover.process_in_flight_count ?? null, handover.external_schedules ?? null,
992
+ handover.registered_at ?? null],
993
+ );
994
+ }
995
+ // app#225 follow-up — separate statement on purpose: the INSERT above is
996
+ // byte-pinned by test698's mutation harness. Sticky: only ever set to 1,
997
+ // and only by a node token bound to this alias (a user token or another
998
+ // node cannot mark someone else's session as able to serve its files).
999
+ if (rulesFileCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
1000
+ db.run("UPDATE sessions SET rules_file_capable = 1 WHERE resume_id = ?1", [resume_id]);
1001
+ }
1002
+ if (skillsCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
1003
+ db.run("UPDATE sessions SET skills_capable = 1 WHERE resume_id = ?1", [resume_id]);
1004
+ }
1005
+ if (filesCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
1006
+ db.run("UPDATE sessions SET files_capable = 1 WHERE resume_id = ?1", [resume_id]);
1007
+ }
1008
+ if (logsCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
1009
+ db.run("UPDATE sessions SET logs_capable = 1 WHERE resume_id = ?1", [resume_id]);
1010
+ }
423
1011
  if (host || proc) {
424
1012
  db.run(
425
1013
  `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)
@@ -468,6 +1056,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
468
1056
  upsertNodeWithSec1Guard({
469
1057
  node_id,
470
1058
  callerNetworkId: effectiveNetId ?? null,
1059
+ callerUserId: enforceUserId ?? null,
1060
+ callerTokenId: callerTokenId ?? null,
471
1061
  node_name: nn || effectiveAlias,
472
1062
  alias: effectiveAlias,
473
1063
  runtime: nodeRuntime,
@@ -559,6 +1149,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
559
1149
  } else {
560
1150
  updatedTaskId = task;
561
1151
  }
1152
+ if (updatedTaskId) syncScheduledRunForTask(updatedTaskId, effectiveNetId);
562
1153
  });
563
1154
  // Log event after transaction
564
1155
  if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
@@ -619,7 +1210,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
619
1210
  const rows0 = db.get<{ cnt: number }>(countSql, ...countParams);
620
1211
  console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
621
1212
  const rowsParams: any[] = [alias];
622
- let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json
1213
+ let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json,
1214
+ CASE WHEN type = 'task' THEN COALESCE(task_id, id) ELSE task_id END AS task_id
623
1215
  FROM inbox WHERE session_name = ?1 AND acked = 0`;
624
1216
  rowsSql = addReadScope(rowsSql, rowsParams, readScope);
625
1217
  rowsSql += ` ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
@@ -643,13 +1235,32 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
643
1235
  alias: z.string().min(1).max(200).describe("Session alias"),
644
1236
  message_id: z.string().min(1).max(200),
645
1237
  response: z.string().max(10000).optional(),
1238
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
646
1239
  },
647
- async ({ alias, message_id, response }) => {
648
- const effectiveNetId = getNetworkId(null);
1240
+ async ({ alias, message_id, response, network_id: netId }) => {
1241
+ const effectiveNetId = getNetworkId(netId);
649
1242
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
650
1243
  console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
651
- const ackParams: any[] = [message_id, alias];
652
- let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2";
1244
+ // New task consumers ACK by the logical tasks.task_id exposed by
1245
+ // get_inbox. Keep exact inbox.id support for legacy consumers and for
1246
+ // non-task messages. Restrict the lookup to pending rows: after a retry
1247
+ // the original row can have id == task_id but is already ACKed, while
1248
+ // the current row has a fresh transport id and the same logical task_id.
1249
+ const inboxTaskParams: any[] = [message_id, alias];
1250
+ let inboxTaskSql = `SELECT id, type, COALESCE(task_id, id) AS task_id
1251
+ FROM inbox
1252
+ WHERE session_name = ?2 AND acked = 0
1253
+ AND (id = ?1 OR (type = 'task' AND task_id = ?1))`;
1254
+ inboxTaskSql = addScope(inboxTaskSql, inboxTaskParams, effectiveNetId);
1255
+ inboxTaskSql += " ORDER BY created_at DESC LIMIT 1";
1256
+ const inboxTask = db.get<{ id: string; type: string; task_id: string }>(inboxTaskSql, ...inboxTaskParams);
1257
+ if (!inboxTask) {
1258
+ return {
1259
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "message not found or already acknowledged" }) }],
1260
+ };
1261
+ }
1262
+ const ackParams: any[] = [inboxTask.id, alias];
1263
+ let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2 AND acked = 0";
653
1264
  ackSql = addScope(ackSql, ackParams, effectiveNetId);
654
1265
  const result = db.run(ackSql, ackParams);
655
1266
  if (result.changes === 0) {
@@ -659,11 +1270,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
659
1270
  }
660
1271
  // V2: sync tasks table — ack_inbox means delivered→acked
661
1272
  try {
662
- const taskParams: any[] = [message_id];
1273
+ if (inboxTask?.type !== "task") {
1274
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }] };
1275
+ }
1276
+ const taskParams: any[] = [inboxTask.task_id];
663
1277
  let taskSql = "UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'";
664
1278
  taskSql = addScope(taskSql, taskParams, effectiveNetId);
665
1279
  const ackResult = db.run(taskSql, taskParams);
666
- if (ackResult.changes > 0) logTaskEvent(message_id, "delivered", "acked", alias);
1280
+ if (ackResult.changes > 0) logTaskEvent(inboxTask.task_id, "delivered", "acked", alias);
667
1281
  } catch {}
668
1282
  return {
669
1283
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }],
@@ -671,44 +1285,252 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
671
1285
  }
672
1286
  );
673
1287
 
1288
+ // #520 — two monotonic runtime-evidence levels for exact tasks.
1289
+ //
1290
+ // runtime_submitted_at means agent-node handed the body to the vendor
1291
+ // runtime. consumed_at is stronger: an attributable turn-start or first
1292
+ // activity event came back. Merely fetching/acking an inbox row sets neither.
1293
+ // Node identity comes exclusively from the ntok; callers cannot self-report
1294
+ // an alias or node_id. A consumed mark also fills runtime_submitted_at because
1295
+ // that stronger fact logically implies submission.
1296
+ const markTaskRuntimeEvidence = (
1297
+ taskIds: string[],
1298
+ level: "submitted" | "consumed",
1299
+ taskContexts: Array<{ task_id: string; thread_id: string; turn_id: string }> = [],
1300
+ ) => {
1301
+ const task_ids = taskIds;
1302
+ if (!callerTokenIsNetwork || !callerAlias || !enforceNetworkId) {
1303
+ return {
1304
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_required" }) }],
1305
+ };
1306
+ }
1307
+ // PgAdapter.transaction currently opens a fresh subprocess/connection
1308
+ // per statement. The all-or-nothing batch promise cannot be made there;
1309
+ // refuse evidence rather than publish a partially stamped batch.
1310
+ if (db.dialect !== "sqlite") {
1311
+ return {
1312
+ content: [{ type: "text" as const, text: JSON.stringify({
1313
+ ok: false,
1314
+ error: "task_runtime_evidence_backend_unsupported",
1315
+ }) }],
1316
+ };
1317
+ }
1318
+
1319
+ const uniqueTaskIds = [...new Set(task_ids)];
1320
+ if (uniqueTaskIds.length !== task_ids.length) {
1321
+ return {
1322
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_id" }) }],
1323
+ };
1324
+ }
1325
+
1326
+ if (level !== "consumed" && taskContexts.length > 0) {
1327
+ return {
1328
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "runtime_context_requires_consumed" }) }],
1329
+ };
1330
+ }
1331
+ const contextByTaskId = new Map(taskContexts.map((context) => [context.task_id, context]));
1332
+ if (contextByTaskId.size !== taskContexts.length) {
1333
+ return {
1334
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_context" }) }],
1335
+ };
1336
+ }
1337
+ const foreignContext = taskContexts.find((context) => !uniqueTaskIds.includes(context.task_id));
1338
+ if (foreignContext) {
1339
+ return {
1340
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task_context_not_requested", task_id: foreignContext.task_id }) }],
1341
+ };
1342
+ }
1343
+
1344
+ const placeholders = uniqueTaskIds.map((_, i) => `?${i + 2}`).join(", ");
1345
+
1346
+ // Keep ownership preflight and every stamp in one SQLite transaction.
1347
+ // This closes the cross-process preflight→UPDATE race: no other writer
1348
+ // can reassign a task between the identity decision and the write.
1349
+ const outcome = db.transaction(() => {
1350
+ const canonicalCaller = resolveCanonicalAlias(enforceNetworkId, callerAlias).alias;
1351
+ const callerSession = db.get<{ node_id: string | null }>(
1352
+ `SELECT node_id FROM sessions
1353
+ WHERE network_id = ?1 AND alias = ?2
1354
+ ORDER BY updated_at DESC LIMIT 1`,
1355
+ enforceNetworkId,
1356
+ canonicalCaller,
1357
+ );
1358
+ const rows = db.all<{ task_id: string; to_node_id: string | null; to_name: string; thread_id: string | null; turn_id: string | null }>(
1359
+ `SELECT task_id, to_node_id, to_name, thread_id, turn_id FROM tasks
1360
+ WHERE network_id = ?1 AND task_id IN (${placeholders})`,
1361
+ enforceNetworkId,
1362
+ ...uniqueTaskIds,
1363
+ );
1364
+ const owned = new Map(rows.map((row) => [row.task_id, row]));
1365
+ const rejectedTaskId = uniqueTaskIds.find((taskId) => {
1366
+ const row = owned.get(taskId);
1367
+ if (!row) return true;
1368
+ // Prefer immutable node identity whenever the task has one. Direct
1369
+ // and legacy token-bound sessions legitimately have NULL node_id;
1370
+ // only that shape may fall back to the canonical token alias.
1371
+ if (row.to_node_id) return row.to_node_id !== callerSession?.node_id;
1372
+ return resolveCanonicalAlias(enforceNetworkId, row.to_name).alias !== canonicalCaller;
1373
+ });
1374
+ if (rejectedTaskId) {
1375
+ return { ok: false as const, error: "task_not_owned", task_id: rejectedTaskId };
1376
+ }
1377
+ const conflictingContext = uniqueTaskIds.find((taskId) => {
1378
+ const context = contextByTaskId.get(taskId);
1379
+ if (!context) return false;
1380
+ const row = owned.get(taskId)!;
1381
+ return (row.thread_id !== null && row.thread_id !== context.thread_id)
1382
+ || (row.turn_id !== null && row.turn_id !== context.turn_id);
1383
+ });
1384
+ if (conflictingContext) {
1385
+ return { ok: false as const, error: "task_runtime_context_conflict", task_id: conflictingContext };
1386
+ }
1387
+
1388
+ for (const taskId of uniqueTaskIds) {
1389
+ const row = owned.get(taskId)!;
1390
+ const ownershipSql = row.to_node_id
1391
+ ? "to_node_id = ?3"
1392
+ : "to_node_id IS NULL AND to_name = ?3";
1393
+ const ownerValue = row.to_node_id ?? row.to_name;
1394
+ let updateResult;
1395
+ if (level === "consumed") {
1396
+ const context = contextByTaskId.get(taskId);
1397
+ updateResult = db.run(
1398
+ `UPDATE tasks SET
1399
+ runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now')),
1400
+ consumed_at = COALESCE(consumed_at, datetime('now')),
1401
+ thread_id = COALESCE(thread_id, ?4),
1402
+ turn_id = COALESCE(turn_id, ?5)
1403
+ WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
1404
+ [enforceNetworkId, taskId, ownerValue, context?.thread_id ?? null, context?.turn_id ?? null],
1405
+ );
1406
+ } else {
1407
+ updateResult = db.run(
1408
+ `UPDATE tasks SET runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now'))
1409
+ WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
1410
+ [enforceNetworkId, taskId, ownerValue],
1411
+ );
1412
+ }
1413
+ if (updateResult.changes !== 1) {
1414
+ // A zero-row write after a successful preflight is an invariant
1415
+ // failure, never a successful evidence report.
1416
+ throw new Error(`task_runtime_evidence_write_race:${taskId}`);
1417
+ }
1418
+ }
1419
+ const evidenceRows = db.all<{
1420
+ task_id: string;
1421
+ runtime_submitted_at: string;
1422
+ consumed_at: string | null;
1423
+ thread_id: string | null;
1424
+ turn_id: string | null;
1425
+ }>(
1426
+ `SELECT task_id, runtime_submitted_at, consumed_at, thread_id, turn_id FROM tasks
1427
+ WHERE network_id = ?1 AND task_id IN (${placeholders})`,
1428
+ enforceNetworkId,
1429
+ ...uniqueTaskIds,
1430
+ );
1431
+ return { ok: true as const, tasks: evidenceRows };
1432
+ });
1433
+
1434
+ return {
1435
+ content: [{
1436
+ type: "text" as const,
1437
+ text: JSON.stringify(outcome),
1438
+ }],
1439
+ };
1440
+ };
1441
+
1442
+ const taskRuntimeEvidenceSchema = {
1443
+ task_ids: z.array(z.string().min(1).max(200)).min(1).max(100),
1444
+ task_contexts: z.array(z.object({
1445
+ task_id: z.string().min(1).max(200),
1446
+ thread_id: z.string().min(1).max(500),
1447
+ turn_id: z.string().min(1).max(500),
1448
+ }).strict()).max(100).optional(),
1449
+ };
1450
+
1451
+ server.tool(
1452
+ "mark_tasks_runtime_submitted",
1453
+ "Internal agent-node signal: exact task bodies were submitted to this token-bound node's vendor runtime.",
1454
+ taskRuntimeEvidenceSchema,
1455
+ async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "submitted", task_contexts),
1456
+ );
1457
+
1458
+ server.tool(
1459
+ "mark_tasks_consumed",
1460
+ "Internal agent-node signal: exact tasks produced attributable turn-start/activity evidence in this token-bound node's runtime.",
1461
+ taskRuntimeEvidenceSchema,
1462
+ async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "consumed", task_contexts),
1463
+ );
1464
+
674
1465
  // ═══════════════════════════════════════════
675
1466
  // Hub Tools (5)
676
1467
  // ═══════════════════════════════════════════
677
1468
 
678
1469
  server.tool(
679
1470
  "get_all_status",
680
- "Get status of all sessions. Hub uses this for the patrol loop.",
1471
+ "Get status of all sessions. Hub uses this for the patrol loop. " +
1472
+ "Pass filter_alias (comma-separated) when you only care about specific " +
1473
+ "nodes — the unfiltered result is one row per session with 31 columns and " +
1474
+ "is large enough on a real fleet that callers cannot read it.",
681
1475
  {
682
1476
  filter_status: z.string().max(50).optional(),
683
1477
  filter_server: z.string().max(200).optional(),
1478
+ // 2026-08-17: on a 222-session hub the unfiltered response is ~259 KB, past
1479
+ // what an MCP client can take in one result — so the caller that wanted the
1480
+ // status of THREE nodes could not get it from this tool at all. The patrol
1481
+ // loop still wants everything, hence optional rather than required.
1482
+ filter_alias: z.string().max(2000).optional()
1483
+ .describe("One alias, or several separated by commas. Exact matches only."),
684
1484
  network_id: z.string().max(200).optional().describe("Filter by network"),
685
1485
  },
686
- async ({ filter_status, filter_server, network_id: netId }) => {
1486
+ async ({ filter_status, filter_server, filter_alias, network_id: netId }) => {
687
1487
  const readScope = resolveReadScope(netId);
688
1488
  if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
689
- console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${readScope.networkId ? " net=" + readScope.networkId.slice(0, 12) : ""}`);
1489
+ 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) : ""}`);
690
1490
 
691
1491
  // Round-2/4 review ③: stale-marking moved to startStaleSessionSweeper()
692
1492
  // (background timer, ~60s cadence). Read path no longer fires UPDATE.
693
1493
  let sql = "SELECT * FROM sessions WHERE 1=1";
694
1494
  const params: any[] = [];
695
- sql = addReadScope(sql, params, readScope);
1495
+ sql = addAgentNetworkScope(sql, params, readScope, { alias: "alias", nodeId: "node_id" });
696
1496
  if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
697
1497
  if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
1498
+ const aliasFilter = parseAliasFilter(filter_alias);
1499
+ const aliases = aliasFilter.aliases;
1500
+ if (aliasFilter.sql) {
1501
+ sql += aliasFilter.sql;
1502
+ params.push(...aliases);
1503
+ }
698
1504
  sql += " ORDER BY updated_at DESC";
699
1505
  const sessions = db.all(sql, ...params);
700
1506
 
1507
+ // `summary` has always counted every session in the read scope, ignoring
1508
+ // filter_status / filter_server — and now filter_alias. That is fine for
1509
+ // the patrol loop, but a caller who asked about three aliases and gets
1510
+ // back three rows plus "idle: 96" can easily read the 96 as being about
1511
+ // their three. A count that does not say what it counted invites exactly
1512
+ // that. So the response now says so, rather than the semantics changing
1513
+ // under existing callers.
701
1514
  const summaryParams: any[] = [];
702
1515
  let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
703
- summarySql = addReadScope(summarySql, summaryParams, readScope);
1516
+ summarySql = addAgentNetworkScope(summarySql, summaryParams, readScope, { alias: "alias", nodeId: "node_id" });
704
1517
  summarySql += " GROUP BY status";
705
1518
  const summary = db.all(summarySql, ...summaryParams);
706
1519
 
1520
+ const filtered = !!(filter_status || filter_server || aliases.length > 0);
707
1521
  return {
708
1522
  content: [
709
1523
  {
710
1524
  type: "text" as const,
711
- text: JSON.stringify({ ok: true, sessions, summary }),
1525
+ text: JSON.stringify({
1526
+ ok: true,
1527
+ sessions,
1528
+ summary,
1529
+ summary_scope: filtered
1530
+ ? "every session in the read scope — NOT narrowed by the filters applied to `sessions`"
1531
+ : "every session in the read scope",
1532
+ sessions_returned: sessions.length,
1533
+ }),
712
1534
  },
713
1535
  ],
714
1536
  };
@@ -725,7 +1547,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
725
1547
  console.log(`[${ts()}] hub → get_session_status: ${alias}`);
726
1548
  const sessionParams: any[] = [alias];
727
1549
  let sessionSql = "SELECT * FROM sessions WHERE alias = ?1";
728
- sessionSql = addReadScope(sessionSql, sessionParams, readScope);
1550
+ sessionSql = addAgentNetworkScope(sessionSql, sessionParams, readScope, { alias: "alias", nodeId: "node_id" });
729
1551
  const session = db.get(sessionSql, ...sessionParams);
730
1552
 
731
1553
  const pendingParams: any[] = [alias];
@@ -766,7 +1588,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
766
1588
  },
767
1589
  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);
768
1590
  const effectiveNetId = getNetworkId(netId);
769
- const metaJson = normalizeMetaJson(meta);
1591
+ const authOrigin: TaskAuthOrigin = callerTokenIsNetwork
1592
+ ? "node"
1593
+ : enforceUserId
1594
+ ? "user"
1595
+ : "legacy";
1596
+ const metaJson = normalizeMetaJson(stampTaskAuthOrigin(meta, authOrigin));
770
1597
 
771
1598
  // Role check FIRST — round5 follow-up (通信牛 oracle catch):
772
1599
  // the explicit-parent verification below distinguishes
@@ -775,8 +1602,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
775
1602
  // existence + ownership of foreign parents via the difference in
776
1603
  // error codes. Run canWrite first so a viewer ALWAYS gets the
777
1604
  // same `permission_denied` regardless of parent state.
778
- if (!canWrite(effectiveNetId)) {
779
- return writeDeniedReply(effectiveNetId, "send_task");
1605
+ {
1606
+ const sendDenied = agentSendDenied(effectiveNetId, alias, _fromIn, "send_task", meta);
1607
+ if (sendDenied) return sendDenied;
780
1608
  }
781
1609
 
782
1610
  // Resolve parent_task_id: explicit > inferred (caller's most recent
@@ -841,6 +1669,46 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
841
1669
  const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
842
1670
  if (target.state === "not_found") return deliveryTargetReply(target)!;
843
1671
 
1672
+ // Dashboard sends carry a random client_request_id inside meta. Derive
1673
+ // the task primary key from authenticated scope + that request id so a
1674
+ // lost HTTP response can be retried safely, even after a hub restart.
1675
+ // The existing row must match the full request; key reuse with changed
1676
+ // content/target fails closed instead of silently returning another task.
1677
+ const clientRequestId = clientRequestIdFromMeta(meta);
1678
+ const id = clientRequestId
1679
+ ? idempotentTaskId(effectiveNetId ?? null, from_session, clientRequestId)
1680
+ : uuidv4();
1681
+ if (clientRequestId) {
1682
+ const existing = db.get<StoredIdempotentTask & { to_node_id: string | null }>(
1683
+ "SELECT task_id, from_name, to_node_id, to_name, priority, content, network_id, meta_json, status FROM tasks WHERE task_id = ?1",
1684
+ [id],
1685
+ );
1686
+ if (existing) {
1687
+ if (!idempotentTaskMatches(existing, {
1688
+ fromName: from_session, toName: targetAlias, priority, content: task,
1689
+ networkId: effectiveNetId ?? null, metaJson,
1690
+ })) {
1691
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1692
+ ok: false, error: "idempotency_conflict",
1693
+ message: "client_request_id was already used with a different task payload",
1694
+ }) }] };
1695
+ }
1696
+ console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: REPLAY (key=request_id, task=${existing.task_id.slice(0, 13)})`);
1697
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1698
+ ok: true, message_id: existing.task_id, task_id: existing.task_id,
1699
+ task_status: existing.status, idempotent_replay: true,
1700
+ // A replay acknowledges the already-persisted dispatch. Use that
1701
+ // row rather than today's session record so alias reuse or a
1702
+ // restarted process cannot rewrite historical recipient identity.
1703
+ actual_to: {
1704
+ alias: existing.to_name,
1705
+ to_node_id: existing.to_node_id ?? null,
1706
+ network_id: existing.network_id ?? null,
1707
+ },
1708
+ }) }] };
1709
+ }
1710
+ }
1711
+
844
1712
  // #212 dedup guardrail. If this exact (from, to, content) has already
845
1713
  // been delivered within COMMHUB_SEND_DEDUP_WINDOW_MS (default 5 min)
846
1714
  // we refuse the call and surface a structured `duplicate_send`
@@ -849,7 +1717,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
849
1717
  // incident: 50+ identical dispatches across 5 LLM turns ignored
850
1718
  // three STOP replies — the LLM cannot be trusted to debounce
851
1719
  // itself, so the runtime layer must.
852
- const dedup = sharedSendDedup.check(from_session, targetAlias, task);
1720
+ //
1721
+ // A send carrying a validated client_request_id is keyed on that id
1722
+ // instead of the content hash (send_dedup.ts header): a human typing
1723
+ // the same short text twice sent two messages. Same-id retries were
1724
+ // already answered from the durable row above.
1725
+ const dedupScope = { requestId: clientRequestId, networkId: effectiveNetId ?? null };
1726
+ const dedup = sharedSendDedup.check(from_session, targetAlias, task, undefined, dedupScope);
853
1727
  if (dedup.duplicate) {
854
1728
  const payload = buildDuplicateSendPayload({
855
1729
  from: from_session,
@@ -857,22 +1731,28 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
857
1731
  ageMs: dedup.ageMs,
858
1732
  windowMs: sharedSendDedup.windowMs,
859
1733
  });
860
- console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: DROPPED duplicate (age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
1734
+ console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: DROPPED duplicate (key=${dedup.kind}, age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
861
1735
  return { content: [{ type: "text" as const, text: JSON.stringify(payload) }] };
862
1736
  }
863
1737
 
1738
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 1/6).
1739
+ {
1740
+ const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
1741
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1742
+ }
1743
+
864
1744
  console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
865
- const id = uuidv4();
866
1745
  const fromNodeId = resolveNodeIdForAlias(from_session, effectiveNetId);
867
1746
  const targetNodeId = target.session?.node_id ?? null;
1747
+ const resolvedActualTo = actualTo(target, effectiveNetId);
868
1748
  // 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
869
1749
  // dashboard 在派任务一刻就反映出"任务已下发",不再等 agent 的
870
1750
  // report_status 心跳;status 字段交给 agent,避免与 working/idle
871
1751
  // 报告冲突)。
872
1752
  db.transaction(() => {
873
1753
  db.run(
874
- `INSERT INTO inbox (id, session_name, node_id, type, priority, content, context, from_session, requires_response, network_id, meta_json)
875
- VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, ?7, 'reply', ?8, ?9)`,
1754
+ `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, context, from_session, requires_response, network_id, meta_json)
1755
+ VALUES (?1, ?1, ?2, ?3, 'task', ?4, ?5, ?6, ?7, 'reply', ?8, ?9)`,
876
1756
  [id, targetAlias, targetNodeId, priority, task, context ?? null, from_session, effectiveNetId ?? null, metaJson]
877
1757
  );
878
1758
  db.run(
@@ -884,12 +1764,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
884
1764
  let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
885
1765
  touchSql = addScope(touchSql, touchParams, effectiveNetId);
886
1766
  db.run(touchSql, touchParams);
1767
+ // #1548 —— 能派活的发送方不可能真的 blocked;此处出 blocked → idle。
1768
+ releaseBlockedSession(from_session, effectiveNetId);
887
1769
  });
888
1770
  logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${targetAlias} (parent=${parentTaskId.slice(0,8)})` : `→ ${targetAlias}`);
889
1771
  // Only stamp the dedup index after the inbox/tasks transaction
890
1772
  // succeeds, so a failed insert never silently shadows a legitimate
891
1773
  // retry.
892
- sharedSendDedup.record(from_session, targetAlias, task);
1774
+ sharedSendDedup.record(from_session, targetAlias, task, undefined, dedupScope);
893
1775
 
894
1776
  // SSE push by alias.
895
1777
  // The SSE channel is keyed by alias (subscribers connected to /events/<alias>),
@@ -902,9 +1784,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
902
1784
  let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
903
1785
  pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
904
1786
  const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
905
- if (target.state === "online") {
906
- pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
907
- }
1787
+ // 🔴 不要用 last_seen_at 的时间戳去猜「有没有人在听」——SSE 订阅者注册表
1788
+ // 就是那件事本身。pushEvent 在没有订阅者时已经是 no-op,所以这道闸不提供
1789
+ // 任何保护,只会把**连着但最近没心跳**的会话排除掉;而全网「停发心跳/状态类
1790
+ // 消息」的省额度规则让安静恰恰是常态。上面那段注释早就写着 "Push
1791
+ // unconditionally",同一类缺陷上次也是打在 Dashboard 任务上(f015d9d6:
1792
+ // 任务标了 delivered,会话却从未收到推送)。
1793
+ pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
1794
+ const actuallyDelivered = hasSubscribers(targetAlias, effectiveNetId);
1795
+ // #461 network observer summary — unconditional (task row exists
1796
+ // even when the target is offline/queued), metadata only.
1797
+ pushNetworkObserverEvent(effectiveNetId, { type: "new_task", task_id: id, from: from_session, to: targetAlias, status: actuallyDelivered ? "delivered" : "queued", priority });
908
1798
 
909
1799
  if (target.state === "offline") {
910
1800
  return {
@@ -919,6 +1809,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
919
1809
  task_id: id,
920
1810
  message_id: id,
921
1811
  session_status: target.session.status ?? "offline",
1812
+ actual_to: resolvedActualTo,
922
1813
  ...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
923
1814
  }),
924
1815
  }],
@@ -932,6 +1823,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
932
1823
  text: JSON.stringify({
933
1824
  ok: true,
934
1825
  message_id: id,
1826
+ actual_to: resolvedActualTo,
935
1827
  ...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
936
1828
  session_status: target.session?.status ?? "unknown",
937
1829
  }),
@@ -948,14 +1840,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
948
1840
  alias: z.string().min(1).max(200).describe("Target session alias"),
949
1841
  message: z.string().min(1).max(10000).describe("Message content"),
950
1842
  from_session: z.string().max(200).optional(),
1843
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
951
1844
  },
952
- async ({ alias, message, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
953
- const effectiveNetId = getNetworkId(null);
954
- if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1845
+ async ({ alias, message, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1846
+ const effectiveNetId = getNetworkId(netId);
1847
+ { const sendDenied = agentSendDenied(effectiveNetId, alias, _fromIn, "write"); if (sendDenied) return sendDenied; }
955
1848
  const canonical = resolveCanonicalAlias(effectiveNetId, alias);
956
1849
  const targetAlias = canonical.alias;
957
1850
  const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
958
1851
  if (target.state === "not_found") return deliveryTargetReply(target)!;
1852
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 2/6).
1853
+ {
1854
+ const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
1855
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1856
+ }
959
1857
  console.log(`[${ts()}] ${from_session} → send_message → ${targetAlias}: ${message.slice(0, 60)}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
960
1858
  const id = uuidv4();
961
1859
  db.run(
@@ -964,9 +1862,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
964
1862
  [id, targetAlias, target.session?.node_id ?? null, message, from_session, effectiveNetId ?? null]
965
1863
  );
966
1864
 
967
- if (target.state === "online") {
968
- pushEvent(targetAlias, { type: "new_message", from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
969
- }
1865
+ // 同 send_task:可达性用订阅者注册表判,不用心跳时间戳猜。
1866
+ pushEvent(targetAlias, { type: "new_message", inbox_count: pendingInboxCount(targetAlias, effectiveNetId), from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
970
1867
 
971
1868
  const offlineReply = deliveryTargetReply(target, { message_id: id });
972
1869
  if (offlineReply) {
@@ -994,96 +1891,270 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
994
1891
  }
995
1892
  );
996
1893
 
997
- // ── V2: send_reply (关联 task_id,不触发 think) ──
998
- server.tool(
999
- "send_reply",
1000
- "Send a reply to a task. Linked to task_id via in_reply_to. Does NOT trigger agent processing.",
1001
- {
1002
- alias: z.string().min(1).max(200).describe("Target session alias"),
1894
+ // ── V2/V3 reply primitives ──
1895
+ const replyToolSchema = {
1896
+ // #1085 — alias is OPTIONAL: when omitted (or empty) and `in_reply_to`
1897
+ // is set, the hub derives the target from the original task's
1898
+ // `from_name` (it already SELECTs that task by in_reply_to below).
1899
+ // The SDK-runtime bridge (agent-node/src/commhub-mcp.ts) exposes
1900
+ // send_reply as (task_id, text, status) with NO alias, so before this
1901
+ // it always sent alias=undefined → -32602 "expected string, received
1902
+ // undefined at alias". The target was already knowable from the task,
1903
+ // so requiring the caller to pass it was a redundant-param trap.
1904
+ alias: z.string().min(1).max(200).optional().describe("Target session alias — optional; derived from in_reply_to's task sender when omitted"),
1003
1905
  text: z.string().min(1).max(10000).describe("Reply content"),
1004
1906
  in_reply_to: z.string().max(200).optional().describe("Original task/message ID"),
1005
1907
  status: z.enum(["replied", "failed", "cancelled"]).optional().default("replied").describe("Task outcome"),
1006
1908
  from_session: z.string().max(200).optional(),
1007
- },
1008
- async ({ alias, text, in_reply_to, status: replyStatus, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1009
- const effectiveNetId = getNetworkId(null);
1909
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1910
+ // #507 — top-level attachments (parity with REST /api/task L506). MCP Zod
1911
+ // otherwise silently strips unknown fields, so a caller passing
1912
+ // `attachments` before this schema entry existed would see `ok:true`
1913
+ // and never learn the attachments were dropped. Validated by
1914
+ // validateAttachments (uploads.ts) — the same helper the REST path uses.
1915
+ 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."),
1916
+ // #507 — optional structured metadata (parity with send_task L837). If
1917
+ // both `attachments` (top-level) and `meta.attachments` are supplied,
1918
+ // top-level wins (same rule as REST /api/task L2101).
1919
+ meta: z.any().optional().describe("Optional structured reply metadata, e.g. { attachments: [...] }."),
1920
+ };
1921
+ const handleReply = async (args: any, peerCapabilityRequired: boolean) => {
1922
+ const { alias, text, in_reply_to, status: replyStatus = "replied", from_session: _fromIn, network_id: netId, attachments, meta } = args;
1923
+ const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1924
+ const effectiveNetId = getNetworkId(netId);
1010
1925
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1011
- console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
1012
- const id = uuidv4();
1013
- let taskBefore: { status: string } | null = null;
1014
- if (in_reply_to) {
1015
- const taskParams: any[] = [in_reply_to];
1016
- let taskSql = "SELECT status FROM tasks WHERE task_id = ?1";
1017
- taskSql = addScope(taskSql, taskParams, effectiveNetId);
1018
- taskBefore = db.get<{ status: string }>(taskSql, ...taskParams) ?? null;
1019
- if (!taskBefore) {
1020
- return {
1021
- content: [{
1022
- type: "text" as const,
1023
- text: JSON.stringify({
1024
- ok: false,
1025
- error: "reply_task_not_found",
1026
- message: `cannot apply reply: task not found (${in_reply_to})`,
1027
- in_reply_to,
1028
- reply_queued: false,
1029
- }),
1030
- }],
1031
- };
1926
+ if (peerCapabilityRequired && !in_reply_to) {
1927
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "peer_reply_task_required" }) }] };
1928
+ }
1929
+
1930
+ // #507 — validate attachments BEFORE any DB write. Rejects malformed
1931
+ // input (bad file_id, >20 items, size > cap, non-object item, wrong
1932
+ // type field) with an explicit error the caller can act on. Empty /
1933
+ // absent attachments return { ok: true, attachments: [] } — the
1934
+ // reverse-(e) invariant (no attachments → behavior unchanged) is
1935
+ // pinned by tests that assert byte-identical response shape
1936
+ // before/after this validation runs.
1937
+ const attachmentsResult = validateAttachments(attachments ?? (meta && typeof meta === "object" ? (meta as any).attachments : undefined));
1938
+ if (!attachmentsResult.ok) {
1939
+ return {
1940
+ content: [{
1941
+ type: "text" as const,
1942
+ text: JSON.stringify({
1943
+ ok: false,
1944
+ error: "bad_attachments",
1945
+ message: attachmentsResult.error,
1946
+ }),
1947
+ }],
1948
+ };
1949
+ }
1950
+ const mergedMeta = attachmentsResult.attachments.length
1951
+ ? { ...(meta && typeof meta === "object" ? meta : {}), attachments: attachmentsResult.attachments }
1952
+ : meta;
1953
+ const metaJson = normalizeMetaJson(mergedMeta);
1954
+
1955
+ // #1085 — derive the reply target when the caller omitted `alias`.
1956
+ // The original task's `from_name` IS the reply target; the hub knows
1957
+ // it from `in_reply_to`. Without this, an SDK-runtime send_reply
1958
+ // (schema has no alias) died with -32602 before reaching this handler.
1959
+ let effectiveAlias: string | undefined =
1960
+ (typeof alias === "string" && alias.trim()) ? alias : undefined;
1961
+ if (!effectiveAlias) {
1962
+ if (!in_reply_to) {
1963
+ 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" }) }] };
1032
1964
  }
1033
- if (!["created", "delivered", "acked", "running"].includes(taskBefore.status)) {
1034
- return {
1035
- content: [{
1036
- type: "text" as const,
1037
- text: JSON.stringify({
1038
- ok: false,
1039
- error: "reply_task_terminal",
1040
- message: `cannot apply reply: task is already terminal (${taskBefore.status})`,
1041
- in_reply_to,
1042
- task_status: taskBefore.status,
1043
- reply_queued: false,
1044
- }),
1045
- }],
1046
- };
1965
+ const derivParams: any[] = [in_reply_to];
1966
+ let derivSql = "SELECT from_name FROM tasks WHERE task_id = ?1";
1967
+ derivSql = addScope(derivSql, derivParams, effectiveNetId);
1968
+ const derived = db.get<{ from_name: string }>(derivSql, ...derivParams);
1969
+ if (!derived?.from_name) {
1970
+ 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})` }) }] };
1047
1971
  }
1972
+ effectiveAlias = derived.from_name;
1048
1973
  }
1049
- const replyTargetNodeId = resolveNodeIdForAlias(alias, effectiveNetId);
1050
- const replyLogged = db.transaction(() => {
1974
+
1975
+ const canonicalReplyTarget = resolveCanonicalAlias(effectiveNetId, effectiveAlias);
1976
+ const replyTargetAlias = canonicalReplyTarget.alias;
1977
+ 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}]` : ""}`);
1978
+ const id = uuidv4();
1979
+ const replyTargetNodeId = resolveNodeIdForAlias(replyTargetAlias, effectiveNetId);
1980
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 3/6).
1981
+ {
1982
+ const lc = assertNodeActive(replyTargetAlias, effectiveNetId ?? null);
1983
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1984
+ }
1985
+ const replyOutcome = db.transaction(() => {
1986
+ type ReplyTask = {
1987
+ status: string;
1988
+ from_node_id: string | null;
1989
+ from_name: string;
1990
+ to_name: string;
1991
+ to_node_id: string | null;
1992
+ };
1993
+ let taskBefore: ReplyTask | null = null;
1994
+ let callerNodeId: string | null = null;
1995
+ if (in_reply_to) {
1996
+ const taskParams: any[] = [in_reply_to];
1997
+ let taskSql = "SELECT status, from_node_id, from_name, to_name, to_node_id FROM tasks WHERE task_id = ?1";
1998
+ taskSql = addScope(taskSql, taskParams, effectiveNetId);
1999
+ taskBefore = db.get<ReplyTask>(taskSql, ...taskParams) ?? null;
2000
+ if (!taskBefore) return { ok: false as const, error: "reply_task_not_found" as const };
2001
+ if (!["created", "delivered", "acked", "running"].includes(taskBefore.status)) {
2002
+ return { ok: false as const, error: "reply_task_terminal" as const, taskStatus: taskBefore.status };
2003
+ }
2004
+
2005
+ // V3 atomic peer replies are capability-negotiated. Both identity
2006
+ // bindings and the recipient capability are re-read inside this
2007
+ // transaction before any inbox/task/run write. Legacy/unbound rows
2008
+ // fail toward compatibility send_reply, which terminalizes the
2009
+ // original task instead of creating a second response-requiring row.
2010
+ if (peerCapabilityRequired) {
2011
+ if (!taskBefore.from_node_id) {
2012
+ // Origin type is an intrinsic property of the task and must be
2013
+ // decided before caller capability. A Dashboard/human task still
2014
+ // needs the established send_reply terminalization path when the
2015
+ // replying node uses a legacy/unbound token.
2016
+ return { ok: false as const, error: "peer_reply_origin_not_node" as const };
2017
+ }
2018
+ if (!callerTokenIsNetwork || !callerTokenId || !enforceNetworkId) {
2019
+ return { ok: false as const, error: "peer_reply_node_token_required" as const };
2020
+ }
2021
+ const token = db.get<{ bound_node_id: string | null }>(
2022
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
2023
+ callerTokenId,
2024
+ enforceNetworkId,
2025
+ );
2026
+ if (!token?.bound_node_id || !taskBefore.to_node_id) {
2027
+ return { ok: false as const, error: "peer_reply_unsupported" as const };
2028
+ }
2029
+ if (token.bound_node_id !== taskBefore.to_node_id) {
2030
+ return { ok: false as const, error: "reply_task_not_owned" as const };
2031
+ }
2032
+ callerNodeId = token.bound_node_id;
2033
+ const canonicalTarget = replyTargetAlias;
2034
+ const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, taskBefore.from_name).alias;
2035
+ if (canonicalTarget !== canonicalOrigin) {
2036
+ return { ok: false as const, error: "reply_target_mismatch" as const };
2037
+ }
2038
+ const recipient = db.get<{ peer_reply_inbox_capable: number }>(
2039
+ `SELECT peer_reply_inbox_capable FROM sessions
2040
+ WHERE node_id = ?1 AND network_id = ?2 AND alias = ?3
2041
+ ORDER BY updated_at DESC LIMIT 1`,
2042
+ taskBefore.from_node_id,
2043
+ enforceNetworkId,
2044
+ canonicalOrigin,
2045
+ );
2046
+ if (recipient?.peer_reply_inbox_capable !== 1) {
2047
+ return { ok: false as const, error: "peer_reply_unsupported" as const };
2048
+ }
2049
+ } else if (callerTokenIsNetwork && enforceNetworkId) {
2050
+ // Compatibility send_reply is still an exact task transition for
2051
+ // node callers. Bind it to the token-authenticated assignee alias
2052
+ // and immutable original sender. Alias binding survives a
2053
+ // legitimate node-id rotation without letting another node close
2054
+ // the task.
2055
+ const canonicalCaller = resolveCanonicalAlias(enforceNetworkId, from_session).alias;
2056
+ const canonicalAssignee = resolveCanonicalAlias(enforceNetworkId, taskBefore.to_name).alias;
2057
+ if (canonicalCaller !== canonicalAssignee) {
2058
+ return { ok: false as const, error: "reply_task_not_owned" as const };
2059
+ }
2060
+ const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, taskBefore.from_name).alias;
2061
+ if (replyTargetAlias !== canonicalOrigin) {
2062
+ return { ok: false as const, error: "reply_target_mismatch" as const };
2063
+ }
2064
+ }
2065
+
2066
+ }
2067
+
2068
+ // #507 — write meta_json on inbox insert (parity with send_task L952).
2069
+ // Prior to this the attachments field on a send_reply call was
2070
+ // silently stripped by MCP Zod, leaving `ok:true` with no persisted
2071
+ // meta.
1051
2072
  db.run(
1052
- `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
1053
- VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7)`,
1054
- [id, alias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null]
2073
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id, meta_json)
2074
+ VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7, ?8)`,
2075
+ [id, replyTargetAlias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null, metaJson]
1055
2076
  );
1056
2077
 
1057
2078
  // 更新 tasks 表
1058
2079
  if (in_reply_to) {
1059
- const updateParams: any[] = [replyStatus, text, in_reply_to];
1060
- let updateSql = `UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now')
1061
- WHERE task_id = ?3 AND status IN ('created', 'delivered', 'acked', 'running')`;
2080
+ // #507 — persist meta_json onto the tasks row too, so
2081
+ // dashboard's task view sees the reply's attachments alongside
2082
+ // the reply text (parity with send_task L957/959 which writes
2083
+ // meta_json into both inbox and tasks). The COALESCE guards
2084
+ // against clobbering pre-existing meta_json when this reply
2085
+ // brings no attachments (metaJson === null → keep the existing
2086
+ // task meta_json unchanged). Reverse-(e) invariant.
2087
+ // #1823 —— 回复的附件写到 tasks.meta_json.reply_attachments,**不碰** meta_json.attachments:
2088
+ // 那个键是提问者随任务带的附件,客户端把它画在提问者的气泡里。以前这里用回复的 metaJson
2089
+ // 整体 COALESCE 替换,于是 agent 回的图出现在提问者气泡下面、提问者自己带的附件被覆盖
2090
+ // (Vincent 2026-09-06 实测「好像没成功发送给我」)。inbox 行(收件方视角)照旧写 metaJson。
2091
+ const replyTaskMetaJson = (() => {
2092
+ if (!attachmentsResult.attachments.length) return null;
2093
+ const rowParams: any[] = [in_reply_to];
2094
+ let rowSql = "SELECT meta_json FROM tasks WHERE task_id = ?1";
2095
+ rowSql = addScope(rowSql, rowParams, effectiveNetId);
2096
+ const existing = db.get<{ meta_json: string | null }>(rowSql, ...rowParams)?.meta_json ?? null;
2097
+ let base: Record<string, unknown> = {};
2098
+ if (existing) { try { const parsed = JSON.parse(existing); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) base = parsed; } catch {} }
2099
+ return JSON.stringify({ ...base, reply_attachments: attachmentsResult.attachments });
2100
+ })();
2101
+ const updateParams: any[] = [replyStatus, text, replyTaskMetaJson, in_reply_to];
2102
+ let updateSql = `UPDATE tasks
2103
+ SET status = ?1,
2104
+ result = ?2,
2105
+ completed_at = datetime('now'),
2106
+ meta_json = COALESCE(?3, meta_json)
2107
+ WHERE task_id = ?4 AND status IN ('created', 'delivered', 'acked', 'running')`;
2108
+ if (peerCapabilityRequired && callerTokenIsNetwork && taskBefore) {
2109
+ if (taskBefore.to_node_id) {
2110
+ updateParams.push(callerNodeId);
2111
+ updateSql += ` AND to_node_id = ?${updateParams.length}`;
2112
+ } else {
2113
+ updateParams.push(taskBefore.to_name);
2114
+ updateSql += ` AND to_node_id IS NULL AND to_name = ?${updateParams.length}`;
2115
+ }
2116
+ }
1062
2117
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1063
2118
  const result = db.run(updateSql, updateParams);
1064
2119
  if (result.changes === 0) {
1065
- console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
1066
- return false;
2120
+ throw new Error(`reply_atomic_cas_failed:${in_reply_to}`);
1067
2121
  }
1068
- return true;
2122
+ syncScheduledRunForTask(in_reply_to, effectiveNetId);
2123
+ // #1548 —— 终态回复是最强的活性证据:一个还标着 blocked 的发送方在此回到 idle。
2124
+ // 只出 blocked;working 可能真在忙别的任务,不碰。
2125
+ releaseBlockedSession(from_session, effectiveNetId);
2126
+ return { ok: true as const, replyLogged: true };
1069
2127
  }
1070
- return false;
2128
+ return { ok: true as const, replyLogged: false };
1071
2129
  });
1072
2130
 
1073
- if (in_reply_to && !replyLogged) {
2131
+ if (!replyOutcome.ok) {
2132
+ const taskStatus = "taskStatus" in replyOutcome ? replyOutcome.taskStatus : undefined;
2133
+ const messages: Record<string, string> = {
2134
+ reply_task_not_found: `cannot apply reply: task not found (${in_reply_to})`,
2135
+ reply_task_terminal: `cannot apply reply: task is already terminal (${taskStatus})`,
2136
+ peer_reply_node_token_required: "atomic peer reply requires a node token",
2137
+ peer_reply_unsupported: "recipient or caller does not support atomic peer replies",
2138
+ peer_reply_origin_not_node: "original task sender is not a node; use send_reply",
2139
+ reply_node_identity_unbound: "cannot apply reply: node identity is not token-bound",
2140
+ reply_task_not_owned: "cannot apply reply: task is not owned by this node",
2141
+ reply_target_mismatch: "cannot apply reply: target is not the original task sender",
2142
+ };
1074
2143
  return {
1075
2144
  content: [{
1076
2145
  type: "text" as const,
1077
2146
  text: JSON.stringify({
1078
2147
  ok: false,
1079
- error: "reply_not_applied",
1080
- message: "reply was not applied to task",
2148
+ error: replyOutcome.error,
2149
+ message: messages[replyOutcome.error],
1081
2150
  in_reply_to,
2151
+ ...(taskStatus ? { task_status: taskStatus } : {}),
1082
2152
  reply_queued: false,
1083
2153
  }),
1084
2154
  }],
1085
2155
  };
1086
2156
  }
2157
+ const replyLogged = replyOutcome.replyLogged;
1087
2158
 
1088
2159
  // Log event after commit (outside transaction)
1089
2160
  if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
@@ -1120,16 +2191,71 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1120
2191
  }
1121
2192
  }
1122
2193
 
1123
- const session = scopedSessionStatus(alias, effectiveNetId);
1124
- pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
2194
+ const session = scopedSessionStatus(replyTargetAlias, effectiveNetId);
2195
+ pushEvent(replyTargetAlias, { type: "new_reply", inbox_count: pendingInboxCount(replyTargetAlias, effectiveNetId), from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
2196
+ // #461 network observer summary — ids + routing only, no reply text.
2197
+ pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to ?? null, message_id: id, from: from_session, to: replyTargetAlias, status: replyStatus });
2198
+
2199
+ // #498 compatibility tripwire. Legacy send_reply is intentionally kept
2200
+ // for old agents and Dashboard callers during rollout, but it is not the
2201
+ // capability-negotiated atomic peer primitive. Warn only for an actual
2202
+ // agent target; Dashboard hub/api replies remain quiet. New agents call
2203
+ // send_peer_reply and never see this warning.
2204
+ const targetIsAgent = replyTargetNodeId !== null
2205
+ && replyTargetAlias !== "hub"
2206
+ && replyTargetAlias !== "api";
2207
+ const warning = !peerCapabilityRequired && targetIsAgent
2208
+ ? `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.)`
2209
+ : undefined;
2210
+
2211
+ // #507 — echo attachments READ BACK FROM DB (lead 2b5f6634): the point
2212
+ // of the echo is to prove attachments actually landed in storage, not
2213
+ // to reflect the in-memory variable we tried to write. Reading
2214
+ // `attachmentsResult.attachments` here would still show `ok:true` +
2215
+ // full echo if the UPDATE failed with `changes=0` (e.g. task moved to
2216
+ // terminal between the pre-check and the transaction). SELECT from
2217
+ // inbox.meta_json (always written, uses the id we just generated) so
2218
+ // the echo means "these attachments are on disk now". Absent field
2219
+ // when the caller sent no attachments — reverse-(e) invariant: a
2220
+ // no-attachment call returns the exact same response shape as before
2221
+ // this PR. Persisted-attachments field is only added when the caller
2222
+ // actually asked for attachments.
2223
+ let attachmentsSaved: unknown[] | null = null;
2224
+ if (attachmentsResult.attachments.length > 0) {
2225
+ const persistedRow = db.get<{ meta_json: string | null }>(
2226
+ "SELECT meta_json FROM inbox WHERE id = ?1", [id]
2227
+ );
2228
+ const persistedMeta = parseMetaJson(persistedRow?.meta_json ?? null);
2229
+ attachmentsSaved = persistedMeta && typeof persistedMeta === "object" && Array.isArray((persistedMeta as any).attachments)
2230
+ ? (persistedMeta as any).attachments
2231
+ : [];
2232
+ }
1125
2233
 
1126
2234
  return {
1127
2235
  content: [{
1128
2236
  type: "text" as const,
1129
- text: JSON.stringify({ ok: true, message_id: id, session_status: session?.status ?? "unknown" }),
2237
+ text: JSON.stringify({
2238
+ ok: true,
2239
+ message_id: id,
2240
+ session_status: session?.status ?? "unknown",
2241
+ ...(warning ? { warning } : {}),
2242
+ ...(attachmentsSaved !== null ? { attachments_saved: attachmentsSaved } : {}),
2243
+ }),
1130
2244
  }],
1131
2245
  };
1132
- }
2246
+ };
2247
+
2248
+ server.tool(
2249
+ "send_reply",
2250
+ "Reply to a Dashboard/UI-originated task. Also serves as the terminal legacy fallback when atomic peer reply capability is unavailable.",
2251
+ replyToolSchema,
2252
+ (args) => handleReply(args, false),
2253
+ );
2254
+ server.tool(
2255
+ "send_peer_reply",
2256
+ "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.",
2257
+ replyToolSchema,
2258
+ (args) => handleReply(args, true),
1133
2259
  );
1134
2260
 
1135
2261
  // ── V2: send_ack (不入 inbox,仅更新状态) ──
@@ -1139,9 +2265,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1139
2265
  {
1140
2266
  task_id: z.string().min(1).max(200).describe("Task ID to acknowledge"),
1141
2267
  from_session: z.string().max(200).optional(),
2268
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1142
2269
  },
1143
- async ({ task_id, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1144
- const effectiveNetId = getNetworkId(null);
2270
+ async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2271
+ const effectiveNetId = getNetworkId(netId);
1145
2272
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1146
2273
  console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
1147
2274
  const updateParams: any[] = [task_id];
@@ -1165,9 +2292,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1165
2292
  {
1166
2293
  task_id: z.string().min(1).max(200).describe("Task ID to retry"),
1167
2294
  from_session: z.string().max(200).optional(),
2295
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1168
2296
  },
1169
- async ({ task_id, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1170
- const effectiveNetId = getNetworkId(null);
2297
+ async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2298
+ const effectiveNetId = getNetworkId(netId);
1171
2299
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1172
2300
  console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
1173
2301
  // Find the original task
@@ -1181,6 +2309,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1181
2309
  if (!["failed", "expired", "cancelled"].includes(task.status)) {
1182
2310
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task status is ${task.status}, not retryable` }) }] };
1183
2311
  }
2312
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 4/6).
2313
+ {
2314
+ const lc = assertNodeActive(task.to_name, effectiveNetId ?? task.network_id ?? null);
2315
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
2316
+ }
1184
2317
  db.transaction(() => {
1185
2318
  // Reset task status
1186
2319
  const updateParams: any[] = [task_id];
@@ -1188,17 +2321,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1188
2321
  WHERE task_id = ?1`;
1189
2322
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1190
2323
  db.run(updateSql, updateParams);
2324
+ syncScheduledRunForTask(task_id, effectiveNetId ?? task.network_id ?? null);
1191
2325
  // Re-queue in inbox with new ID (original ID may already exist)
1192
2326
  const retryInboxId = uuidv4();
1193
2327
  db.run(
1194
- `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id)
1195
- VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7)`,
1196
- [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]
2328
+ `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id)
2329
+ VALUES (?1, ?2, ?3, ?4, 'task', ?5, ?6, ?7, 'reply', ?8)`,
2330
+ [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]
1197
2331
  );
1198
2332
  });
1199
2333
  logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
1200
2334
  // SSE push (unconditional — channel is keyed by alias, not network)
1201
- pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session }, effectiveNetId ?? task.network_id ?? null);
2335
+ 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);
2336
+ pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: task.to_name, status: "delivered", priority: task.priority });
1202
2337
  return {
1203
2338
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
1204
2339
  };
@@ -1217,7 +2352,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1217
2352
  if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
1218
2353
  const params: any[] = [task_id];
1219
2354
  let sql = "SELECT * FROM tasks WHERE task_id = ?1";
1220
- sql = addReadScope(sql, params, readScope);
2355
+ sql = addOwnTrafficScope(sql, params, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
1221
2356
  const task = db.get<any>(sql, ...params);
1222
2357
  return {
1223
2358
  content: [{
@@ -1238,33 +2373,88 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1238
2373
  from_name: z.string().max(200).optional().describe("Filter by sender"),
1239
2374
  from_node_id: z.string().max(200).optional().describe("Filter by immutable sender node_id"),
1240
2375
  network_id: z.string().max(200).optional().describe("Filter by network"),
2376
+ before_created_at: z.string().max(64).optional().describe("Pagination cursor timestamp"),
2377
+ before_task_id: z.string().max(200).optional().describe("Pagination cursor task id"),
2378
+ durable_cursor: z.boolean().optional().describe("Require immutable node-scoped cursor protocol"),
2379
+ durable_terminal_cursor: z.boolean().optional().describe("Read terminal outbound journal in monotonic order"),
2380
+ after_terminal_seq: z.number().int().min(0).optional().describe("Exclusive terminal journal watermark"),
1241
2381
  limit: z.number().min(1).max(100).optional().default(20),
1242
2382
  },
1243
- async ({ alias, status, from_name, from_node_id, network_id: netId, limit }) => {
2383
+ 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 }) => {
1244
2384
  const readScope = resolveReadScope(netId);
1245
2385
  if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
1246
- 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";
2386
+ if (durable_cursor) {
2387
+ if (!callerTokenIsNetwork) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_required" }) }] };
2388
+ const boundNodeId = callerTokenId && enforceNetworkId
2389
+ ? db.get<{ bound_node_id: string | null }>(
2390
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
2391
+ callerTokenId,
2392
+ enforceNetworkId,
2393
+ )?.bound_node_id ?? null
2394
+ : null;
2395
+ if (!boundNodeId || from_node_id !== boundNodeId) {
2396
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "from_node_id_identity_mismatch" }) }] };
2397
+ }
2398
+ }
2399
+ if (durable_terminal_cursor) {
2400
+ if (!durable_cursor) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "durable_cursor_required" }) }] };
2401
+ const params: any[] = [enforceNetworkId, from_node_id, after_terminal_seq ?? 0, limit];
2402
+ const tasks = db.all(
2403
+ `SELECT t.task_id, t.from_node_id, t.from_name, t.to_node_id, t.to_name, t.priority,
2404
+ t.status, t.content, t.result, t.created_at, t.runtime_submitted_at,
2405
+ t.consumed_at, t.thread_id, t.turn_id, t.completed_at, e.terminal_seq
2406
+ FROM task_terminal_events e
2407
+ JOIN tasks t ON t.task_id = e.task_id
2408
+ WHERE COALESCE(e.network_id, 'default') = COALESCE(?1, 'default')
2409
+ AND e.from_node_id = ?2 AND e.terminal_seq > ?3
2410
+ ORDER BY e.terminal_seq ASC LIMIT ?4`,
2411
+ ...params,
2412
+ );
2413
+ return { content: [{ type: "text" as const, text: JSON.stringify({
2414
+ ok: true,
2415
+ capability: "list_tasks.immutable-terminal-sequence.v2",
2416
+ tasks,
2417
+ count: tasks.length,
2418
+ next_terminal_seq: tasks.length ? tasks[tasks.length - 1]?.terminal_seq : (after_terminal_seq ?? 0),
2419
+ has_more: tasks.length === limit,
2420
+ }) }] };
2421
+ }
2422
+ 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";
1247
2423
  const params: any[] = [];
1248
- sql = addReadScope(sql, params, readScope);
2424
+ sql = addOwnTrafficScope(sql, params, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
1249
2425
  if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
1250
2426
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
1251
2427
  if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
1252
2428
  if (from_node_id) { sql += ` AND from_node_id = ?${params.length + 1}`; params.push(from_node_id); }
1253
- sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
2429
+ if (before_created_at && before_task_id) {
2430
+ sql += ` AND (created_at < ?${params.length + 1} OR (created_at = ?${params.length + 1} AND task_id < ?${params.length + 2}))`;
2431
+ params.push(before_created_at, before_task_id);
2432
+ }
2433
+ sql += ` ORDER BY created_at DESC, task_id DESC LIMIT ?${params.length + 1}`;
1254
2434
  params.push(limit);
1255
2435
  const tasks = db.all(sql, ...params);
1256
2436
 
1257
2437
  // Stats
1258
2438
  const statsParams: any[] = [];
1259
2439
  let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
1260
- statsSql = addReadScope(statsSql, statsParams, readScope);
2440
+ statsSql = addOwnTrafficScope(statsSql, statsParams, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
1261
2441
  statsSql += " GROUP BY status";
1262
2442
  const stats = db.all(statsSql, ...statsParams);
1263
2443
 
1264
2444
  return {
1265
2445
  content: [{
1266
2446
  type: "text" as const,
1267
- text: JSON.stringify({ ok: true, tasks, count: tasks.length, stats }),
2447
+ text: JSON.stringify({
2448
+ ok: true,
2449
+ ...(durable_cursor ? { capability: "list_tasks.immutable-node-cursor.v1" } : {}),
2450
+ tasks,
2451
+ count: tasks.length,
2452
+ next_cursor: tasks.length === limit ? {
2453
+ before_created_at: tasks[tasks.length - 1]?.created_at,
2454
+ before_task_id: tasks[tasks.length - 1]?.task_id,
2455
+ } : null,
2456
+ stats,
2457
+ }),
1268
2458
  }],
1269
2459
  };
1270
2460
  }
@@ -1278,24 +2468,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1278
2468
  task_id: z.string().min(1).max(200).describe("Task ID to cancel"),
1279
2469
  reason: z.string().max(1000).optional().describe("Cancellation reason"),
1280
2470
  from_session: z.string().max(200).optional(),
2471
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1281
2472
  },
1282
- async ({ task_id, reason, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1283
- const effectiveNetId = getNetworkId(null);
2473
+ async ({ task_id, reason, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2474
+ const effectiveNetId = getNetworkId(netId);
1284
2475
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1285
2476
  console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
1286
2477
  const updateParams: any[] = [reason || "cancelled by " + from_session, task_id];
1287
2478
  let updateSql = `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
1288
2479
  WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`;
1289
2480
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1290
- const result = db.run(updateSql, updateParams);
1291
- // Also ack the inbox entry to prevent agent from picking it up
1292
- if (result.changes > 0) {
1293
- const inboxParams: any[] = [task_id];
1294
- let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
1295
- inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
1296
- db.run(inboxSql, inboxParams);
1297
- logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
1298
- }
2481
+ const result = db.transaction(() => {
2482
+ const updated = db.run(updateSql, updateParams);
2483
+ // Also ack the inbox entry to prevent agent from picking it up.
2484
+ if (updated.changes > 0) {
2485
+ const inboxParams: any[] = [task_id];
2486
+ let inboxSql = "UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0";
2487
+ inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
2488
+ db.run(inboxSql, inboxParams);
2489
+ syncScheduledRunForTask(task_id, effectiveNetId);
2490
+ }
2491
+ return updated;
2492
+ });
2493
+ if (result.changes > 0) logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
1299
2494
  return {
1300
2495
  content: [{ type: "text" as const, text: JSON.stringify({ ok: result.changes > 0, task_id, cancelled: result.changes > 0 }) }],
1301
2496
  };
@@ -1310,9 +2505,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1310
2505
  task_id: z.string().min(1).max(200).describe("Task ID to reassign"),
1311
2506
  new_alias: z.string().min(1).max(200).describe("Target agent alias"),
1312
2507
  from_session: z.string().max(200).optional(),
2508
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1313
2509
  },
1314
- async ({ task_id, new_alias, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1315
- const effectiveNetId = getNetworkId(null);
2510
+ async ({ task_id, new_alias, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2511
+ const effectiveNetId = getNetworkId(netId);
1316
2512
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1317
2513
  console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
1318
2514
  const taskParams: any[] = [task_id];
@@ -1328,10 +2524,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1328
2524
  const reassignedAlias = canonical.alias;
1329
2525
  const target = resolveDeliveryTarget(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
1330
2526
  const newNodeId = target.state === "not_found" ? null : (target.session?.node_id ?? null);
2527
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 5/6).
2528
+ {
2529
+ const lc = assertNodeActive(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
2530
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
2531
+ }
1331
2532
  db.transaction(() => {
1332
2533
  // Ack old inbox to prevent original agent from picking it up
1333
2534
  const inboxParams: any[] = [task_id];
1334
- let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
2535
+ let inboxSql = "UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0";
1335
2536
  inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
1336
2537
  db.run(inboxSql, inboxParams);
1337
2538
 
@@ -1341,15 +2542,179 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1341
2542
  db.run(updateSql, updateParams);
1342
2543
 
1343
2544
  const newInboxId = uuidv4();
1344
- 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)",
1345
- [newInboxId, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
2545
+ 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)",
2546
+ [newInboxId, task_id, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
1346
2547
  });
1347
2548
  logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${reassignedAlias}`);
1348
- 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);
2549
+ 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);
2550
+ pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: reassignedAlias, status: "delivered", priority: task.priority });
1349
2551
  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 } : {}) }) }] };
1350
2552
  }
1351
2553
  );
1352
2554
 
2555
+ server.tool(
2556
+ "send_desktop_message",
2557
+ "Push a message to a user's active Desktop/web clients in one network. Targets user identity, not node alias.",
2558
+ {
2559
+ to_user_id: z.string().min(1).max(200).optional().describe("Target user_id. Mutually checked with to_username when both are supplied."),
2560
+ to_username: z.string().min(1).max(50).optional().describe("Target username. Mutually checked with to_user_id when both are supplied."),
2561
+ title: z.string().max(200).optional(),
2562
+ message: z.string().min(1).max(10000),
2563
+ severity: z.enum(["info", "success", "warning", "error"]).optional().default("info"),
2564
+ kind: z.string().min(1).max(80).optional().default("agent_message"),
2565
+ meta: z.record(z.string(), z.unknown()).optional(),
2566
+ from_session: z.string().max(200).optional(),
2567
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens; ntok stays bound to its network)."),
2568
+ },
2569
+ async ({ to_user_id, to_username, title, message, severity, kind, meta, from_session: _fromIn, network_id: netId }) => {
2570
+ const fromMismatch = fromIdentityMismatchReply(_fromIn);
2571
+ if (fromMismatch) return fromMismatch;
2572
+ const from_session = defaultFrom(_fromIn);
2573
+ const effectiveNetId = getNetworkId(netId);
2574
+ // 人类 ↔ 人类私信:受限成员也能发(canWriteHuman 不看 Agent 授权),但发件人固定为自己的用户名。
2575
+ if (!canWriteHuman(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
2576
+ if (effectiveNetId && restrictedNets.includes(effectiveNetId) && _fromIn?.trim() && _fromIn.trim() !== callerAlias) {
2577
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "from_session_identity_mismatch", message: "restricted members always send as their own username" }) }] };
2578
+ }
2579
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
2580
+
2581
+ if (!to_user_id && !to_username) {
2582
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_required", message: "to_user_id or to_username is required" }) }] };
2583
+ }
2584
+
2585
+ const byId = to_user_id
2586
+ ? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE user_id = ?1", to_user_id)
2587
+ : null;
2588
+ const byName = to_username
2589
+ ? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE username = ?1", to_username)
2590
+ : null;
2591
+
2592
+ if (to_user_id && !byId) {
2593
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_user_id" }) }] };
2594
+ }
2595
+ if (to_username && !byName) {
2596
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_username" }) }] };
2597
+ }
2598
+ if (byId && byName && byId.user_id !== byName.user_id) {
2599
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_mismatch", to_user_id, to_username }) }] };
2600
+ }
2601
+
2602
+ const target = byId ?? byName;
2603
+ if (!target) {
2604
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found" }) }] };
2605
+ }
2606
+
2607
+ const targetRole = getUserNetworkRole(target.user_id, effectiveNetId);
2608
+ if (!targetRole) {
2609
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_in_network", network_id: effectiveNetId }) }] };
2610
+ }
2611
+
2612
+ const messageId = `dm_${uuidv4()}`;
2613
+ const event = {
2614
+ type: "desktop_message",
2615
+ message_id: messageId,
2616
+ kind,
2617
+ from: from_session,
2618
+ title: title || null,
2619
+ message,
2620
+ severity,
2621
+ created_at: new Date().toISOString(),
2622
+ ...(meta && Object.keys(meta).length ? { meta } : {}),
2623
+ };
2624
+
2625
+ // #1459 ① P2 —— 审计与收件持久化必须同生共死:只落其中一半,就会出现
2626
+ // 「审计说发过、而用户永远收不到」或者反过来。放进同一个事务。
2627
+ db.transaction(() => {
2628
+ db.run(
2629
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
2630
+ VALUES (?1, ?2, 'send_desktop_message', 'user', ?3, ?4, ?5)`,
2631
+ [
2632
+ enforceUserId || null,
2633
+ callerAlias || null,
2634
+ target.user_id,
2635
+ JSON.stringify({
2636
+ message_id: messageId,
2637
+ to_username: target.username,
2638
+ from: from_session,
2639
+ severity,
2640
+ kind,
2641
+ title: title || null,
2642
+ meta_keys: meta && typeof meta === "object" ? Object.keys(meta) : [],
2643
+ }),
2644
+ effectiveNetId,
2645
+ ],
2646
+ );
2647
+ // 🔴 `ON CONFLICT(message_id) DO NOTHING`,而不是 `INSERT OR IGNORE`:
2648
+ // 两者对"重投同一个 message_id"效果相同(幂等,不产生重复消息),
2649
+ // 但 OR IGNORE 会**连同其它约束错误一起吞掉** —— 我在实现本段时就
2650
+ // 撞上了:`kind` 为 NULL 触发 NOT NULL,OR IGNORE 静默丢弃整行,
2651
+ // 而工具照样返回 `persisted: true`。**一条消息凭空消失且报告成功**,
2652
+ // 正是本 issue 要消灭的那个形状。
2653
+ // DO NOTHING 只针对主键冲突,别的约束照常抛出、事务回滚。
2654
+ //
2655
+ // 另:普通 INSERT 会让重试抛主键冲突(无害重试变 500);
2656
+ // OR REPLACE 会覆盖旧行、把用户已 ack 的状态冲掉。都不取。
2657
+ db.run(
2658
+ `INSERT INTO user_inbox
2659
+ (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, sender_user_id)
2660
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
2661
+ ON CONFLICT(message_id) DO NOTHING`,
2662
+ [
2663
+ messageId,
2664
+ effectiveNetId ?? null,
2665
+ target.user_id,
2666
+ from_session,
2667
+ // 🔴 不依赖 zod 的 .default():直接调用 handler 的调用方(测试、
2668
+ // 未来的内部调用)拿不到 zod 默认值,会把 NULL 写进 NOT NULL 列。
2669
+ // 列上虽有 DEFAULT,但**显式传 NULL 会覆盖列默认值**。
2670
+ kind ?? "agent_message",
2671
+ title || null,
2672
+ message,
2673
+ severity ?? "info",
2674
+ // 存储侧走与 inbox 一致的跨主机脱敏;读取侧另有一道 token 形状
2675
+ // 脱敏(规格 ③ redact-at-read),两层分别防不同的东西。
2676
+ meta && typeof meta === "object" ? normalizeMetaJson(meta) : null,
2677
+ // 多用户私信:只有人(用户令牌)发的才记发信人 user_id;节点令牌发的是 Agent 消息。
2678
+ !callerTokenIsNetwork && enforceUserId ? enforceUserId : null,
2679
+ ],
2680
+ );
2681
+ });
2682
+ // #1459 / #1563 — pushUserEvent 在没有订阅者时静默 return。
2683
+ // 🔴 这段注释原先写「本工具既不写 inbox、也没有 user 级回读路径 ⇒ dashboard
2684
+ // 关着时消息永久没了」。**那半句已经不成立** —— 上面 20 行就在
2685
+ // `INSERT INTO user_inbox`,且 `/api/messages?scope=user` 能回读
2686
+ // (desktop-message-seam.test.ts 有真实 send → 真实 GET 的端到端断言)。
2687
+ // 留着它会把 debug 未读数的人直接带向错误的一层。
2688
+ //
2689
+ // 这里先把**丢失变可见**:`ok` 仍表示「请求被接受、审计已落库」,
2690
+ // 新增 `delivered` 表示「此刻真的有人收到」。判据取自订阅者注册表,
2691
+ // 不是猜的。持久化 + 重连补投是另一条(按 user_id 寻址的新收件表,#1459)。
2692
+ //
2693
+ // 🔴 `reason` 报的是**观测到的事实**(此刻没有活订阅者),不是它的后果。
2694
+ // 今天「没有活订阅者」确实等于「丢了」,但持久化落地之后同一个事实会
2695
+ // 变成「已入库、等重连补投」。把值取成 no_live_subscriber 而不是
2696
+ // "lost"/"dropped",就是为了那天不用改写历史含义 —— 届时按需细分
2697
+ // (如 lost vs queued)即可,现在不预先把语义焊死。
2698
+ // 🔴 push 必须在事务**提交之后**:放进事务里的话,活订阅者可能先收到
2699
+ // 事件、而行还没提交;一旦回滚,用户就看到了一条数据库里不存在的消息。
2700
+ const delivered = hasUserSubscribers(effectiveNetId, target.user_id);
2701
+ pushUserEvent(effectiveNetId, target.user_id, event);
2702
+
2703
+ return { content: [{ type: "text" as const, text: JSON.stringify({
2704
+ ok: true,
2705
+ message_id: messageId,
2706
+ delivered,
2707
+ // #1459 ① P2 —— 有了持久化之后,「此刻没人在听」不再等于「丢了」。
2708
+ // `delivered` 说的是实时投递、`persisted` 说的是它已经入库等重连补投。
2709
+ // `reason` 仍报**观测到的事实**而不是它的后果,所以这个中间态没有说错话。
2710
+ persisted: true,
2711
+ ...(delivered ? {} : { reason: "no_live_subscriber" }),
2712
+ delivered_to_user_id: target.user_id,
2713
+ network_id: effectiveNetId,
2714
+ }) }] };
2715
+ },
2716
+ );
2717
+
1353
2718
  server.tool(
1354
2719
  "broadcast",
1355
2720
  "Send a message to multiple sessions.",
@@ -1371,26 +2736,39 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1371
2736
 
1372
2737
  const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
1373
2738
  const ids: string[] = [];
2739
+ // #1440 ① — the push loop must walk the recipients that actually
2740
+ // got an inbox row, not every candidate target. Walking `targets`
2741
+ // announced a broadcast to nodes the lifecycle guard had just
2742
+ // skipped, i.e. nodes with nothing to fetch.
2743
+ const delivered: Array<{ alias: string; netId: string | null }> = [];
1374
2744
 
1375
2745
  for (const t of targets) {
2746
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 6/6).
2747
+ // Broadcast skips non-active recipients silently rather than
2748
+ // failing the entire send — broadcast semantics are best-effort
2749
+ // per-recipient and a stopped node simply gets nothing.
2750
+ const netId = effectiveNetId ?? t.network_id ?? null;
2751
+ const lc = assertNodeActive(t.alias, netId);
2752
+ if (!lc.ok) continue;
1376
2753
  const id = uuidv4();
1377
2754
  db.run(
1378
2755
  `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
1379
2756
  VALUES (?1, ?2, ?3, 'broadcast', 'normal', ?4, 'hub', ?5)`,
1380
- [id, t.alias, t.node_id ?? null, message, effectiveNetId ?? t.network_id ?? null]
2757
+ [id, t.alias, t.node_id ?? null, message, netId]
1381
2758
  );
1382
2759
  ids.push(id);
2760
+ delivered.push({ alias: t.alias, netId });
1383
2761
  }
1384
2762
 
1385
- for (const t of targets) {
1386
- pushEvent(t.alias, { type: "broadcast", inbox_count: 1 }, effectiveNetId ?? t.network_id ?? null);
2763
+ for (const d of delivered) {
2764
+ pushEvent(d.alias, { type: "broadcast", inbox_count: pendingInboxCount(d.alias, d.netId) }, d.netId);
1387
2765
  }
1388
2766
 
1389
2767
  return {
1390
2768
  content: [
1391
2769
  {
1392
2770
  type: "text" as const,
1393
- text: JSON.stringify({ ok: true, recipients: targets.length, message_ids: ids }),
2771
+ text: JSON.stringify({ ok: true, recipients: ids.length, message_ids: ids }),
1394
2772
  },
1395
2773
  ],
1396
2774
  };
@@ -1483,15 +2861,34 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1483
2861
  "update_node_config",
1484
2862
  "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.",
1485
2863
  {
1486
- node_id: z.string().min(1).max(200).describe("Target node ID (not alias). Must be in caller's network."),
2864
+ ...NODE_ID_ALIAS_FIELDS,
1487
2865
  base_revision: z.number().int().min(0).describe("Current revision per the dashboard's last GET — 409 if hub's current revision differs."),
1488
2866
  patch: z.object({
1489
2867
  model: z.string().max(200).optional(),
1490
- flags: z.record(z.unknown()).optional(),
2868
+ flags: z.record(z.string(), z.unknown()).optional(),
2869
+ // #260 P5 — channel enable/disable. Restart-tier field (agent-node
2870
+ // reads config.channels once at boot to fork per-channel workers,
2871
+ // so the swap takes effect via process restart). Not
2872
+ // SECURITY_SENSITIVE — this is a lifecycle op, gated by SEC-1
2873
+ // (network scope) only.
2874
+ //
2875
+ // Deliberately `z.array(z.unknown()).max(16)` (mirrors flags's
2876
+ // `z.record(z.string(), z.unknown())`): trust nothing at the wire boundary,
2877
+ // narrow the same way `narrowChannelsPatch` narrows raw untrusted
2878
+ // JSON (typeof + allowlist + dedup + case-fold). A strict
2879
+ // `z.array(z.string())` would fail-fast on a single non-string
2880
+ // entry, but the wire contract wants junk silently dropped so a
2881
+ // dashboard fat-finger doesn't turn into a 400 the user has to
2882
+ // interpret. validatePatch then re-rejects if the caller bypassed
2883
+ // narrowing.
2884
+ channels: z.array(z.unknown()).max(16).optional(),
1491
2885
  }).describe("Fields to update. Empty patch → no-op (use restart_node for that)."),
1492
2886
  network_id: z.string().max(200).optional(),
1493
2887
  },
1494
- async ({ node_id: nodeId, base_revision: baseRev, patch, network_id: clientNetId }) => {
2888
+ async ({ node_id, child_node_id, base_revision: baseRev, patch, network_id: clientNetId }) => {
2889
+ const idArg = resolveNodeIdArg({ node_id, child_node_id });
2890
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
2891
+ const nodeId = idArg.node_id;
1495
2892
  const effectiveNetId = getNetworkId(clientNetId);
1496
2893
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
1497
2894
 
@@ -1505,6 +2902,50 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1505
2902
 
1506
2903
  const model = typeof patch.model === "string" ? patch.model : undefined;
1507
2904
  const flags = (patch.flags && typeof patch.flags === "object") ? patch.flags as Record<string, unknown> : {};
2905
+ // Narrow untrusted `channels` at the boundary (typeof + allowlist +
2906
+ // dedup + case-fold), and distinguish two very different cases
2907
+ // that both narrow to `[]`:
2908
+ // (a) `patch.channels === []` (explicit "disable all editable
2909
+ // channels"). Downstream must proceed and write channels=[]
2910
+ // so the node's next restart forks no workers.
2911
+ // (b) `patch.channels === ["commhub"]` or similar — the caller
2912
+ // sent items but every single one was invalid (dashboard PR
2913
+ // #31 still ships commhub, and a typo like "telegarm" would
2914
+ // hit the same path). Downstream MUST NOT treat this as
2915
+ // (a) — silently converting to disable-all would nuke the
2916
+ // user's existing telegram/feishu workers.
2917
+ //
2918
+ // The reject error is `channels_all_invalid` so the dashboard
2919
+ // can surface the mismatch instead of showing a false success.
2920
+ let channels: string[] | undefined = undefined;
2921
+ if (patch.channels !== undefined) {
2922
+ if (!Array.isArray(patch.channels)) {
2923
+ // Zod already permits arrays only; belt+braces if it drifts.
2924
+ return {
2925
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_patch", field: "channels", reason: "must be an array" }) }],
2926
+ };
2927
+ }
2928
+ const narrowed = narrowChannelsPatch(patch.channels) ?? [];
2929
+ if (patch.channels.length === 0) {
2930
+ channels = []; // (a) explicit disable-all
2931
+ } else if (narrowed.length === 0) {
2932
+ // (b) every entry was invalid — refuse to write. Distinct
2933
+ // error so the dashboard can surface the failure.
2934
+ return {
2935
+ content: [{
2936
+ type: "text" as const,
2937
+ text: JSON.stringify({
2938
+ ok: false,
2939
+ error: "channels_all_invalid",
2940
+ requested: patch.channels,
2941
+ message: "every requested channel is unknown or unsupported; use an explicit empty array to disable-all",
2942
+ }),
2943
+ }],
2944
+ };
2945
+ } else {
2946
+ channels = narrowed;
2947
+ }
2948
+ }
1508
2949
 
1509
2950
  // SEC-2 (final policy 2026-06-28) — security-sensitive flags
1510
2951
  // (permissionMode / dangerouslySkipPermissions / teammateMode)
@@ -1531,7 +2972,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1531
2972
  };
1532
2973
  }
1533
2974
 
1534
- const validationFail = validatePatch(model, flags);
2975
+ const validationFail = validatePatch(model, flags, channels);
1535
2976
  if (validationFail) {
1536
2977
  return {
1537
2978
  content: [{
@@ -1609,8 +3050,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1609
3050
 
1610
3051
  // Compute apply_mode + persist + push doorbell.
1611
3052
  const updateId = `cu_${uuidv4()}`;
1612
- const applyMode = computeApplyMode(model, flags);
1613
- const patchJson = JSON.stringify({ ...(model !== undefined ? { model } : {}), flags });
3053
+ const applyMode = computeApplyMode(model, flags, channels);
3054
+ const patchJson = JSON.stringify({
3055
+ ...(model !== undefined ? { model } : {}),
3056
+ flags,
3057
+ ...(channels !== undefined ? { channels } : {}),
3058
+ });
1614
3059
  const networkId = node.network_id || "default";
1615
3060
  db.run(
1616
3061
  `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)`,
@@ -1628,6 +3073,51 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1628
3073
  },
1629
3074
  );
1630
3075
 
3076
+ // One alias can own several `nodes` rows in a network: re-creating a node
3077
+ // under the same alias leaves the old row behind, and `db.get` on
3078
+ // `WHERE alias = ? AND network_id = ?` then returns whichever row SQLite
3079
+ // happens to yield first. A field report showed the result: the client
3080
+ // queued a rules-file read for the live node, the node pulled with its own
3081
+ // token, the hub resolved its alias to the stale row, found nothing pending
3082
+ // and returned `request: null` until the read timed out.
3083
+ //
3084
+ // resolveNodeByAlias: prefer the row the alias's session points at (sessions
3085
+ // are UNIQUE per network+alias and carry the node_id that last reported),
3086
+ // then the newest row. Used where only an alias is known (a client targeting
3087
+ // by alias; unbound legacy node tokens).
3088
+ const resolveNodeByAlias = (alias: string, networkId: string) =>
3089
+ db.get<{ node_id: string; alias: string; network_id: string | null }>(
3090
+ `SELECT n.node_id, n.alias, n.network_id FROM nodes n
3091
+ LEFT JOIN sessions s ON s.alias = n.alias AND s.network_id = n.network_id AND s.node_id = n.node_id
3092
+ WHERE n.alias = ?1 AND n.network_id = ?2
3093
+ ORDER BY (s.node_id IS NOT NULL) DESC, n.created_at DESC, n.rowid DESC
3094
+ LIMIT 1`,
3095
+ alias,
3096
+ networkId,
3097
+ ) ?? null;
3098
+ // resolveCallerNode: the node a network token pulls/acks for. A token bound
3099
+ // to a node resolves to exactly that node (and never to another row that
3100
+ // shares its alias); only unbound legacy tokens fall back to the alias.
3101
+ // Callers have already required callerTokenIsNetwork + enforceNetworkId +
3102
+ // callerAlias.
3103
+ const resolveCallerNode = (): { node_id: string; network_id: string | null } | null => {
3104
+ const bound = callerTokenId
3105
+ ? db.get<{ bound_node_id: string | null }>(
3106
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
3107
+ callerTokenId,
3108
+ enforceNetworkId,
3109
+ )?.bound_node_id ?? null
3110
+ : null;
3111
+ if (bound) {
3112
+ return db.get<{ node_id: string; network_id: string | null }>(
3113
+ "SELECT node_id, network_id FROM nodes WHERE node_id = ?1 AND network_id = ?2",
3114
+ bound,
3115
+ enforceNetworkId,
3116
+ ) ?? null;
3117
+ }
3118
+ return resolveNodeByAlias(callerAlias!, enforceNetworkId!);
3119
+ };
3120
+
1631
3121
  server.tool(
1632
3122
  "get_config_update",
1633
3123
  "Node pulls its pending config update (called from agent-node when SSE config_update doorbell arrives). RFC-024.",
@@ -1651,11 +3141,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1651
3141
  // Unconditional network_id filter (was previously conditional on
1652
3142
  // enforceNetworkId being set; the new ntok guard above guarantees
1653
3143
  // it's non-null so the filter is always applied).
1654
- const node = db.get<any>(
1655
- "SELECT node_id, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
1656
- callerAlias,
1657
- enforceNetworkId,
1658
- );
3144
+ const node = resolveCallerNode();
1659
3145
  if (!node) {
1660
3146
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update: null }) }] };
1661
3147
  }
@@ -1708,11 +3194,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1708
3194
  // Cross-tenant guard: the ack-er must own the update being acked.
1709
3195
  // Resolve node by caller's alias under the enforced network.
1710
3196
  // Network filter is unconditional (guard above guarantees non-null).
1711
- const node = db.get<any>(
1712
- "SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
1713
- callerAlias,
1714
- enforceNetworkId,
1715
- );
3197
+ const node = resolveCallerNode();
1716
3198
  if (!node) {
1717
3199
  // Silently ignore stale ack — return ok so the node doesn't retry forever.
1718
3200
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "alias_unknown" }) }] };
@@ -1748,14 +3230,455 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1748
3230
  },
1749
3231
  );
1750
3232
 
3233
+ // ── app#225 — 节点规则文件(AGENTS.md / CLAUDE.md)远程读写 ──
3234
+ //
3235
+ // 形状照抄 RFC-024 config-apply:客户端调 read_node_rules_file /
3236
+ // write_node_rules_file 落一行 node_rules_requests + 门铃 {type:"rules_file"};
3237
+ // 节点收到门铃后 get_rules_file_request 拉(和 get_config_update 同一道
3238
+ // F-A 闸:网络 token + alias),做完 ack_rules_file_request 回报;客户端
3239
+ // 轮询 get_rules_file_result。
3240
+ //
3241
+ // 🔴 安全(#225 验收第 5 条):整条链路**没有路径参数**。文件名由节点按
3242
+ // 自己的 RUNTIME 决定(claude → CLAUDE.md,其余 → AGENTS.md),目录固定是
3243
+ // 节点进程的 cwd(agent-node/src/runtime/rules-file.ts)。hub 只传 op +
3244
+ // content,客户端连文件名都指定不了,所以不存在「借路径参数写任意文件」。
3245
+ // 读和写都要 canWrite:规则文件是节点行为的一部分,读它等于读节点配置。
3246
+ const RULES_FILE_MAX_BYTES = 256 * 1024;
3247
+ const RULES_REQUEST_STALE_MS = 60_000;
3248
+
3249
+ // Node skills view — skills_list / skill_read ride the same queue + doorbell.
3250
+ // `content` carries the skill NAME for skill_read (never a path; the node
3251
+ // validates it again against [A-Za-z0-9._-] and resolves it under its own
3252
+ // runtime's skills roots — node-skills.ts).
3253
+ const SKILL_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/;
3254
+ const isSkillsOp = (op: string) => op === "skills_list" || op === "skill_read";
3255
+
3256
+ // Project folder view — files_list / file_read ride the same queue + doorbell.
3257
+ // `content` carries a path RELATIVE to the node's work dir. The hub refuses
3258
+ // absolute paths, `..` segments, backslashes and NUL before any row exists;
3259
+ // the node re-validates, realpath-contains the result in its work dir and
3260
+ // refuses secret-looking files (node-files.ts). Stricter than skills on the
3261
+ // caller: a node token may not browse another node's disk, and only the token
3262
+ // that asked can read the answer.
3263
+ const NODE_FILE_PATH_MAX = 1024;
3264
+ const NODE_FILES_RESULT_MAX_CHARS = 1024 * 1024;
3265
+ const isFilesOp = (op: string) => op === "files_list" || op === "file_read";
3266
+ const normalizeNodeRelPath = (raw: unknown): string | null => {
3267
+ if (raw === undefined || raw === null) return "";
3268
+ if (typeof raw !== "string" || raw.length > NODE_FILE_PATH_MAX) return null;
3269
+ if (raw.includes("\0") || raw.includes("\\")) return null;
3270
+ if (raw.startsWith("/") || raw.startsWith("~") || /^[A-Za-z]:/.test(raw)) return null;
3271
+ const segs = raw.split("/").filter((s) => s !== "" && s !== ".");
3272
+ if (segs.some((s) => s === "..")) return null;
3273
+ return segs.join("/");
3274
+ };
3275
+
3276
+ // Node run-log view — logs_tail rides the same queue + doorbell. `content`
3277
+ // carries the filter parameters as JSON (lines / level / grep / since_ts) and
3278
+ // never a path: the node reads only its own log directory and redacts before
3279
+ // it answers (agent-node/src/runtime/node-logs.ts). Stricter than the project
3280
+ // folder on the caller: user login only, and only the node's owner or a
3281
+ // network owner/admin — a member who may chat with a node does not get to read
3282
+ // its process log. Log bytes are purged on the first terminal read (see
3283
+ // get_rules_file_result) and by the retention sweep after LOGS_CONTENT_TTL_MS.
3284
+ const isLogsOp = (op: string) => op === "logs_tail";
3285
+ const LOGS_MAX_LINES = 2000;
3286
+ const LOGS_GREP_MAX = 200;
3287
+ const logsParamsJson = (p: { lines?: number; level?: string; grep?: string; since_ts?: number }): string =>
3288
+ JSON.stringify({
3289
+ lines: Math.min(Math.max(Math.floor(p.lines ?? 500), 1), LOGS_MAX_LINES),
3290
+ ...(p.level ? { level: p.level } : {}),
3291
+ ...(p.grep ? { grep: p.grep.slice(0, LOGS_GREP_MAX) } : {}),
3292
+ ...(typeof p.since_ts === "number" && p.since_ts > 0 ? { since_ts: p.since_ts } : {}),
3293
+ });
3294
+ // Owner of the node row, or owner/admin of the network. Legacy open mode (no
3295
+ // user identity on the connection) keeps canWrite's allow-all semantics.
3296
+ const canReadNodeLogs = (nodeId: string, networkId: string): boolean => {
3297
+ if (!enforceUserId) return true;
3298
+ const role = getUserNetworkRole(enforceUserId, networkId);
3299
+ if (role === "owner" || role === "admin") return true;
3300
+ if (nodeId.startsWith("session:")) return false;
3301
+ const row = db.get<{ owner_user_id: string | null }>("SELECT owner_user_id FROM nodes WHERE node_id = ?1", nodeId);
3302
+ return !!row?.owner_user_id && row.owner_user_id === enforceUserId;
3303
+ };
3304
+
3305
+ const enqueueRulesFileRequest = (
3306
+ op: "read" | "write" | "skills_list" | "skill_read" | "files_list" | "file_read" | "logs_tail",
3307
+ a: { node_id?: string; child_node_id?: string; alias?: string; network_id?: string; content?: string },
3308
+ ) => {
3309
+ const effectiveNetId = getNetworkId(a.network_id);
3310
+ if (isLogsOp(op) && callerTokenIsNetwork) {
3311
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_cannot_read_logs", message: "a node token cannot read another node's run log; use a user login" }) }] };
3312
+ }
3313
+ if (isFilesOp(op)) {
3314
+ if (callerTokenIsNetwork) {
3315
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_cannot_browse_files", message: "a node token cannot browse another node's project folder; use a user login" }) }] };
3316
+ }
3317
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, `${op} node files`);
3318
+ const rel = normalizeNodeRelPath(a.content);
3319
+ if (rel === null || (op === "file_read" && rel === "")) {
3320
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_path", reason: "path must be relative to the node work dir: no leading / or ~, no .. segment, no backslash or NUL, at most 1024 chars" }) }] };
3321
+ }
3322
+ a = { ...a, content: rel };
3323
+ }
3324
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, isLogsOp(op) ? "read node logs" : isSkillsOp(op) ? `${op} node skills` : `${op} rules file`);
3325
+ if (op === "skill_read" && (typeof a.content !== "string" || !SKILL_NAME_RE.test(a.content) || a.content === "." || a.content === "..")) {
3326
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_skill_name", reason: "name must match [A-Za-z0-9._-]{1,64} and not be . or .." }) }] };
3327
+ }
3328
+
3329
+ // Target: a `nodes` row by node_id (original path), or — app#225 follow-up —
3330
+ // an alias. An alias resolves to its `nodes` row when there is one; otherwise
3331
+ // to a live session in the caller's network that advertised
3332
+ // rules_file_capable (claude-code sessions mostly have no `nodes` row). The
3333
+ // queue key for such a session is `session:<alias>`, network-scoped.
3334
+ let node: { node_id: string; alias: string; network_id: string | null } | null = null;
3335
+ if (a.node_id || a.child_node_id || !a.alias) {
3336
+ const idArg = resolveNodeIdArg({ node_id: a.node_id, child_node_id: a.child_node_id });
3337
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
3338
+ const nodeId = idArg.node_id;
3339
+ const resolved = resolveTargetNode(nodeId, effectiveNetId);
3340
+ if (!resolved.row) {
3341
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: nodeId }) }] };
3342
+ }
3343
+ if (!resolved.sec1Ok) {
3344
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node", message: "node belongs to another network" }) }] };
3345
+ }
3346
+ node = resolved.row as any;
3347
+ } else {
3348
+ const scopeNet = effectiveNetId || "default";
3349
+ const byAlias = resolveNodeByAlias(a.alias, scopeNet);
3350
+ if (byAlias) {
3351
+ node = byAlias;
3352
+ } else {
3353
+ const session = db.get<{ alias: string; network_id: string | null }>(
3354
+ isLogsOp(op)
3355
+ ? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND logs_capable = 1 ORDER BY updated_at DESC LIMIT 1"
3356
+ : isFilesOp(op)
3357
+ ? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND files_capable = 1 ORDER BY updated_at DESC LIMIT 1"
3358
+ : isSkillsOp(op)
3359
+ ? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND skills_capable = 1 ORDER BY updated_at DESC LIMIT 1"
3360
+ : "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND rules_file_capable = 1 ORDER BY updated_at DESC LIMIT 1",
3361
+ a.alias,
3362
+ scopeNet,
3363
+ );
3364
+ if (!session) {
3365
+ if (isLogsOp(op)) {
3366
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "logs_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve its run log (its agent-node may be too old)" }) }] };
3367
+ }
3368
+ if (isFilesOp(op)) {
3369
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "files_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve its project folder (its channel server may be too old)" }) }] };
3370
+ }
3371
+ return isSkillsOp(op)
3372
+ ? { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "skills_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve its skills (its channel server may be too old)" }) }] }
3373
+ : { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "rules_file_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve rules files (its channel server may be too old)" }) }] };
3374
+ }
3375
+ node = { node_id: `session:${session.alias}`, alias: session.alias, network_id: session.network_id };
3376
+ }
3377
+ }
3378
+ const nodeId = node.node_id;
3379
+ if (isLogsOp(op) && !canReadNodeLogs(nodeId, node.network_id || "default")) {
3380
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "logs_permission_denied", message: "only the node's owner or a network owner/admin can read its run log" }) }] };
3381
+ }
3382
+ if (op === "write") {
3383
+ if (typeof a.content !== "string") {
3384
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_content", reason: "content must be a string" }) }] };
3385
+ }
3386
+ const bytes = Buffer.byteLength(a.content, "utf8");
3387
+ if (bytes > RULES_FILE_MAX_BYTES) {
3388
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "content_too_large", bytes, max_bytes: RULES_FILE_MAX_BYTES }) }] };
3389
+ }
3390
+ }
3391
+
3392
+ // 单飞 + 陈旧回收,同 update_node_config 的 F-B:节点掉线时旧行永远
3393
+ // 非终态,不回收就把这个节点锁死。
3394
+ // Separate single-flight lanes: the client opens the rules file and the
3395
+ // skills list together; one must not block the other.
3396
+ const inFlight = db.get<{ request_id: string; created_at: number; pulled_at: number | null }>(
3397
+ isLogsOp(op)
3398
+ ? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op = 'logs_tail' ORDER BY created_at DESC LIMIT 1"
3399
+ : isFilesOp(op)
3400
+ ? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('files_list', 'file_read') ORDER BY created_at DESC LIMIT 1"
3401
+ : isSkillsOp(op)
3402
+ ? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('skills_list', 'skill_read') ORDER BY created_at DESC LIMIT 1"
3403
+ : "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('read', 'write') ORDER BY created_at DESC LIMIT 1",
3404
+ nodeId,
3405
+ );
3406
+ if (inFlight) {
3407
+ const age = Date.now() - (inFlight.pulled_at ?? inFlight.created_at);
3408
+ if (age <= RULES_REQUEST_STALE_MS) {
3409
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_in_flight", existing_request_id: inFlight.request_id, age_ms: age }) }] };
3410
+ }
3411
+ db.run(
3412
+ "UPDATE node_rules_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3",
3413
+ [Date.now(), `superseded after ${age}ms (> ${RULES_REQUEST_STALE_MS}ms) — node did not answer`, inFlight.request_id],
3414
+ );
3415
+ }
3416
+
3417
+ // Opportunistic content retention (bounded, indexed) — node-request-retention.ts.
3418
+ sweepNodeRequestContent();
3419
+ const requestId = `rf_${uuidv4()}`;
3420
+ const networkId = node.network_id || "default";
3421
+ db.run(
3422
+ `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)`,
3423
+ [requestId, nodeId, networkId, op, op === "write" || op === "skill_read" || isFilesOp(op) || isLogsOp(op) ? a.content! : null, Date.now(), callerTokenId || "unknown"],
3424
+ );
3425
+ pushEvent(node.alias, { type: "rules_file", request_id: requestId }, networkId);
3426
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, op }) }] };
3427
+ };
3428
+
3429
+ server.tool(
3430
+ "read_node_rules_file",
3431
+ "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.",
3432
+ {
3433
+ ...NODE_ID_ALIAS_FIELDS,
3434
+ alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported rules_file_capable (claude-code sessions)."),
3435
+ network_id: z.string().max(200).optional(),
3436
+ },
3437
+ async ({ node_id, child_node_id, alias, network_id }) => enqueueRulesFileRequest("read", { node_id, child_node_id, alias, network_id }),
3438
+ );
3439
+
3440
+ server.tool(
3441
+ "write_node_rules_file",
3442
+ "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.",
3443
+ {
3444
+ ...NODE_ID_ALIAS_FIELDS,
3445
+ alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported rules_file_capable (claude-code sessions)."),
3446
+ content: z.string().max(RULES_FILE_MAX_BYTES).describe("Full new file content (UTF-8). The node writes it atomically."),
3447
+ network_id: z.string().max(200).optional(),
3448
+ },
3449
+ async ({ node_id, child_node_id, alias, content, network_id }) => enqueueRulesFileRequest("write", { node_id, child_node_id, alias, content, network_id }),
3450
+ );
3451
+
3452
+ server.tool(
3453
+ "list_node_skills",
3454
+ "Ask a node to list the skills its runtime loads (name, scope project|user|system, display path, frontmatter description). Read-only; no path argument. Poll get_rules_file_result — content is JSON {skills:[…]}.",
3455
+ {
3456
+ ...NODE_ID_ALIAS_FIELDS,
3457
+ alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported skills_capable."),
3458
+ network_id: z.string().max(200).optional(),
3459
+ },
3460
+ async ({ node_id, child_node_id, alias, network_id }) => enqueueRulesFileRequest("skills_list", { node_id, child_node_id, alias, network_id }),
3461
+ );
3462
+
3463
+ server.tool(
3464
+ "read_node_skill",
3465
+ "Ask a node for one skill's SKILL.md by name. Read-only; the node resolves the name under its own runtime's skills roots — no path argument. Poll get_rules_file_result — content is JSON {name, scope, path_rel, description, content}.",
3466
+ {
3467
+ ...NODE_ID_ALIAS_FIELDS,
3468
+ alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported skills_capable."),
3469
+ name: z.string().min(1).max(64).describe("Skill directory name, [A-Za-z0-9._-]."),
3470
+ network_id: z.string().max(200).optional(),
3471
+ },
3472
+ async ({ node_id, child_node_id, alias, name, network_id }) => enqueueRulesFileRequest("skill_read", { node_id, child_node_id, alias, content: name, network_id }),
3473
+ );
3474
+
3475
+ server.tool(
3476
+ "list_node_files",
3477
+ "Ask a node to list one directory level of its work dir (project folder view, read-only). `path` is relative to the work dir (default: the root); absolute paths and .. are refused. Poll get_rules_file_result — content is JSON {path, entries:[{name,type,size?,mtime?,hidden_reason?,no_descend?}], truncated, total}. User logins only.",
3478
+ {
3479
+ ...NODE_ID_ALIAS_FIELDS,
3480
+ alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported files_capable."),
3481
+ path: z.string().max(NODE_FILE_PATH_MAX).optional().describe("Directory relative to the node's work dir; omit or \"\" for the root."),
3482
+ network_id: z.string().max(200).optional(),
3483
+ },
3484
+ async ({ node_id, child_node_id, alias, path: relPath, network_id }) => enqueueRulesFileRequest("files_list", { node_id, child_node_id, alias, content: relPath ?? "", network_id }),
3485
+ );
3486
+
3487
+ server.tool(
3488
+ "read_node_file",
3489
+ "Ask a node for one text file under its work dir (project folder view, read-only, 256 KiB cap; binary / too large → size only; secret-looking files → name only). `path` is relative to the work dir. Poll get_rules_file_result — content is JSON {path, name, kind, size?, mtime?, content?, hidden_reason?}. User logins only.",
3490
+ {
3491
+ ...NODE_ID_ALIAS_FIELDS,
3492
+ alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported files_capable."),
3493
+ path: z.string().min(1).max(NODE_FILE_PATH_MAX).describe("File path relative to the node's work dir."),
3494
+ network_id: z.string().max(200).optional(),
3495
+ },
3496
+ async ({ node_id, child_node_id, alias, path: relPath, network_id }) => enqueueRulesFileRequest("file_read", { node_id, child_node_id, alias, content: relPath, network_id }),
3497
+ );
3498
+
3499
+ server.tool(
3500
+ "tail_node_logs",
3501
+ "Ask a node for the tail of its own agent-node run log (read-only, redacted on the node before it leaves). No path argument: the node reads only its own log directory. User logins only; the node's owner or a network owner/admin. Poll get_rules_file_result — content is JSON {files, lines:[{ts, level, text, key}], truncated, matched, now_ts}; it is handed out once and then purged.",
3502
+ {
3503
+ ...NODE_ID_ALIAS_FIELDS,
3504
+ alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported logs_capable."),
3505
+ lines: z.number().int().min(1).max(LOGS_MAX_LINES).optional().describe("How many of the newest matching lines (default 500, max 2000)."),
3506
+ level: z.enum(["info", "warn", "error"]).optional().describe("Only lines of exactly this level."),
3507
+ grep: z.string().min(1).max(LOGS_GREP_MAX).optional().describe("Case-insensitive substring, matched after redaction."),
3508
+ since_ts: z.number().int().min(0).optional().describe("Only lines at or after this epoch-ms timestamp (follow mode; the client de-duplicates by line key)."),
3509
+ network_id: z.string().max(200).optional(),
3510
+ },
3511
+ async ({ node_id, child_node_id, alias, lines, level, grep, since_ts, network_id }) =>
3512
+ enqueueRulesFileRequest("logs_tail", { node_id, child_node_id, alias, content: logsParamsJson({ lines, level, grep, since_ts }), network_id }),
3513
+ );
3514
+
3515
+ server.tool(
3516
+ "get_rules_file_request",
3517
+ "Node pulls its oldest pending rules-file request (called from agent-node when the SSE rules_file doorbell arrives). app#225.",
3518
+ {},
3519
+ async () => {
3520
+ // 同 get_config_update 的 F-A 闸:必须是网络 token + alias。
3521
+ if (!callerTokenIsNetwork || !enforceNetworkId) {
3522
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
3523
+ }
3524
+ if (!callerAlias) {
3525
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
3526
+ }
3527
+ const node = resolveCallerNode();
3528
+ // app#225 follow-up — no `nodes` row: this is a session-only target
3529
+ // (claude-code). Its queue key is `session:<alias>`, always paired with the
3530
+ // caller's network so a same-named alias in another network never matches.
3531
+ const queueKey: string = node ? node.node_id : `session:${callerAlias}`;
3532
+ const req = db.get<any>(
3533
+ "SELECT request_id, op, content FROM node_rules_requests WHERE node_id = ?1 AND network_id = ?2 AND status = 'pending' ORDER BY created_at ASC LIMIT 1",
3534
+ queueKey,
3535
+ enforceNetworkId,
3536
+ );
3537
+ if (!req) {
3538
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request: null }) }] };
3539
+ }
3540
+ db.run(
3541
+ "UPDATE node_rules_requests SET status = 'in_progress', pulled_at = ?1 WHERE request_id = ?2 AND status = 'pending'",
3542
+ [Date.now(), req.request_id],
3543
+ );
3544
+ return {
3545
+ content: [{
3546
+ type: "text" as const,
3547
+ text: JSON.stringify({
3548
+ ok: true,
3549
+ request: {
3550
+ request_id: req.request_id,
3551
+ op: req.op,
3552
+ ...(req.op === "write" || req.op === "skill_read" || isFilesOp(req.op) || isLogsOp(req.op) ? { content: req.content ?? "" } : {}),
3553
+ },
3554
+ }),
3555
+ }],
3556
+ };
3557
+ },
3558
+ );
3559
+
3560
+ server.tool(
3561
+ "ack_rules_file_request",
3562
+ "Node reports the outcome of a rules-file request — done (with content for reads) or failed (with error). app#225.",
3563
+ {
3564
+ request_id: z.string().min(1).max(200),
3565
+ status: z.enum(["done", "failed"]),
3566
+ file_name: z.string().max(64).optional(),
3567
+ exists: z.boolean().optional(),
3568
+ // Files results (JSON listing / a 256 KiB file, escaped) may exceed the rules
3569
+ // cap; the per-op cap is enforced below once the row's op is known.
3570
+ content: z.string().max(NODE_FILES_RESULT_MAX_CHARS).optional(),
3571
+ error: z.string().max(2000).optional(),
3572
+ },
3573
+ async ({ request_id: requestId, status, file_name: fileName, exists, content, error: ackError }) => {
3574
+ if (!callerTokenIsNetwork || !enforceNetworkId) {
3575
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
3576
+ }
3577
+ if (!callerAlias) {
3578
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
3579
+ }
3580
+ const node = resolveCallerNode();
3581
+ const queueKey: string = node ? node.node_id : `session:${callerAlias}`;
3582
+ const req = db.get<any>(
3583
+ "SELECT request_id, node_id, network_id, status, op FROM node_rules_requests WHERE request_id = ?1",
3584
+ requestId,
3585
+ );
3586
+ // 跨租户闸:只能 ack 自己的请求;别人的当不存在处理。session 键另外核网络。
3587
+ if (!req || req.node_id !== queueKey || req.network_id !== enforceNetworkId) {
3588
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "unknown_or_foreign_request" }) }] };
3589
+ }
3590
+ if (req.status === "done" || req.status === "failed" || req.status === "timeout") {
3591
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "already_terminal", current_status: req.status }) }] };
3592
+ }
3593
+ if (typeof content === "string" && !isFilesOp(req.op) && !isLogsOp(req.op) && content.length > RULES_FILE_MAX_BYTES) {
3594
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "content_too_large", max: RULES_FILE_MAX_BYTES }) }] };
3595
+ }
3596
+ db.run(
3597
+ `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`,
3598
+ [
3599
+ status,
3600
+ Date.now(),
3601
+ fileName ?? null,
3602
+ typeof exists === "boolean" ? (exists ? 1 : 0) : null,
3603
+ status === "done" ? (content ?? null) : null,
3604
+ status === "failed" ? (ackError || "node reported failure without a reason") : null,
3605
+ requestId,
3606
+ ],
3607
+ );
3608
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, status }) }] };
3609
+ },
3610
+ );
3611
+
3612
+ server.tool(
3613
+ "get_rules_file_result",
3614
+ "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.",
3615
+ { request_id: z.string().min(1).max(200), network_id: z.string().max(200).optional() },
3616
+ async ({ request_id: requestId, network_id: clientNetId }) => {
3617
+ const effectiveNetId = getNetworkId(clientNetId);
3618
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "read rules file result");
3619
+ const row = db.get<any>(
3620
+ "SELECT request_id, node_id, network_id, op, status, file_name, file_exists, result_content, error, created_at, pulled_at, acked_at, created_by_token FROM node_rules_requests WHERE request_id = ?1",
3621
+ requestId,
3622
+ );
3623
+ // SEC-1:结果行的网络必须等于调用方作用域,否则当不存在。
3624
+ // Project folder results: only the (user) token that asked may read them.
3625
+ // Run-log results: same rule — only the token that asked.
3626
+ const foreignFilesRow = !!row && (isFilesOp(row.op) || isLogsOp(row.op)) && (callerTokenIsNetwork || row.created_by_token !== (callerTokenId || "unknown"));
3627
+ if (!row || row.network_id !== (effectiveNetId || "default") || foreignFilesRow) {
3628
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found", request_id: requestId }) }] };
3629
+ }
3630
+ let status: string = row.status;
3631
+ const ageMs = Date.now() - (row.pulled_at ?? row.created_at);
3632
+ if ((status === "pending" || status === "in_progress") && ageMs > RULES_REQUEST_STALE_MS) {
3633
+ status = "timeout";
3634
+ db.run(
3635
+ "UPDATE node_rules_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3 AND status IN ('pending', 'in_progress')",
3636
+ [Date.now(), `node did not answer within ${RULES_REQUEST_STALE_MS}ms (offline, or running an agent-node without app#225 support)`, requestId],
3637
+ );
3638
+ row.error = `node did not answer within ${RULES_REQUEST_STALE_MS}ms (offline, or running an agent-node without app#225 support)`;
3639
+ }
3640
+ // Privacy: node file bytes are handed out for a short grace window after the
3641
+ // first terminal read, then purged (node-request-retention.ts). Only reached
3642
+ // after the SEC-1 scope check above — a foreign caller can't stamp/purge.
3643
+ const terminal = status === "done" || status === "failed" || status === "timeout";
3644
+ const purged = terminal ? noteTerminalResultRead(requestId).purged : false;
3645
+ // Run-log bytes are read-once: purge them in the same call that hands them
3646
+ // out (no grace window — the app never follows someone else's logs request,
3647
+ // it re-asks; see node-request-retention.ts).
3648
+ if (terminal && isLogsOp(row.op) && !purged) purgeLogsResultNow(requestId);
3649
+ return {
3650
+ content: [{
3651
+ type: "text" as const,
3652
+ text: JSON.stringify({
3653
+ ok: true,
3654
+ request_id: row.request_id,
3655
+ node_id: row.node_id,
3656
+ op: row.op,
3657
+ status,
3658
+ file_name: row.file_name ?? null,
3659
+ exists: row.file_exists === null || row.file_exists === undefined ? null : row.file_exists === 1,
3660
+ ...(status === "done" && row.op !== "write" && !purged ? { content: row.result_content ?? "" } : {}),
3661
+ ...(purged ? { content_purged: true } : {}),
3662
+ error: row.error ?? null,
3663
+ age_ms: ageMs,
3664
+ }),
3665
+ }],
3666
+ };
3667
+ },
3668
+ );
3669
+
3670
+
1751
3671
  server.tool(
1752
3672
  "restart_node",
1753
3673
  "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).",
1754
3674
  {
1755
- node_id: z.string().min(1).max(200),
3675
+ ...NODE_ID_ALIAS_FIELDS,
1756
3676
  network_id: z.string().max(200).optional(),
1757
3677
  },
1758
- async ({ node_id: nodeId, network_id: clientNetId }) => {
3678
+ async ({ node_id, child_node_id, network_id: clientNetId }) => {
3679
+ const idArg = resolveNodeIdArg({ node_id, child_node_id });
3680
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
3681
+ const nodeId = idArg.node_id;
1759
3682
  const effectiveNetId = getNetworkId(clientNetId);
1760
3683
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
1761
3684
 
@@ -1787,10 +3710,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1787
3710
  }
1788
3711
  const updateId = `cu_${uuidv4()}`;
1789
3712
  const networkId = node.network_id || "default";
1790
- db.run(
1791
- `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)`,
1792
- [updateId, nodeId, networkId, node.config_revision || 0, Date.now(), callerTokenId || "unknown"],
1793
- );
3713
+ // RFC-027 PR1.2a latent fix (#346 ack): restart_node must reset
3714
+ // lifecycle_state to 'active'. Otherwise a node that was previously
3715
+ // stop_node'd → 'stopped' would, after restart, stay marked
3716
+ // 'stopped' in the nodes table → the 6 MCP + 2 REST inbox guards
3717
+ // would refuse every routing attempt → node silently unreachable
3718
+ // (no error to operator, just no traffic). Pair the schema flip
3719
+ // with the config_updates INSERT inside one tx so we don't
3720
+ // half-commit if the dispatch INSERT throws.
3721
+ db.transaction(() => {
3722
+ db.run(
3723
+ `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)`,
3724
+ [updateId, nodeId, networkId, node.config_revision || 0, Date.now(), callerTokenId || "unknown"],
3725
+ );
3726
+ db.run(
3727
+ `UPDATE nodes SET lifecycle_state = 'active' WHERE node_id = ?1`,
3728
+ [nodeId],
3729
+ );
3730
+ });
1794
3731
  pushEvent(node.alias, { type: "restart", update_id: updateId }, networkId);
1795
3732
  return {
1796
3733
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update_id: updateId, apply_mode: "restart_only" }) }],
@@ -1895,7 +3832,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1895
3832
  // schema-promoted columns runtimes_supported/allowed_secret_keys
1896
3833
  // are pre-extracted but role isn't a first-class column).
1897
3834
  const nowMs = Date.now();
1898
- const ONLINE_MS = 60_000;
3835
+ // 心跳周期是 3 分钟(agent-node/src/cli.ts 的 report_status 定时器),
3836
+ // 窗口必须大于它,否则每次心跳后的 60s~180s 之间必然抖成 offline
3837
+ // (通信评审牛 #1279 独审④)。取 5min,与仓内既有 stale 口径一致。
3838
+ const ONLINE_MS = 5 * 60_000;
1899
3839
  const daemons = rows
1900
3840
  .map(r => {
1901
3841
  let snapRole: string | null = null;
@@ -1914,7 +3854,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1914
3854
  let lastSeenAt: string | null = null;
1915
3855
  if (r.session_last_seen) {
1916
3856
  lastSeenAt = r.session_last_seen;
1917
- const t = Date.parse(r.session_last_seen);
3857
+ const t = parseDbTimestampMs(r.session_last_seen);
1918
3858
  if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
1919
3859
  }
1920
3860
  // Parse self-declare arrays (default to [] for pre-PR2 daemons)
@@ -1941,6 +3881,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1941
3881
  telemetry.mem_gb = r.session_mem_total_gb ?? null;
1942
3882
  telemetry.ip_internal = r.session_ip ?? null;
1943
3883
  }
3884
+ // 工作目录默认根会暴露那台机器的家目录路径 —— 与 IP 同级,只给 admin/owner
3885
+ // (也只有他们能 create_node)。见 create-node-validate.ts daemonDefaultWorkdirRoot。
3886
+ const workdirRoot = isPrivileged ? daemonDefaultWorkdirRoot(r.config_snapshot) : null;
1944
3887
  return {
1945
3888
  daemon_node_id: r.node_id,
1946
3889
  alias: r.alias,
@@ -1950,6 +3893,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1950
3893
  runtimes_supported: runtimes,
1951
3894
  allowed_secret_keys: secrets,
1952
3895
  host_telemetry: telemetry,
3896
+ ...(workdirRoot ? { default_workdir_root: workdirRoot } : {}),
1953
3897
  };
1954
3898
  });
1955
3899
 
@@ -1968,10 +3912,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1968
3912
  node_spec: z.object({
1969
3913
  name: z.string().min(1).max(64),
1970
3914
  runtime: z.string().min(1).max(64),
1971
- model: z.string().min(1).max(100),
1972
- flags: z.record(z.unknown()).optional(),
3915
+ model: z.string().min(1).max(100).optional().nullable(),
3916
+ flags: z.record(z.string(), z.unknown()).optional(),
1973
3917
  env_refs: z.array(z.string().max(64)).optional(),
1974
3918
  channels: z.array(z.unknown()).optional(),
3919
+ // app「新建节点」确认页的工作目录(绝对路径或 ~/…,daemon 侧展开并校验)。
3920
+ // 缺席 = 老行为:落 daemon 的 cwd。
3921
+ workdir: z.string().max(1024).optional().nullable(),
1975
3922
  }),
1976
3923
  network_id: z.string().max(200).optional(),
1977
3924
  },
@@ -1998,23 +3945,116 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1998
3945
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
1999
3946
  }
2000
3947
 
2001
- // Read daemon's host_supervisor capability + allowlist from its
2002
- // last reported config_snapshot. P1 simplification: we accept
2003
- // (allowed_runtimes empty / allowed_secret_keys absent) as
2004
- // "accept any in the global enum"; P2 will tighten when daemon
2005
- // self-publishes its own allowlist via daemon_capabilities.
2006
- let daemonAllowList = new Set<string>();
2007
- let daemonAllowedRuntimes: string[] | null = null;
3948
+ // #1353 Fix ① — daemon self-declared "I can't create nodes right now"
3949
+ // is now a dispatch-time gate, not just a stored fact. Before this,
3950
+ // daemon reported `daemon_capabilities.can_create_nodes=false` (via
3951
+ // #1371 + #1377), hub stored it in config_snapshot, and then no
3952
+ // consumer read it — dispatch still returned `{ok:true, request_id}`
3953
+ // and pushed an SSE doorbell to the broken daemon. That's the exact
3954
+ // "在线但静默失败" case the issue title reports.
3955
+ //
3956
+ // Vincent's own 2026-08-28 comment on #1353 authorizes this shape:
3957
+ // "daemon 侧确知不可用时,hub 应当**拒绝派发**并返回可操作的错误,
3958
+ // 而不是发一个注定失败的 doorbell".
3959
+ //
3960
+ // 🔴 Pre-#1371 compat: a daemon that has NOT reported
3961
+ // `can_create_nodes` at all leaves the field UNDEFINED in the
3962
+ // snapshot. Do NOT fail-closed on undefined — that would silently
3963
+ // start rejecting every daemon on the old (preview.10 through .55
3964
+ // roughly) shape. Only `=== false` gates; `=== true` and `undefined`
3965
+ // both pass through (new-guard-matches-existing-stance).
2008
3966
  try {
2009
3967
  const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
2010
- if (snap?.daemon_capabilities?.allowed_secret_keys) {
2011
- daemonAllowList = new Set(snap.daemon_capabilities.allowed_secret_keys);
2012
- }
2013
- if (Array.isArray(snap?.daemon_capabilities?.allowed_runtimes)) {
2014
- daemonAllowedRuntimes = snap.daemon_capabilities.allowed_runtimes;
2015
- }
2016
- } catch { /* permissive P1 fallback */ }
2017
-
3968
+ const caps = snap?.daemon_capabilities;
3969
+ if (caps && caps.can_create_nodes === false) {
3970
+ const reason: string = (typeof caps.create_nodes_blocked_reason === "string"
3971
+ && caps.create_nodes_blocked_reason.length > 0)
3972
+ ? caps.create_nodes_blocked_reason
3973
+ : "anet_bin_unknown";
3974
+ // #1545 —— 光说 `blocked_reason` 不够:**那个判断是什么时候做出来的**
3975
+ // 决定了下一步完全不同的两件事。
3976
+ // 刚测的 blocked ⇒ 去那台机器按 reason 修
3977
+ // 三周前测的 blocked ⇒ 那台 daemon 是**开机算一次就永久缓存**的旧版本
3978
+ // (agent-node ≤ 2.5.0-preview.54),它可能早就好了 ——
3979
+ // 先重启/升级它,再谈修 pin
3980
+ // 而在拒绝这一刻,用户正好在决定"去修哪台机器"。
3981
+ //
3982
+ // 🔴 年龄未知时给的是**显式 null + 一个说明为什么的枚举**,不是省略这两个键。
3983
+ // 省略读起来像"没有这个问题",而调用方(常常是个 agent)会默认它是刚测的
3984
+ // —— 那是朝「更可信」方向撒谎,比不给更糟。
3985
+ const rawAge = caps.create_capability_observed_ms_ago;
3986
+ const reportedAgeOk = typeof rawAge === "number" && Number.isFinite(rawAge)
3987
+ && rawAge >= 0 && rawAge <= 365 * 24 * 60 * 60 * 1000;
3988
+ // 🔴 daemon 报的是「测完到**这份 report 发出**之间隔了多久」,不是「到现在」。
3989
+ // 两者差一个心跳间隔(3 分钟),而对一个已经失联的 daemon 差多少都可能。
3990
+ // 直接把它当成"多久以前测的"渲染,会**朝更新鲜的方向**低报 ——
3991
+ // 正是这一格要防的那个方向。所以在这里补上心跳那一段:
3992
+ // 绝对年龄 = (now − last_seen_at) + daemon 报的时长
3993
+ // hub 的钟出绝对时间,daemon 只提供时长(它自己的钟偏移污染不了这个数)。
3994
+ //
3995
+ // 这是冷路径(只有被拒时才走),多一次小查询不影响心跳路径。
3996
+ let heartbeatAgeMs: number | null = null;
3997
+ try {
3998
+ const sess = db.get<{ last_seen_at: string | null }>(
3999
+ "SELECT last_seen_at FROM sessions WHERE node_id = ?1 ORDER BY last_seen_at DESC LIMIT 1",
4000
+ daemon_node_id,
4001
+ );
4002
+ // #1650 — 这一列是 TEXT(`datetime('now')`),UTC 但不带时区标记。
4003
+ // Date.parse 会按**本机时区**解析,误差 = 主机偏移(生产 hub 在 UTC+8
4004
+ // 时这个诊断数字整整偏 8 小时)。走 parseHubTimestamp 显式按 UTC 解。
4005
+ const t = parseHubTimestamp(sess?.last_seen_at);
4006
+ if (t !== null) heartbeatAgeMs = Math.max(0, Date.now() - t);
4007
+ } catch { /* 取不到就是取不到 —— 下面如实说 unknown,不猜 0 */ }
4008
+
4009
+ // 🔴 三种取值,不能塌成两种:
4010
+ // known 两半都有,可以给绝对年龄
4011
+ // unknown_legacy_daemon 那台 daemon 根本没报这一格(旧版本)
4012
+ // unknown_no_heartbeat_time 它报了,但 hub 这边没有可用的心跳时间
4013
+ // ⇒ **补不出绝对年龄**,而不是"年龄是 0"
4014
+ const ageKnown = reportedAgeOk && heartbeatAgeMs !== null;
4015
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4016
+ ok: false,
4017
+ error: "daemon_cannot_create_nodes",
4018
+ blocked_reason: reason,
4019
+ // 到**现在**为止,这个判断做出来有多久了(毫秒)。null = 补不出来。
4020
+ capability_observed_ms_ago: ageKnown ? heartbeatAgeMs! + (rawAge as number) : null,
4021
+ capability_age: ageKnown
4022
+ ? "known"
4023
+ : (reportedAgeOk ? "unknown_no_heartbeat_time" : "unknown_legacy_daemon"),
4024
+ // 🔴 这里**不复制那张 code → 修法命令 的表**。它只有一个作者,在
4025
+ // `@sleep2agi/agent-network` 的 daemon-capability-display —— 抄一份到 hub,
4026
+ // 两份就会各自漂移,而「hub 教的修法」和「CLI 教的修法」不一致时,
4027
+ // 没有任何东西会红。这里只把人指到那一份。
4028
+ // (同 `blocked_reason` 是 z.enum 而不是自由文本的立场:hub 出**代码**,
4029
+ // 渲染留给客户端。)
4030
+ remediation_hint: "run `anet daemon list` where that daemon is configured — it prints the exact, paste-able fix command for this blocked_reason",
4031
+ daemon_node_id,
4032
+ }) }] };
4033
+ }
4034
+ } catch { /* malformed snapshot: permissive, mirrors L3230 fallthrough */ }
4035
+
4036
+ // Read daemon's host_supervisor capability + allowlist from its
4037
+ // last reported config_snapshot. PR3 (#338) canonical path is
4038
+ // `daemon_capabilities.runtimes_supported` (RFC-026 §9.3).
4039
+ // Pre-PR3 daemons (preview.10 and earlier) place these at the
4040
+ // TOP level of the snapshot rather than nested — those reads
4041
+ // return undefined here and `daemonAllowedRuntimes` stays null
4042
+ // (permissive — no allowlist enforcement); they fall back to
4043
+ // §4.2.2 structural validation only, identical to pre-PR3
4044
+ // behavior. No regression on in-flight daemons.
4045
+ let daemonAllowList = new Set<string>();
4046
+ let daemonAllowedRuntimes: string[] | null = null;
4047
+ try {
4048
+ const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
4049
+ const caps = snap?.daemon_capabilities;
4050
+ if (Array.isArray(caps?.allowed_secret_keys)) {
4051
+ daemonAllowList = new Set(caps.allowed_secret_keys);
4052
+ }
4053
+ if (Array.isArray(caps?.runtimes_supported)) {
4054
+ daemonAllowedRuntimes = caps.runtimes_supported;
4055
+ }
4056
+ } catch { /* permissive fallback */ }
4057
+
2018
4058
  // §4.2.2 — structural validation (catches name/runtime/model/
2019
4059
  // flag injection at the hub edge). Daemon repeats this; double
2020
4060
  // layer per RFC §4.2.2.
@@ -2023,6 +4063,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2023
4063
  validateRuntime(node_spec.runtime);
2024
4064
  validateModel(node_spec.model);
2025
4065
  validateChannelsP1((node_spec as any).channels);
4066
+ validateWorkdir(node_spec.workdir);
2026
4067
  for (const [k, v] of Object.entries(node_spec.flags || {})) {
2027
4068
  if (!(FLAG_KEYS as readonly string[]).includes(k)) throw new ValidationError("flag_key_unknown", { field: k });
2028
4069
  validateFlagValue(k, v);
@@ -2030,6 +4071,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2030
4071
  } catch (e) {
2031
4072
  return validationFailReply(e);
2032
4073
  }
4074
+ // 🔴 老 daemon 会**静默忽略** node_spec.workdir,把节点建在它自己的 cwd 里 ——
4075
+ // 用户以为指定了目录,实际落到了别处(常常就是 $HOME)。所以没自报支持的 daemon
4076
+ // 直接拒,而不是派一个会被悄悄改写的请求。app 在这种 daemon 上本就不显示那一行。
4077
+ const requestedWorkdir = typeof node_spec.workdir === "string" ? node_spec.workdir.trim() : null;
4078
+ if (requestedWorkdir && !daemonDefaultWorkdirRoot(daemon.config_snapshot)) {
4079
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4080
+ ok: false,
4081
+ error: "workdir_not_supported_by_daemon",
4082
+ hint: "this daemon's agent-node predates create-node workdir support; upgrade it, or omit workdir to use its current directory",
4083
+ daemon_node_id,
4084
+ }) }] };
4085
+ }
2033
4086
 
2034
4087
  // P1 daemon-side allowlist: if daemon publishes allowed_runtimes,
2035
4088
  // enforce at hub for fast-fail; daemon repeats.
@@ -2115,12 +4168,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2115
4168
  const envKeys = Object.keys(envBlob);
2116
4169
  db.run(
2117
4170
  `INSERT INTO node_create_requests
2118
- (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
2119
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
4171
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token, workdir)
4172
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11, ?12)`,
2120
4173
  [
2121
4174
  requestId, daemon_node_id, node_spec.name, networkIdForChild,
2122
- node_spec.runtime, node_spec.model, JSON.stringify(node_spec.flags || {}),
4175
+ node_spec.runtime, node_spec.model ?? null, JSON.stringify(node_spec.flags || {}),
2123
4176
  JSON.stringify(envKeys), childTokenId, Date.now(), callerTokenId || "unknown",
4177
+ requestedWorkdir,
2124
4178
  ],
2125
4179
  );
2126
4180
 
@@ -2129,9 +4183,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2129
4183
  // SSE handler resolves the rest via MCP call.
2130
4184
  pushEvent(daemon.alias, { type: "create_node", request_id: requestId }, networkIdForChild);
2131
4185
 
2132
- // §4.5 audit — dispatch succeeded
4186
+ // §4.5 audit — request queued and doorbell attempted. pushEvent is
4187
+ // fire-and-forget, so actual SSE delivery is intentionally not claimed.
2133
4188
  auditCreateNode({
2134
- action: "create_node_dispatched",
4189
+ action: "create_node_dispatch_attempted",
2135
4190
  user_id: enforceUserId,
2136
4191
  network_id: networkIdForChild,
2137
4192
  target_id: requestId,
@@ -2139,9 +4194,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2139
4194
  daemon_node_id,
2140
4195
  child_name: node_spec.name,
2141
4196
  runtime: node_spec.runtime,
2142
- model: node_spec.model,
4197
+ model: node_spec.model ?? null,
2143
4198
  flag_keys: Object.keys(node_spec.flags || {}),
2144
4199
  env_keys: envKeys,
4200
+ workdir: requestedWorkdir,
2145
4201
  },
2146
4202
  });
2147
4203
 
@@ -2151,6 +4207,78 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2151
4207
  },
2152
4208
  );
2153
4209
 
4210
+ // #1362 — daemon-side compensation for missed SSE doorbells. pushEvent is
4211
+ // intentionally best-effort; after a daemon connects or reconnects, it pulls
4212
+ // any still-pending requests bound to its own daemon token.
4213
+ server.tool(
4214
+ "list_my_pending_create_requests",
4215
+ "Daemon lists pending create-node requests bound to itself for SSE reconnect compensation. RFC-026/#1362.",
4216
+ {},
4217
+ async () => {
4218
+ const callerDaemon = resolveCallerDaemonTokenBound();
4219
+ if (!callerDaemon.ok) {
4220
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4221
+ }
4222
+ const rows = db.all<{ request_id: string; child_name: string; created_at: number }>(
4223
+ `SELECT request_id, child_name, created_at
4224
+ FROM node_create_requests
4225
+ WHERE daemon_node_id = ?1
4226
+ AND network_id = ?2
4227
+ AND status = 'pending'
4228
+ ORDER BY created_at ASC`,
4229
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
4230
+ );
4231
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: rows.length, requests: rows }) }] };
4232
+ },
4233
+ );
4234
+
4235
+ // #1448 — stop/delete/start 门铃的 SSE 重连补偿,镜像 create 的
4236
+ // list_my_pending_create_requests(#1394)。pushEvent(push.ts) 是纯内存
4237
+ // fan-out:派发门铃那刻若 daemon 无 live SSE 订阅者(hub 重启/网络抖/boot
4238
+ // 窗口)就静默丢弃、不入队,于是 node_stop_requests/node_start_requests 的行
4239
+ // 永远停在 pending、节点卡死 stopping/deleting/starting。daemon 每次 SSE
4240
+ // connected 调本工具把漏掉的门铃拉回来重放(reconcilePendingLifecycleRequestsOnConnect)。
4241
+ //
4242
+ // 统一一个工具返回两张表的 pending 行(stop 表用 action 区分 stop/delete),
4243
+ // 省一次往返。鉴权同 create:token-bound daemon 身份(alias 不是安全边界),
4244
+ // 只返回绑定到本 daemon + 本网络的 pending 行。
4245
+ server.tool(
4246
+ "list_my_pending_lifecycle_requests",
4247
+ "Daemon lists pending stop/delete/start requests bound to itself for SSE reconnect compensation. Mirrors list_my_pending_create_requests (#1394/#1448).",
4248
+ {},
4249
+ async () => {
4250
+ const callerDaemon = resolveCallerDaemonTokenBound();
4251
+ if (!callerDaemon.ok) {
4252
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4253
+ }
4254
+ const stop_requests = db.all<{ request_id: string; action: string; child_node_id: string; child_alias: string; created_at: number }>(
4255
+ `SELECT request_id, action, child_node_id, child_alias, created_at
4256
+ FROM node_stop_requests
4257
+ WHERE daemon_node_id = ?1
4258
+ AND network_id = ?2
4259
+ AND status = 'pending'
4260
+ ORDER BY created_at ASC`,
4261
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
4262
+ );
4263
+ const start_requests = db.all<{ request_id: string; child_node_id: string; child_alias: string; created_at: number }>(
4264
+ `SELECT request_id, child_node_id, child_alias, created_at
4265
+ FROM node_start_requests
4266
+ WHERE daemon_node_id = ?1
4267
+ AND network_id = ?2
4268
+ AND status = 'pending'
4269
+ ORDER BY created_at ASC`,
4270
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
4271
+ );
4272
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4273
+ ok: true,
4274
+ stop_count: stop_requests.length,
4275
+ start_count: start_requests.length,
4276
+ stop_requests,
4277
+ start_requests,
4278
+ }) }] };
4279
+ },
4280
+ );
4281
+
2154
4282
  // §2.5 step 3 — daemon-facing pull. Returns full spec + env_blob +
2155
4283
  // child_ntok in one shot. Map evicted on take (one-shot consume).
2156
4284
  //
@@ -2203,8 +4331,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2203
4331
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "env_blob_unavailable" }) }] };
2204
4332
  }
2205
4333
  // Hydrate spec from row.
2206
- const specRow = db.get<{ child_name: string; runtime: string; model: string; flags_json: string }>(
2207
- `SELECT child_name, runtime, model, flags_json FROM node_create_requests WHERE request_id = ?1`,
4334
+ const specRow = db.get<{ child_name: string; runtime: string; model: string | null; flags_json: string; workdir: string | null }>(
4335
+ `SELECT child_name, runtime, model, flags_json, workdir FROM node_create_requests WHERE request_id = ?1`,
2208
4336
  request_id,
2209
4337
  );
2210
4338
  if (!specRow) {
@@ -2227,6 +4355,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2227
4355
  model: specRow.model,
2228
4356
  flags: JSON.parse(specRow.flags_json),
2229
4357
  channels: [],
4358
+ // 只在请求真带了时出现:老 daemon 看不到多余的键,新 daemon 缺席即走老布局。
4359
+ ...(specRow.workdir ? { workdir: specRow.workdir } : {}),
2230
4360
  },
2231
4361
  child_token: blob.child_token,
2232
4362
  env_blob: blob.env_blob,
@@ -2243,14 +4373,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2243
4373
  // record fork-side info.
2244
4374
  server.tool(
2245
4375
  "ack_create_request",
2246
- "Daemon acks a create-node request (called after fork). status='started' or 'failed'. RFC-026.",
4376
+ "Daemon acks a create-node request (called after fork). status='started' | 'failed' | 'rejected' | 'runtime_capability_check_failed'. RFC-026 §9.3 D2.",
2247
4377
  {
2248
4378
  request_id: z.string().min(1).max(200),
2249
- status: z.enum(["started", "failed", "rejected"]),
4379
+ // RFC-026 §9.3 D2 — runtime_capability_check_failed signals the
4380
+ // daemon spawned the child OK but it died within FAIL_FAST_MS
4381
+ // (5s in agent-node v2.5.0-preview.11+), indicating a
4382
+ // declaration↔reality gap on this daemon's runtimes_supported.
4383
+ // Treated terminal like 'failed' but fires a distinct audit_log
4384
+ // action so dashboards can highlight "lying daemons" separately
4385
+ // from generic spawn failures.
4386
+ status: z.enum(["started", "failed", "rejected", "runtime_capability_check_failed"]),
2250
4387
  error: z.string().max(1000).optional(),
2251
4388
  child_pid: z.number().int().optional(),
4389
+ runtime: z.string().max(64).optional(), // populated by daemon when status=runtime_capability_check_failed
2252
4390
  },
2253
- async ({ request_id, status, error: ackError, child_pid: _pid }) => {
4391
+ async ({ request_id, status, error: ackError, child_pid: _pid, runtime: ackRuntime }) => {
2254
4392
  const callerDaemon = resolveCallerDaemonTokenBound();
2255
4393
  if (!callerDaemon.ok) {
2256
4394
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
@@ -2276,7 +4414,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2276
4414
  );
2277
4415
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "awaiting_register" }) }] };
2278
4416
  }
2279
- // failed / rejected — revoke child-ntok + mark request terminal
4417
+ // failed / rejected / runtime_capability_check_failed — revoke
4418
+ // child-ntok + mark request terminal.
2280
4419
  if (row.child_token_id) {
2281
4420
  db.run(`UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = ?1 AND revoked_at IS NULL`, [row.child_token_id]);
2282
4421
  }
@@ -2284,10 +4423,857 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2284
4423
  `UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3 WHERE request_id = ?4 AND status IN ('pending', 'delivered')`,
2285
4424
  [status, ackError || null, ackedAt, request_id],
2286
4425
  );
4426
+ // RFC-026 §9.3 D2 — surface declaration↔reality gap on a
4427
+ // distinct audit_log action so dashboards / operators can spot
4428
+ // chronically-lying daemons separate from generic spawn-failed.
4429
+ if (status === "runtime_capability_check_failed") {
4430
+ auditCreateNode({
4431
+ action: "daemon_capability_lied",
4432
+ user_id: null,
4433
+ network_id: row.network_id,
4434
+ target_id: request_id,
4435
+ detail: {
4436
+ daemon_node_id: row.daemon_node_id,
4437
+ runtime: ackRuntime || null,
4438
+ error: ackError ? ackError.slice(0, 500) : null,
4439
+ acked_at: ackedAt,
4440
+ },
4441
+ });
4442
+ }
2287
4443
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
2288
4444
  },
2289
4445
  );
2290
4446
 
4447
+ // ─── RFC-027 §2 — stop/delete node lifecycle ───────────────────────
4448
+ //
4449
+ // Two user-facing MCP tools (stop_node, delete_node) + two daemon-
4450
+ // facing tools (get_stop_request, ack_stop_request). State machine
4451
+ // per §2.3 — nodes.lifecycle_state ∈ {active, stopping, stopped,
4452
+ // deleting}. row-gone implies deleted. Security per §4:
4453
+ // §4.1 SEC-1: trust-root SQL join, not getNetworkId
4454
+ // §4.2 D6: delete_node refuses target.role==host_supervisor
4455
+ // §4.3 D4: in-flight inbox default-refuse + force+audit
4456
+ // §4.4 D7: daemon writes backup chmod 700, sweeper真删
4457
+ // §4.5 D8: audit_log in same SQLite tx as state UPDATE
4458
+ //
4459
+ // The dispatcher logic shared by stop_node + delete_node lives in
4460
+ // dispatchStopOrDelete; the two tool handlers thin-wrap it with
4461
+ // their respective action discriminator.
4462
+ type DispatchAction = "stop" | "delete";
4463
+ type DispatchArgs = {
4464
+ action: DispatchAction;
4465
+ child_node_id: string;
4466
+ daemon_node_id: string;
4467
+ force: boolean;
4468
+ grace_seconds: number;
4469
+ delete_config: boolean;
4470
+ confirm_alias?: string;
4471
+ };
4472
+ // RFC-027 PR2 prereq — auto-resolve daemon_node_id from child_node_id.
4473
+ // The dashboard (and most callers of stop_node / delete_node) shouldn't
4474
+ // need to track the daemon→child mapping themselves; the hub has it on
4475
+ // node_create_requests at child creation time. Returns null when no
4476
+ // creation record exists (orphan node row OR pre-RFC-026 node) — caller
4477
+ // must then pass daemon_node_id explicitly. node_id derivation:
4478
+ // `node_${request_id.replace(/^cr_/,"")}` (see create-node-daemon.ts
4479
+ // and PR1 BLOCKER-1 fix), so we reverse it: `cr_${node_id.slice(5)}`.
4480
+ const resolveDaemonForChild = (child_node_id: string): string | null => {
4481
+ if (!child_node_id.startsWith("node_")) return null;
4482
+ const requestId = `cr_${child_node_id.slice(5)}`;
4483
+ const row = db.get<{ daemon_node_id: string }>(
4484
+ `SELECT daemon_node_id FROM node_create_requests WHERE request_id = ?1`,
4485
+ requestId,
4486
+ );
4487
+ return row?.daemon_node_id ?? null;
4488
+ };
4489
+
4490
+ /**
4491
+ * 为什么这里要分两种「解析不到 daemon」(#196 / app 仓)。
4492
+ *
4493
+ * `resolveDaemonForChild` 对两件完全不同的事都返回 `null`:
4494
+ * ① id 不以 `node_` 开头 —— 这个节点**根本不是 daemon 创建的**,
4495
+ * 是有人在某台机器上直接 `anet node start` 起来的。hub 上没有任何
4496
+ * daemon 可以代它执行停止,**这条路径对它在概念上就不成立**。
4497
+ * ② id 以 `node_` 开头,但 `node_create_requests` 里查不到那一行 ——
4498
+ * 它可能确实是 daemon 建的,只是记录缺失,这时「显式传 daemon_node_id」
4499
+ * 是一条真的走得通的路。
4500
+ *
4501
+ * 🔴 原先两种情况打印同一句 `pass daemon_node_id explicitly`。对 ① 来说
4502
+ * **这句建议的前提是假的** —— 没有 daemon 可传,用户按它做只会走进死路。
4503
+ * 2026-08-28 对生产 hub 实测:218 个节点里 **207 个**是 ① 这种。
4504
+ *
4505
+ * 信息在源头就存在(那个 `startsWith("node_")` 判断),只是被丢在了返回值里。
4506
+ */
4507
+ const explainUnresolvableDaemon = (child_node_id: string) =>
4508
+ child_node_id.startsWith("node_")
4509
+ ? {
4510
+ ok: false, error: "daemon_not_resolvable",
4511
+ message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
4512
+ }
4513
+ : {
4514
+ ok: false, error: "not_daemon_managed",
4515
+ message:
4516
+ `node ${child_node_id} was not created by a daemon (it was started by hand with ` +
4517
+ `\`anet node start\` on some machine), so no daemon on the Hub can stop it. ` +
4518
+ `Run \`anet node stop <alias>\` on that machine instead. ` +
4519
+ `Daemon-created children have ids beginning with \`node_\`; this one does not.`,
4520
+ };
4521
+
4522
+ const dispatchStopOrDelete = (args: DispatchArgs, clientNetId?: string | null) => {
4523
+ // §4.1 — SEC-1 trust-root join: resolve target node row WITHIN the
4524
+ // caller's scope. resolveReadScope returns 'denied' if the caller
4525
+ // doesn't belong to clientNetId; for the row-existence test we
4526
+ // re-join with member's networks so an attacker can't probe by
4527
+ // node_id from a network they don't belong to.
4528
+ const scope = resolveReadScope(clientNetId);
4529
+ if (scope.denied) {
4530
+ return { ok: false, error: "forbidden_cross_tenant", message: scope.denied };
4531
+ }
4532
+ const nodeQ: any[] = [args.child_node_id];
4533
+ let nodeSql = `SELECT node_id, alias, network_id, lifecycle_state, config_snapshot, runtimes_supported
4534
+ FROM nodes WHERE node_id = ?1`;
4535
+ nodeSql = addReadScope(nodeSql, nodeQ, scope);
4536
+ const node = db.get<{
4537
+ node_id: string;
4538
+ alias: string;
4539
+ network_id: string;
4540
+ lifecycle_state: string | null;
4541
+ config_snapshot: string | null;
4542
+ }>(nodeSql, ...nodeQ);
4543
+ if (!node) return { ok: false, error: "forbidden_cross_tenant", message: "node not found in your networks" };
4544
+
4545
+ // Caller must hold a write-capable role on the resolved network
4546
+ // (admin/owner/member; viewer denied). canWrite walks getUserNetworkRole.
4547
+ if (!canWrite(node.network_id)) {
4548
+ return { ok: false, error: "permission_denied", message: "viewer role cannot stop/delete nodes" };
4549
+ }
4550
+
4551
+ // §4.2 D6 — delete refuses targets whose role is host_supervisor.
4552
+ // Stop is allowed against any node per RFC table (host_supervisor's
4553
+ // stop is functionally a no-op anyway: the daemon's children_map
4554
+ // only tracks its child nodes, never the daemon itself).
4555
+ //
4556
+ // PR1 SF-4 (#345 review) — fail-CLOSED on role read. The first cut
4557
+ // parsed config_snapshot and treated parse-fail / missing-field as
4558
+ // role=null which slipped through the gate. A daemon row with a
4559
+ // corrupt snapshot would then be deletable via delete_node. Fix:
4560
+ // - read role via two independent paths (snapshot.role + the
4561
+ // indexed `runtimes_supported` column whose presence ≈ daemon)
4562
+ // - if EITHER path indicates host_supervisor → refuse
4563
+ // - if snapshot parsed but role missing AND the node has any
4564
+ // daemon-shape evidence (non-null runtimes_supported column,
4565
+ // present in any node_create_requests as daemon_node_id) →
4566
+ // refuse defensively. The defense-in-depth daemon-side
4567
+ // children_map miss is the second layer; the hub gate must
4568
+ // itself fail-closed when role is ambiguous.
4569
+ if (args.action === "delete") {
4570
+ let role: string | null = null;
4571
+ let snapshotParseFailed = false;
4572
+ try {
4573
+ const snap = node.config_snapshot ? JSON.parse(node.config_snapshot) : null;
4574
+ role = typeof snap?.role === "string" ? snap.role : null;
4575
+ } catch { snapshotParseFailed = true; }
4576
+ const ambiguous = snapshotParseFailed || (role == null);
4577
+ // Independent corroborating check: nodes flagged as daemons by
4578
+ // create_node dispatchers will show up as daemon_node_id in
4579
+ // node_create_requests. Costs one indexed query.
4580
+ let looksLikeDaemon = false;
4581
+ if (ambiguous) {
4582
+ const m = db.get<{ n: number }>(
4583
+ `SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1`,
4584
+ node.node_id,
4585
+ );
4586
+ if ((m?.n ?? 0) > 0) looksLikeDaemon = true;
4587
+ }
4588
+ if (role === "host_supervisor" || (ambiguous && looksLikeDaemon)) {
4589
+ return {
4590
+ ok: false, error: "cannot_delete_daemon_via_delete_node",
4591
+ message: snapshotParseFailed
4592
+ ? "node config_snapshot unreadable; node is referenced as a daemon — refuse delete defensively (use delete_daemon path)"
4593
+ : "host_supervisor daemons must be removed via the dedicated delete_daemon path (RFC-027.5)",
4594
+ };
4595
+ }
4596
+ }
4597
+
4598
+ // delete_node confirm_alias gate: dashboard's二次确认 input must
4599
+ // match the actual alias byte-for-byte. Hub rejects mismatched
4600
+ // input even if dashboard UI claims it's disabled.
4601
+ if (args.action === "delete") {
4602
+ if (typeof args.confirm_alias !== "string" || args.confirm_alias !== node.alias) {
4603
+ return { ok: false, error: "confirm_alias_mismatch", message: "confirm_alias must equal the node's alias" };
4604
+ }
4605
+ }
4606
+
4607
+ // Daemon must exist + be in the same network. We do NOT enforce
4608
+ // daemon.role==host_supervisor here: a child whose creator daemon
4609
+ // got demoted should still be stoppable. The daemon will refuse
4610
+ // with noop_not_my_child if children_map doesn't know it.
4611
+ //
4612
+ // PR1.2 e2e catch (BLOCKER): pushEvent keys SSE by clientKey ≈
4613
+ // `${networkId}:${sessionName}`, and agent-node registers its SSE
4614
+ // connection under its ALIAS (not its node_id). The create_node
4615
+ // doorbell at line ~2197 correctly pushes via `daemon.alias`;
4616
+ // stop_node was pushing via `daemon_node_id` so every doorbell
4617
+ // missed every SSE client → daemon never woke up → request_id
4618
+ // stayed 'pending' forever → child never reaped. The 24 unit tests
4619
+ // in stop-delete-node.test.ts all mock pushEvent and inject the
4620
+ // post-doorbell handler call directly, so single-unit coverage
4621
+ // couldn't surface this — exactly the failure class 通信龙 said
4622
+ // the docker e2e gate exists to catch (BLOCKER-1 同源). Load the
4623
+ // alias along with node_id + network_id and dispatch to the alias.
4624
+ const daemon = db.get<{ node_id: string; alias: string; network_id: string }>(
4625
+ `SELECT node_id, alias, network_id FROM nodes WHERE node_id = ?1`, args.daemon_node_id,
4626
+ );
4627
+ if (!daemon) return { ok: false, error: "daemon_not_found" };
4628
+ if (daemon.network_id !== node.network_id) {
4629
+ // Cross-tenant child↔daemon mismatch shouldn't be possible in
4630
+ // normal flow but is a SEC-1 hardening — refuse loud.
4631
+ return { ok: false, error: "daemon_cross_tenant", message: "daemon and child are in different networks" };
4632
+ }
4633
+
4634
+ // #1448 finding-4 — daemon 必须是该 child 的**权威创建者**,镜像 start_node 的
4635
+ // daemon_child_mismatch。此前 stop/delete 接受调用方传入的 daemon_node_id,只校验
4636
+ // 它存在 + 同网,不验它真的创建了这个 child。同网调用方传错 daemon_node_id → 门铃
4637
+ // 路由到错 daemon。🔴 f3(#1453) 合入后更糟:错 daemon 无该 child 的 map entry → 走
4638
+ // 收敛分支 sweep(错机器 pgrep 空) + ack stopped → hub 假收敛 stopped、child 仍在
4639
+ // 正确机器上跑(旧 noop 至少留下可见的卡死态)。网络 scope 只挡跨租户,网内是 footgun。
4640
+ //
4641
+ // 用「确定性不匹配」判据:authority 已知(child 有 create 记录)且不等 → 拒。不对
4642
+ // authority==null(create 记录被裁剪的旧 child)加拒,避免给它们的 stop/delete 引入
4643
+ // 新失败面——handler 的 daemon_node_id??resolveDaemonForChild 已兜住无法解析的情形。
4644
+ const authoritativeDaemonId = resolveDaemonForChild(args.child_node_id);
4645
+ if (authoritativeDaemonId && authoritativeDaemonId !== args.daemon_node_id) {
4646
+ return { ok: false, error: "daemon_child_mismatch", message: "daemon_node_id is not this child's authoritative creator daemon" };
4647
+ }
4648
+
4649
+ // State machine gate.
4650
+ const state = node.lifecycle_state ?? "active";
4651
+ if (args.action === "stop" && state !== "active") {
4652
+ return { ok: false, error: state === "stopping" ? "node_already_stopping" : "node_not_active", current_state: state };
4653
+ }
4654
+ if (args.action === "delete" && state === "deleting") {
4655
+ // #1286 —— stale-deleting 重派出口。
4656
+ //
4657
+ // 在 .40 埋点修复之前,daemon 丢失 ack 会让行**永远**停在 deleting:
4658
+ // 重发 delete 被这里挡住,force=true 也一样 —— 状态机没有出口,
4659
+ // 2026-08-28 实测两行卡死(acc-…-3373 / probe-tl2),API 层面无解。
4660
+ //
4661
+ // 出口的三个条件,缺一不放行:
4662
+ // ① 调用方显式 force=true(不改默认行为 —— 正常并发的重复 delete 仍被拒)
4663
+ // ② 该 child 最近一条 delete 请求不是终态(pending/dispatched)
4664
+ // —— 终态说明 daemon 刚 ack 过,行马上会转移,不该重派
4665
+ // ③ 那条请求已经晾了超过 STALE_DELETING_MS —— 🔴 不是"刚派发就能 force":
4666
+ // 给正常的 doorbell→ack 往返留时间,否则 force 变成竞态放大器。
4667
+ const STALE_DELETING_MS = 5 * 60_000;
4668
+ const last = db.get<{ request_id: string; status: string; created_at: number }>(
4669
+ `SELECT request_id, status, created_at FROM node_stop_requests
4670
+ WHERE child_node_id = ?1 AND action = 'delete'
4671
+ ORDER BY created_at DESC LIMIT 1`, node.node_id,
4672
+ );
4673
+ const lastIsStale = !!last
4674
+ && (last.status === "pending" || last.status === "dispatched")
4675
+ && (Date.now() - last.created_at) > STALE_DELETING_MS;
4676
+ if (!(args.force === true && (lastIsStale || !last))) {
4677
+ return {
4678
+ ok: false, error: "node_already_deleting", current_state: state,
4679
+ // 🔴 告诉调用方出口在哪,而不是让他反复撞同一面墙。
4680
+ ...(last ? { last_request_id: last.request_id, last_request_status: last.status } : {}),
4681
+ hint: last && (last.status === "pending" || last.status === "dispatched")
4682
+ ? `再等一下;若超过 ${STALE_DELETING_MS / 60000} 分钟仍未收敛,带 force=true 重试可重新派发`
4683
+ : "上一条请求已终态,行应即将转移;稍后重查",
4684
+ };
4685
+ }
4686
+ // 放行:走正常派发路径,生成新 request_id。旧请求行保留(审计线索),
4687
+ // daemon 侧对未知 request 的 get_stop_request 会拿到 request_not_found,无副作用。
4688
+ }
4689
+ if (args.action === "delete" && state === "stopping") {
4690
+ // Can't start delete while a stop is mid-flight — race-prone.
4691
+ return { ok: false, error: "node_stopping_in_progress", current_state: state };
4692
+ }
4693
+
4694
+ // §4.3 D4 — in-flight inbox check. Count unacked tasks routed to
4695
+ // this node's alias (inbox routing uses session_name == alias).
4696
+ // Default refuse with the count surfaced; force=true overrides
4697
+ // and triggers the forced_stop_with_in_flight audit row.
4698
+ // SF-5 (#345 review): legacy inbox rows may have network_id=NULL
4699
+ // and would silently miss a network_id=?2 strict equality match.
4700
+ // Use COALESCE so a NULL inbox row is counted against the same
4701
+ // network as the target node (which is the only safe default
4702
+ // — pre-multi-network rows existed in single-network mode).
4703
+ //
4704
+ // #2022 — "unacked" alone is not "in flight". Nothing acks a row
4705
+ // whose task already reached a terminal state (report_completion,
4706
+ // send_reply, send_ack, the TTL patrol all leave inbox alone) and
4707
+ // retention sweeps only acked=1 rows, so those rows were counted
4708
+ // forever: one dead row pinned this gate to node_busy_in_flight
4709
+ // and force=true was the only way past it.
4710
+ //
4711
+ // Counting rule (#2029 review): a row counts while it can still be
4712
+ // work this node is doing — and the age ceiling must NOT be applied
4713
+ // to a task row whose tasks row we can see. agent-node acks the
4714
+ // inbox row only once the task is done, so a 90-minute (or 3-hour,
4715
+ // or ttl_seconds>3600) task keeps acked=0 for its whole run;
4716
+ // capping it by age would open this gate mid-run, which is exactly
4717
+ // what the gate exists to prevent. So:
4718
+ // 1. type='task' row with a matching tasks row — LEFT JOIN on
4719
+ // COALESCE(task_id, id), the key cancel_task / ack_inbox use
4720
+ // (first deliveries set id == task_id; retry/reassign write a
4721
+ // fresh transport id). Status alone decides: terminal
4722
+ // (replied / failed / cancelled / expired) → not counted;
4723
+ // anything else → counted, no age ceiling. Expiry belongs to
4724
+ // patrolExpiredTasks, which now acks the inbox row in the same
4725
+ // transaction, so an expired task leaves this count together
4726
+ // with its rows.
4727
+ // 2. every other row — a task row whose tasks row is gone
4728
+ // (reaped by retention, or a legacy row whose id resolves to
4729
+ // nothing) and non-task rows (reply / broadcast / message):
4730
+ // these carry no status of their own and nothing else will
4731
+ // ever retire them, so they get the age ceiling
4732
+ // IN_FLIGHT_MAX_AGE_MINUTES on inbox.created_at.
4733
+ // The count stays a per-alias inbox count: one PK join per row.
4734
+ const IN_FLIGHT_MAX_AGE_MINUTES = 60;
4735
+ const inFlightRow = db.get<{ n: number }>(
4736
+ `SELECT COUNT(*) AS n FROM inbox i
4737
+ LEFT JOIN tasks t
4738
+ ON i.type = 'task'
4739
+ AND t.task_id = COALESCE(i.task_id, i.id)
4740
+ WHERE i.session_name = ?1 AND i.acked = 0
4741
+ AND COALESCE(i.network_id, ?2) = ?2
4742
+ AND ((t.task_id IS NOT NULL
4743
+ AND t.status NOT IN ('replied', 'failed', 'cancelled', 'expired'))
4744
+ OR (t.task_id IS NULL
4745
+ AND i.created_at >= datetime('now', ?3)))`,
4746
+ node.alias, node.network_id, `-${IN_FLIGHT_MAX_AGE_MINUTES} minutes`,
4747
+ );
4748
+ const inFlight = inFlightRow?.n ?? 0;
4749
+ if (inFlight > 0 && !args.force) {
4750
+ return { ok: false, error: "node_busy_in_flight", in_flight_count: inFlight, hint: "set force=true to override (audit logged)" };
4751
+ }
4752
+
4753
+ // ── all gates passed; create request + transition state + push doorbell, transactionally ──
4754
+ const requestId = generateId("sr");
4755
+ const now = Date.now();
4756
+ const newState = args.action === "stop" ? "stopping" : "deleting";
4757
+
4758
+ // #1448 finding-5 — 记下该 child **当前活着的** ntok 的 token_id,让 ack 精确按
4759
+ // token_id 撤销,而非按 name='node:<alias>' 广撤。后者会在「同 alias 节点在这次
4760
+ // delete 未收敛的窗口内被重建(拿到同名新 token)」时,把新 token 一并误撤。这里
4761
+ // 在派发时刻定住身份;若此刻没有活 token(异常),存 null,ack 退回按 name 撤(旧语义)。
4762
+ const childTokenRow = db.get<{ token_id: string }>(
4763
+ `SELECT token_id FROM api_tokens WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
4764
+ node.network_id, `node:${node.alias}`,
4765
+ );
4766
+ const childTokenId = childTokenRow?.token_id ?? null;
4767
+
4768
+ // §4.5 D8 — lifecycle UPDATE + audit INSERTs + request INSERT atomic.
4769
+ // PR1.1: use db.transaction() (SQLiteAdapter wraps better-sqlite3's
4770
+ // native transaction; PgAdapter ships a real BEGIN/COMMIT/ROLLBACK
4771
+ // shim). Lets us drop the open-coded BEGIN/COMMIT + makes the
4772
+ // SF-2 failure-injection test (mock db.run throw inside callback)
4773
+ // reliable across both backends.
4774
+ try {
4775
+ db.transaction(() => {
4776
+ db.run(
4777
+ `INSERT INTO node_stop_requests
4778
+ (request_id, network_id, daemon_node_id, child_node_id, child_alias, child_token_id, action,
4779
+ delete_config, grace_seconds, force, in_flight_at_dispatch, created_by_token,
4780
+ status, created_at)
4781
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'pending', ?13)`,
4782
+ [
4783
+ requestId, node.network_id, args.daemon_node_id, node.node_id, node.alias, childTokenId, args.action,
4784
+ args.delete_config ? 1 : 0, args.grace_seconds, args.force ? 1 : 0, inFlight,
4785
+ callerTokenId || "unknown", now,
4786
+ ],
4787
+ );
4788
+ db.run(
4789
+ `UPDATE nodes SET lifecycle_state = ?1 WHERE node_id = ?2`,
4790
+ [newState, node.node_id],
4791
+ );
4792
+ // §4.5 D8 — audit dispatch action. Uses the STRICT variant so a
4793
+ // failed audit INSERT propagates and triggers ROLLBACK (PR1 SF-2
4794
+ // review catch — auditCreateNode's swallow-and-warn would have
4795
+ // committed the lifecycle UPDATE without an audit row, leaving
4796
+ // exactly the "deleted but no audit" window §4.5 closes).
4797
+ auditCreateNodeStrict({
4798
+ action: args.action === "stop" ? "stop_node_dispatched" : "delete_node_dispatched",
4799
+ user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
4800
+ detail: {
4801
+ child_node_id: node.node_id, child_alias: node.alias,
4802
+ daemon_node_id: args.daemon_node_id, action: args.action,
4803
+ force: args.force, delete_config: args.delete_config,
4804
+ grace_seconds: args.grace_seconds, in_flight_at_dispatch: inFlight,
4805
+ lifecycle_state_before: state, lifecycle_state_after: newState,
4806
+ ts_request: now,
4807
+ },
4808
+ });
4809
+ if (args.force && inFlight > 0) {
4810
+ auditCreateNodeStrict({
4811
+ action: "forced_stop_with_in_flight",
4812
+ user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
4813
+ detail: { in_flight_count: inFlight, child_alias: node.alias },
4814
+ });
4815
+ }
4816
+ }); // end db.transaction
4817
+ } catch (e: any) {
4818
+ return { ok: false, error: "dispatch_tx_failed", message: e?.message || String(e) };
4819
+ }
4820
+
4821
+ // SSE doorbell — daemon will pull via get_stop_request.
4822
+ // PR1.2 e2e fix: route by daemon.alias (see SELECT above for the
4823
+ // BLOCKER explanation). Mirrors create_node's pushEvent target at
4824
+ // line ~2197.
4825
+ pushEvent(daemon.alias, { type: "stop_node", request_id: requestId }, node.network_id);
4826
+ return {
4827
+ ok: true, request_id: requestId, action: args.action,
4828
+ lifecycle_state: newState, in_flight_at_dispatch: inFlight,
4829
+ };
4830
+ };
4831
+
4832
+ server.tool(
4833
+ "stop_node",
4834
+ "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).",
4835
+ {
4836
+ ...NODE_ID_ALIAS_FIELDS,
4837
+ // PR2 prereq: dashboard rarely knows daemon_node_id directly.
4838
+ // When omitted, hub resolves from the original creation record
4839
+ // (node_create_requests.daemon_node_id keyed by this child).
4840
+ daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
4841
+ force: z.boolean().optional().default(false),
4842
+ grace_seconds: z.number().int().min(5).max(60).optional().default(10),
4843
+ network_id: z.string().max(200).optional(),
4844
+ },
4845
+ async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, force, grace_seconds, network_id: clientNetId }) => {
4846
+ const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
4847
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
4848
+ const child_node_id = idArg.node_id;
4849
+ const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
4850
+ if (!resolved) {
4851
+ return { content: [{ type: "text" as const,
4852
+ text: JSON.stringify(explainUnresolvableDaemon(child_node_id)) }] };
4853
+ }
4854
+ const r = dispatchStopOrDelete(
4855
+ { action: "stop", child_node_id, daemon_node_id: resolved, force: force ?? false,
4856
+ grace_seconds: grace_seconds ?? 10, delete_config: false },
4857
+ clientNetId,
4858
+ );
4859
+ return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
4860
+ },
4861
+ );
4862
+
4863
+ server.tool(
4864
+ "delete_node",
4865
+ "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.",
4866
+ {
4867
+ ...NODE_ID_ALIAS_FIELDS,
4868
+ daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
4869
+ confirm_alias: z.string().min(1).max(200),
4870
+ force: z.boolean().optional().default(false),
4871
+ grace_seconds: z.number().int().min(5).max(60).optional().default(10),
4872
+ delete_config: z.boolean().optional().default(true),
4873
+ network_id: z.string().max(200).optional(),
4874
+ },
4875
+ async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, confirm_alias, force, grace_seconds, delete_config, network_id: clientNetId }) => {
4876
+ const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
4877
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
4878
+ const child_node_id = idArg.node_id;
4879
+ const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
4880
+ if (!resolved) {
4881
+ return { content: [{ type: "text" as const,
4882
+ text: JSON.stringify(explainUnresolvableDaemon(child_node_id)) }] };
4883
+ }
4884
+ const r = dispatchStopOrDelete(
4885
+ { action: "delete", child_node_id, daemon_node_id: resolved, force: force ?? false,
4886
+ grace_seconds: grace_seconds ?? 10, delete_config: delete_config ?? true, confirm_alias },
4887
+ clientNetId,
4888
+ );
4889
+ return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
4890
+ },
4891
+ );
4892
+
4893
+ server.tool(
4894
+ "get_stop_request",
4895
+ "Daemon pulls a pending stop/delete request (called when SSE stop_node doorbell arrives). RFC-027 §2.4.",
4896
+ {
4897
+ request_id: z.string().min(1).max(200),
4898
+ },
4899
+ async ({ request_id }) => {
4900
+ const callerDaemon = resolveCallerDaemonTokenBound();
4901
+ if (!callerDaemon.ok) {
4902
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4903
+ }
4904
+ const row = db.get<{
4905
+ daemon_node_id: string; status: string; network_id: string;
4906
+ child_node_id: string; child_alias: string; action: string;
4907
+ delete_config: number; grace_seconds: number; force: number;
4908
+ }>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, action,
4909
+ delete_config, grace_seconds, force
4910
+ FROM node_stop_requests WHERE request_id = ?1`, request_id);
4911
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
4912
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
4913
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
4914
+ }
4915
+ if (row.network_id !== callerDaemon.networkId) {
4916
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
4917
+ }
4918
+ // Stamp delivered_at on first pull (idempotent — only if still pending).
4919
+ db.run(
4920
+ `UPDATE node_stop_requests SET status = 'delivered', delivered_at = ?1
4921
+ WHERE request_id = ?2 AND status = 'pending'`,
4922
+ [Date.now(), request_id],
4923
+ );
4924
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4925
+ ok: true,
4926
+ request_id,
4927
+ child_node_id: row.child_node_id,
4928
+ child_alias: row.child_alias,
4929
+ action: row.action,
4930
+ delete_config: row.delete_config === 1,
4931
+ grace_seconds: row.grace_seconds,
4932
+ force: row.force === 1,
4933
+ }) }] };
4934
+ },
4935
+ );
4936
+
4937
+ server.tool(
4938
+ "ack_stop_request",
4939
+ "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.",
4940
+ {
4941
+ request_id: z.string().min(1).max(200),
4942
+ status: z.enum(["stopped", "stop_failed", "noop_not_my_child"]),
4943
+ exit_signal: z.string().max(16).optional(),
4944
+ backup_path: z.string().max(500).optional(),
4945
+ error: z.string().max(1000).optional(),
4946
+ },
4947
+ async ({ request_id, status, exit_signal, backup_path, error: ackError }) => {
4948
+ // #1358 —— 🔴 在此之前,这个工具的**每一条出口都是静默的**:到达不记、
4949
+ // 三条拒绝不记、成功收尾也不记。而隔壁 create-node 的收尾**是记的**
4950
+ // (`✓ create-node finalize`)。这种不对称最坑人:读日志的人看到 create 有、
4951
+ // delete 没有,第一反应是「delete 没发生」—— 但两者的日志覆盖面本来就不同,
4952
+ // 那条推论没有依据。排查 #1286 时我自己就差点这样读。
4953
+ console.log(`[commhub] ← ack_stop_request request=${request_id} status=${status}${exit_signal ? ` exit_signal=${exit_signal}` : ""}`);
4954
+ const callerDaemon = resolveCallerDaemonTokenBound();
4955
+ if (!callerDaemon.ok) {
4956
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4957
+ }
4958
+ const row = db.get<{
4959
+ daemon_node_id: string; status: string; network_id: string;
4960
+ child_node_id: string; child_alias: string; child_token_id: string | null; action: string;
4961
+ in_flight_at_dispatch: number; force: number;
4962
+ }>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, child_token_id, action,
4963
+ in_flight_at_dispatch, force
4964
+ FROM node_stop_requests WHERE request_id = ?1`, request_id);
4965
+ if (!row) {
4966
+ console.warn(`[commhub] ✕ ack_stop_request rejected: request_not_found request=${request_id}`);
4967
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
4968
+ }
4969
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
4970
+ console.warn(`[commhub] ✕ ack_stop_request rejected: not_your_request request=${request_id} row_daemon=${row.daemon_node_id} caller=${callerDaemon.daemonNodeId}`);
4971
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
4972
+ }
4973
+ if (row.network_id !== callerDaemon.networkId) {
4974
+ console.warn(`[commhub] ✕ ack_stop_request rejected: cross_network_request request=${request_id} row_net=${row.network_id} caller_net=${callerDaemon.networkId}`);
4975
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
4976
+ }
4977
+ const now = Date.now();
4978
+
4979
+ // §4.5 D8 — lifecycle UPDATE + request UPDATE + audit INSERT
4980
+ // (+ DELETE for delete action) atomic via db.transaction().
4981
+ // PR1.1: replaces hand-rolled BEGIN..COMMIT so PG adapter and
4982
+ // SQLite both get correct rollback semantics from one code path.
4983
+ try {
4984
+ db.transaction(() => {
4985
+ db.run(
4986
+ `UPDATE node_stop_requests SET status = ?1, error = ?2, exit_signal = ?3,
4987
+ backup_path = ?4, acked_at = ?5
4988
+ WHERE request_id = ?6`,
4989
+ [status, ackError || null, exit_signal || null, backup_path || null, now, request_id],
4990
+ );
4991
+ if (status === "stopped" && row.action === "stop") {
4992
+ db.run(`UPDATE nodes SET lifecycle_state = 'stopped' WHERE node_id = ?1`, [row.child_node_id]);
4993
+ auditCreateNodeStrict({
4994
+ action: "stop_node_completed",
4995
+ user_id: null, network_id: row.network_id, target_id: request_id,
4996
+ detail: {
4997
+ child_node_id: row.child_node_id, child_alias: row.child_alias,
4998
+ exit_signal: exit_signal || null, ts_daemon_ack: now,
4999
+ lifecycle_state_after: "stopped",
5000
+ },
5001
+ });
5002
+ } else if (status === "stopped" && row.action === "delete") {
5003
+ // Revoke the child's ntok, then DELETE the nodes row.
5004
+ // #1448 finding-5 — 精确按派发时定住的 child_token_id 撤(镜像
5005
+ // ack_create_request 的 token_id 精撤)。旧行(升级前派发,child_token_id
5006
+ // 为 NULL)fallback 回按 name='node:<alias>' 撤——旧语义,不引入回归。
5007
+ // 按 token_id 撤避免了「同 alias 在本次 delete 窗口内被重建、拿到同名新
5008
+ // token」时把新 token 一并误撤。
5009
+ if (row.child_token_id) {
5010
+ db.run(
5011
+ `UPDATE api_tokens SET revoked_at = datetime('now')
5012
+ WHERE token_id = ?1 AND revoked_at IS NULL`,
5013
+ [row.child_token_id],
5014
+ );
5015
+ } else {
5016
+ db.run(
5017
+ `UPDATE api_tokens SET revoked_at = datetime('now')
5018
+ WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
5019
+ [row.network_id, `node:${row.child_alias}`],
5020
+ );
5021
+ }
5022
+ db.run(`DELETE FROM nodes WHERE node_id = ?1`, [row.child_node_id]);
5023
+ auditCreateNodeStrict({
5024
+ action: "delete_node_completed",
5025
+ user_id: null, network_id: row.network_id, target_id: request_id,
5026
+ detail: {
5027
+ child_node_id: row.child_node_id, child_alias: row.child_alias,
5028
+ exit_signal: exit_signal || null, backup_path: backup_path || null,
5029
+ ts_daemon_ack: now, lifecycle_state_after: "deleted",
5030
+ },
5031
+ });
5032
+ } else if (status === "stop_failed") {
5033
+ db.run(`UPDATE nodes SET lifecycle_state = 'stop_failed' WHERE node_id = ?1`, [row.child_node_id]);
5034
+ // No completion audit; the failure error is on the request row.
5035
+ }
5036
+ // noop_not_my_child: leave lifecycle_state as-is; the hub-side
5037
+ // sweeper / next reconciliation will pick it up. Don't transition.
5038
+ });
5039
+ } catch (e: any) {
5040
+ console.warn(`[commhub] ✕ ack_stop_request finalize_tx_failed request=${request_id} action=${row.action} child=${row.child_alias}: ${e?.message || e}`);
5041
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "finalize_tx_failed", message: e?.message || String(e) }) }] };
5042
+ }
5043
+
5044
+ // 🔴 关键的是最后那半句「据此做了什么」。#1286 里 hub 的代码路径经核对是
5045
+ // 正确的,缺的正是「它到底走没走那条分支」—— 只说「收到了 ack」回答不了。
5046
+ const did = status === "stopped" && row.action === "delete"
5047
+ ? "revoked ntok + DELETE FROM nodes"
5048
+ : status === "stopped" && row.action === "stop"
5049
+ ? "lifecycle_state=stopped"
5050
+ : status === "stop_failed"
5051
+ ? "lifecycle_state=stop_failed"
5052
+ : "no lifecycle transition (noop_not_my_child)";
5053
+ console.log(`[commhub] ✓ ${row.action}-node finalize: request=${request_id} child=${row.child_alias} (${row.child_node_id}) status=${status} → ${did}`);
5054
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
5055
+ },
5056
+ );
5057
+
5058
+ // start_node is deliberately daemon-mediated. A stopped child has no SSE
5059
+ // consumer of its own, so restart_node's self-doorbell cannot revive it.
5060
+ server.tool(
5061
+ "start_node",
5062
+ "Start a stopped child through its host supervisor daemon. daemon_node_id is auto-resolved from the original create request.",
5063
+ {
5064
+ ...NODE_ID_ALIAS_FIELDS,
5065
+ daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
5066
+ network_id: z.string().max(200).optional(),
5067
+ },
5068
+ async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, network_id: clientNetId }) => {
5069
+ const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
5070
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
5071
+ const child_node_id = idArg.node_id;
5072
+ const resolvedDaemonId = daemon_node_id ?? resolveDaemonForChild(child_node_id);
5073
+ if (!resolvedDaemonId) {
5074
+ return { content: [{ type: "text" as const, text: JSON.stringify({
5075
+ ok: false, error: "daemon_not_resolvable",
5076
+ message: "no create request maps this child to a daemon",
5077
+ }) }] };
5078
+ }
5079
+ const scope = resolveReadScope(clientNetId);
5080
+ if (scope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "forbidden_cross_tenant" }) }] };
5081
+ const q: any[] = [child_node_id];
5082
+ const child = db.get<{ node_id: string; alias: string; network_id: string; lifecycle_state: string | null }>(
5083
+ addReadScope(`SELECT node_id, alias, network_id, lifecycle_state FROM nodes WHERE node_id = ?1`, q, scope), ...q,
5084
+ );
5085
+ if (!child) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "forbidden_cross_tenant" }) }] };
5086
+ if (!canWrite(child.network_id)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
5087
+ // #1448 finding-2 — stale-starting reaper,对齐 update_node_config / restart_node
5088
+ // 的 60s single-flight stale-supersede。start 只受理 'stopped';一旦卡 'starting'
5089
+ // (门铃丢 / daemon 在 UPDATE 'starting' 后死),config/restart 有 reaper、delete 有
5090
+ // 5min force,唯独 start 裸奔 → 下面的 gate 永远拦、也无人把节点拉回 stopped →
5091
+ // 永久卡 'starting'。出口:'starting' 且最近一条 in-flight start 请求晾过阈值 → 标
5092
+ // 旧请求 timeout 超越、放行重派;未过阈值则拒 node_already_starting(带 age)。
5093
+ const priorState = child.lifecycle_state ?? "active";
5094
+ if (priorState !== "stopped") {
5095
+ if (priorState !== "starting") {
5096
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_stopped", current_state: priorState }) }] };
5097
+ }
5098
+ const STALE_STARTING_MS = 60_000;
5099
+ const inFlight = db.get<{ request_id: string; created_at: number; delivered_at: number | null; acked_at: number | null }>(
5100
+ `SELECT request_id, created_at, delivered_at, acked_at FROM node_start_requests
5101
+ WHERE child_node_id = ?1 AND status IN ('pending', 'delivered')
5102
+ ORDER BY created_at DESC LIMIT 1`, child.node_id,
5103
+ );
5104
+ if (inFlight) {
5105
+ // Age anchor = COALESCE(acked_at, delivered_at, created_at),同 config reaper:
5106
+ // 已 pull/已 ack 的活着但慢的 start 会刷新存活时钟,不被误 reap。
5107
+ const ageAnchor = inFlight.acked_at ?? inFlight.delivered_at ?? inFlight.created_at;
5108
+ const age = Date.now() - ageAnchor;
5109
+ if (age <= STALE_STARTING_MS) {
5110
+ return { content: [{ type: "text" as const, text: JSON.stringify({
5111
+ ok: false, error: "node_already_starting",
5112
+ existing_request_id: inFlight.request_id, age_ms: age,
5113
+ hint: `再等一下;超过 ${STALE_STARTING_MS / 1000}s 仍卡 starting,重试 start 会重新派发`,
5114
+ }) }] };
5115
+ }
5116
+ // Stale — 标旧请求 timeout(autocommit,先于下面 tx 的新 INSERT,解除
5117
+ // node_start_requests 对 pending/delivered 的 child 唯一约束),放行重派。
5118
+ db.run(
5119
+ `UPDATE node_start_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3`,
5120
+ [Date.now(), `superseded by new start after ${age}ms stale (> ${STALE_STARTING_MS}ms threshold)`, inFlight.request_id],
5121
+ );
5122
+ }
5123
+ // else: 'starting' 但无 in-flight 行(异常残留态)——照样放行重派以自愈。
5124
+ }
5125
+ // The caller may provide daemon_node_id, but it cannot override the
5126
+ // authoritative child->daemon relationship recorded at creation.
5127
+ const authoritativeDaemonId = resolveDaemonForChild(child_node_id);
5128
+ if (!authoritativeDaemonId || authoritativeDaemonId !== resolvedDaemonId) {
5129
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_child_mismatch" }) }] };
5130
+ }
5131
+ const daemon = db.get<{ node_id: string; alias: string; network_id: string }>(
5132
+ `SELECT node_id, alias, network_id FROM nodes WHERE node_id = ?1`, resolvedDaemonId,
5133
+ );
5134
+ if (!daemon) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found" }) }] };
5135
+ if (daemon.network_id !== child.network_id) {
5136
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_cross_tenant" }) }] };
5137
+ }
5138
+ const requestId = generateId("str");
5139
+ const now = Date.now();
5140
+ try {
5141
+ db.transaction(() => {
5142
+ db.run(
5143
+ `INSERT INTO node_start_requests
5144
+ (request_id, network_id, daemon_node_id, child_node_id, child_alias,
5145
+ created_by_token, status, created_at)
5146
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7)`,
5147
+ [requestId, child.network_id, daemon.node_id, child.node_id, child.alias, callerTokenId || "unknown", now],
5148
+ );
5149
+ // #1448 finding-2 — 也接受从 'starting' 重派(stale-supersede 后原态仍是
5150
+ // starting);两种入态都合法地转/留到 'starting'。
5151
+ db.run(`UPDATE nodes SET lifecycle_state = 'starting' WHERE node_id = ?1 AND lifecycle_state IN ('stopped', 'starting')`, [child.node_id]);
5152
+ auditCreateNodeStrict({
5153
+ action: "start_node_dispatched", user_id: enforceUserId,
5154
+ network_id: child.network_id, target_id: requestId,
5155
+ detail: { child_node_id: child.node_id, child_alias: child.alias, daemon_node_id: daemon.node_id,
5156
+ lifecycle_state_before: priorState, lifecycle_state_after: "starting", ts_request: now },
5157
+ });
5158
+ });
5159
+ } catch (e: any) {
5160
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "dispatch_tx_failed", message: e?.message || String(e) }) }] };
5161
+ }
5162
+ pushEvent(daemon.alias, { type: "start_node", request_id: requestId }, child.network_id);
5163
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, lifecycle_state: "starting" }) }] };
5164
+ },
5165
+ );
5166
+
5167
+ server.tool(
5168
+ "get_start_request",
5169
+ "Host supervisor pulls an authenticated pending start request.",
5170
+ { request_id: z.string().min(1).max(200) },
5171
+ async ({ request_id }) => {
5172
+ const callerDaemon = resolveCallerDaemonTokenBound();
5173
+ if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
5174
+ const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
5175
+ `SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id = ?1`, request_id,
5176
+ );
5177
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
5178
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
5179
+ if (row.network_id !== callerDaemon.networkId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
5180
+ if (!['pending', 'delivered'].includes(row.status)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_startable", status: row.status }) }] };
5181
+ db.run(`UPDATE node_start_requests SET status='delivered', delivered_at=?1 WHERE request_id=?2 AND status='pending'`, [Date.now(), request_id]);
5182
+ 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 }) }] };
5183
+ },
5184
+ );
5185
+
5186
+ server.tool(
5187
+ "ack_start_request",
5188
+ "Host supervisor reports start completion or failure.",
5189
+ {
5190
+ request_id: z.string().min(1).max(200),
5191
+ status: z.enum(["started", "start_failed"]),
5192
+ child_pid: z.number().int().positive().optional(),
5193
+ error: z.string().max(1000).optional(),
5194
+ },
5195
+ async ({ request_id, status, child_pid, error: ackError }) => {
5196
+ const callerDaemon = resolveCallerDaemonTokenBound();
5197
+ if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
5198
+ const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
5199
+ `SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id=?1`, request_id,
5200
+ );
5201
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
5202
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
5203
+ if (row.network_id !== callerDaemon.networkId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
5204
+ if (row.status === "started" || row.status === "start_failed") {
5205
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: row.status, idempotent: true }) }] };
5206
+ }
5207
+ if (!['pending', 'delivered'].includes(row.status)) {
5208
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_ackable", status: row.status }) }] };
5209
+ }
5210
+ if (status === "started" && !child_pid) {
5211
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "child_pid_required" }) }] };
5212
+ }
5213
+ const now = Date.now();
5214
+ try {
5215
+ db.transaction(() => {
5216
+ 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]);
5217
+ db.run(`UPDATE nodes SET lifecycle_state=?1 WHERE node_id=?2`, [status === "started" ? "active" : "stopped", row.child_node_id]);
5218
+ if (status === "started") auditCreateNodeStrict({
5219
+ action: "start_node_completed", network_id: row.network_id, target_id: request_id,
5220
+ detail: { child_node_id: row.child_node_id, child_alias: row.child_alias, child_pid, lifecycle_state_after: "active", ts_daemon_ack: now },
5221
+ });
5222
+ });
5223
+ } catch (e: any) {
5224
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "finalize_tx_failed", message: e?.message || String(e) }) }] };
5225
+ }
5226
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
5227
+ },
5228
+ );
5229
+
5230
+ // RFC-027 PR1.1 — list_my_children: daemon-only query. Returns the
5231
+ // {child_node_id, alias, lifecycle_state} tuples for nodes whose
5232
+ // active create-request has this daemon as daemon_node_id. Used at
5233
+ // daemon boot to rebuild the in-memory childrenMap (PR1 dropped
5234
+ // every entry on daemon restart → stop/delete silently no-op'd).
5235
+ //
5236
+ // Network-scope is the daemon's own (resolveCallerDaemonTokenBound
5237
+ // already binds tokenIsNetwork to a single network). No SEC-1 leak:
5238
+ // returns ONLY children whose request landed under this daemon's
5239
+ // token + network.
5240
+ server.tool(
5241
+ "list_my_children",
5242
+ "Daemon pulls the alias + node_id list of children it spawned (for childrenMap rebuild after restart). RFC-027 PR1.1.",
5243
+ {},
5244
+ async () => {
5245
+ const callerDaemon = resolveCallerDaemonTokenBound();
5246
+ if (!callerDaemon.ok) {
5247
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
5248
+ }
5249
+ // node_create_requests carries the canonical authoritative
5250
+ // child→daemon mapping (the row id IS the request, and the
5251
+ // child node_id derives from it deterministically via
5252
+ // `node_${request_id.replace(/^cr_/, "")}`). Filter to children
5253
+ // that completed registration (have a nodes row) so we don't
5254
+ // ask the daemon to pgrep for children that never came up.
5255
+ const rows = db.all<{ request_id: string; child_name: string; child_node_id: string; lifecycle_state: string | null }>(
5256
+ `SELECT ncr.request_id, ncr.child_name,
5257
+ ('node_' || substr(ncr.request_id, 4)) AS child_node_id,
5258
+ n.lifecycle_state
5259
+ FROM node_create_requests ncr
5260
+ LEFT JOIN nodes n ON n.node_id = ('node_' || substr(ncr.request_id, 4))
5261
+ WHERE ncr.daemon_node_id = ?1
5262
+ AND ncr.network_id = ?2
5263
+ AND ncr.status IN ('succeeded', 'delivered')
5264
+ AND n.node_id IS NOT NULL
5265
+ AND COALESCE(n.lifecycle_state, 'active') NOT IN ('stopped', 'stop_failed')`,
5266
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
5267
+ );
5268
+ const children = rows.map(r => ({
5269
+ child_node_id: r.child_node_id,
5270
+ alias: r.child_name,
5271
+ lifecycle_state: r.lifecycle_state ?? "active",
5272
+ }));
5273
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: children.length, children }) }] };
5274
+ },
5275
+ );
5276
+
2291
5277
  // ── RFC-028 P1 — Provider & Model Registry + connectivity probe ──
2292
5278
  // Background timers (probe GC + sweeper) idempotent; safe to call
2293
5279
  // per registerTools.
@@ -2516,6 +5502,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2516
5502
  const willReplaceModels = patch.models !== undefined;
2517
5503
  const newModelIds: string[] = [];
2518
5504
 
5505
+ // No-op early return (通信牛 nit): all patch fields matched the
5506
+ // existing row values + no models supplied → no real change to
5507
+ // persist. Returning here means we don't write an empty-diff
5508
+ // audit row (audit noise). Audit_log INSERT must only fire when
5509
+ // something actually changed.
5510
+ if (sets.length === 0 && !willReplaceModels) {
5511
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id, no_changes: true, hint: "patch fields matched existing row, no-op" }) }] };
5512
+ }
5513
+
2519
5514
  try {
2520
5515
  db.exec("BEGIN");
2521
5516
  if (sets.length > 0) {
@@ -2554,10 +5549,6 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2554
5549
  throw e;
2555
5550
  }
2556
5551
 
2557
- if (Object.keys(diff).length === 0 && !willReplaceModels) {
2558
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id, no_changes: true, hint: "patch fields matched existing row, no-op" }) }] };
2559
- }
2560
-
2561
5552
  return { content: [{ type: "text" as const, text: JSON.stringify({
2562
5553
  ok: true, provider_id,
2563
5554
  fields_changed: Object.keys(diff),
@@ -2750,6 +5741,110 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2750
5741
  }
2751
5742
  },
2752
5743
  );
5744
+
5745
+ // ── 需求池 / 任务看板(Agent 读写任务,例如把 GitHub issue 同步成任务) ──
5746
+ // 全部走 REST 同一个处理函数(requirements.ts handleRequirementsRequest):权限、校验、谁做的记录只有一份。
5747
+ // 节点令牌只在它绑定的网络里读 / 建 / 改 / 勾子任务 / upsert / 读项目;删除和管理项目只给人。
5748
+ const requirementsCall = async (method: string, path: string, clientNetId?: string | null, body?: Record<string, unknown>) => {
5749
+ const scope = resolveRestNetworkScope(enforceNetworkId ? null : (clientNetId ?? null), mcpAuthCtx, false);
5750
+ if (scope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "access_denied", message: scope.denied }) }] };
5751
+ const url = new URL(`http://mcp.internal${path}`);
5752
+ const req = new Request(url, { method, headers: { "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
5753
+ const res = await handleRequirementsRequest({
5754
+ req, url,
5755
+ auth: mcpAuthCtx ? { ...mcpAuthCtx, username: "", tokenId: callerTokenId ?? null, tokenName: callerTokenIsNetwork && callerAlias ? `node:${callerAlias}` : null } : null,
5756
+ isAdmin: false,
5757
+ isNodeToken: callerTokenIsNetwork,
5758
+ scope,
5759
+ });
5760
+ const data = res ? await res.json() : { ok: false, error: "not_found" };
5761
+ return { content: [{ type: "text" as const, text: JSON.stringify(res && !res.ok && data && typeof data === "object" ? { ...data, status: res.status } : data) }] };
5762
+ };
5763
+ const reqPerson = z.object({ kind: z.enum(["user", "node"]), id: z.string().min(1).max(200) });
5764
+ const reqChecklistItem = z.object({ id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/).optional(), text: z.string().min(1).max(500), done: z.boolean().optional() });
5765
+ const reqFields = {
5766
+ tags: z.array(z.string()).max(10).optional().describe("Task labels; each 1–20 Unicode characters. Omit to preserve, [] to clear."),
5767
+ name: z.string().min(1).max(80).optional(),
5768
+ priority: z.enum(["high", "normal", "low", "lowest"]).optional().describe("high = P0 最高, normal = P1 普通, low = P2 低, lowest = P3 极低"),
5769
+ column: z.enum(["pool", "doing", "done"]).optional(),
5770
+ due: z.string().max(40).optional().describe("YYYY-MM-DD (all day) or ISO 8601 with Z / ±HH:MM (stored as UTC seconds); \"\" clears"),
5771
+ description: z.string().max(20_000).optional().describe("markdown"),
5772
+ checklist: z.array(reqChecklistItem).max(100).optional().describe("replaces the whole list"),
5773
+ project_id: z.string().max(200).nullable().optional(),
5774
+ owner: reqPerson.nullable().optional().describe("负责人: must be {kind:'user'}"),
5775
+ agent_owner: reqPerson.nullable().optional().describe("负责 Agent: must be {kind:'node'}"),
5776
+ participants: z.array(reqPerson).max(100).optional(),
5777
+ external_url: z.string().max(500).nullable().optional(),
5778
+ parent_id: z.string().regex(/^[A-Za-z0-9_-]{1,200}$/).nullable().optional().describe("parent requirement (same network, no cycles, ≤ 5 levels); null detaches"),
5779
+ };
5780
+ const pick = (args: Record<string, unknown>, keys: string[]) => Object.fromEntries(keys.filter(k => args[k] !== undefined).map(k => [k, args[k]]));
5781
+ const REQ_WRITE_KEYS = [...Object.keys(reqFields), "external_ref", "archived"];
5782
+
5783
+ server.tool(
5784
+ "requirements_list",
5785
+ "List requirement tasks in your network. Filters: status (pool/doing/done), project_id ('none' = no project), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), updated_since (ISO), external_ref, parent_id / top_level, include_archived. Newest first, max 500. Each task carries children {total, done}.",
5786
+ {
5787
+ network_id: z.string().max(200).optional(),
5788
+ status: z.enum(["pool", "doing", "done"]).optional(),
5789
+ project_id: z.string().max(200).optional(),
5790
+ owner: z.string().max(210).optional(),
5791
+ agent_owner: z.string().max(210).optional(),
5792
+ updated_since: z.string().max(40).optional(),
5793
+ external_ref: z.string().max(200).optional(),
5794
+ include_archived: z.boolean().optional(),
5795
+ parent_id: z.string().max(200).optional().describe("children of this requirement ('none' = top level)"),
5796
+ top_level: z.boolean().optional(),
5797
+ },
5798
+ async (args) => {
5799
+ const q = new URLSearchParams();
5800
+ for (const k of ["status", "project_id", "owner", "agent_owner", "updated_since", "external_ref", "parent_id"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
5801
+ if (args.include_archived) q.set("include_archived", "1");
5802
+ if (args.top_level) q.set("top_level", "1");
5803
+ return requirementsCall("GET", `/api/requirements${q.size ? `?${q}` : ""}`, args.network_id);
5804
+ },
5805
+ );
5806
+
5807
+ server.tool(
5808
+ "requirements_get",
5809
+ "Get one requirement task by id (with description, checklist, owners, project, external_ref).",
5810
+ { id: z.string().min(1).max(200), network_id: z.string().max(200).optional() },
5811
+ async ({ id, network_id }) => requirementsCall("GET", `/api/requirements/${encodeURIComponent(id)}`, network_id),
5812
+ );
5813
+
5814
+ server.tool(
5815
+ "requirements_create",
5816
+ "Create a requirement task. name is required. A duplicate external_ref in the network returns 409 external_ref_exists with existing_id — use requirements_upsert_by_external_ref for syncing.",
5817
+ { network_id: z.string().max(200).optional(), ...reqFields, name: z.string().min(1).max(80), external_ref: z.string().max(200).optional() },
5818
+ async (args) => requirementsCall("POST", "/api/requirements", args.network_id, pick(args, REQ_WRITE_KEYS)),
5819
+ );
5820
+
5821
+ server.tool(
5822
+ "requirements_update",
5823
+ "Patch a requirement task; omitted fields keep their value. archived=true hides it from the default list (agents cannot delete).",
5824
+ { id: z.string().min(1).max(200), network_id: z.string().max(200).optional(), ...reqFields, external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
5825
+ async (args) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(args.id)}`, args.network_id, pick(args, REQ_WRITE_KEYS)),
5826
+ );
5827
+
5828
+ server.tool(
5829
+ "requirements_checklist_toggle",
5830
+ "Set one checklist item done / not done (only that item is written, so concurrent edits of other items are kept).",
5831
+ { id: z.string().min(1).max(200), item_id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/), done: z.boolean(), network_id: z.string().max(200).optional() },
5832
+ async ({ id, item_id, done, network_id }) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(id)}/checklist/${encodeURIComponent(item_id)}`, network_id, { done }),
5833
+ );
5834
+
5835
+ server.tool(
5836
+ "requirements_upsert_by_external_ref",
5837
+ "Idempotent sync: create the task for external_ref (e.g. github:owner/repo#123) or, if it already exists in the network, patch it (omitted fields — including status — are kept). Returns { requirement, created }.",
5838
+ { network_id: z.string().max(200).optional(), external_ref: z.string().min(1).max(200), ...reqFields, archived: z.boolean().optional() },
5839
+ async (args) => requirementsCall("POST", "/api/requirements/upsert", args.network_id, pick(args, REQ_WRITE_KEYS)),
5840
+ );
5841
+
5842
+ server.tool(
5843
+ "projects_list",
5844
+ "List requirement projects in your network (id, name, color, sort, archived).",
5845
+ { network_id: z.string().max(200).optional() },
5846
+ async ({ network_id }) => requirementsCall("GET", "/api/requirements/projects", network_id),
5847
+ );
2753
5848
  }
2754
5849
 
2755
5850
  // ────────────────────────────────────────────────────────────────────
@@ -2767,6 +5862,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2767
5862
  export interface UpsertNodeWithSec1GuardInput {
2768
5863
  node_id: string;
2769
5864
  callerNetworkId: string | null;
5865
+ callerUserId?: string | null;
5866
+ callerTokenId?: string | null;
2770
5867
  node_name?: string | null;
2771
5868
  alias?: string | null;
2772
5869
  runtime?: string | null;
@@ -2779,19 +5876,60 @@ export interface UpsertNodeWithSec1GuardInput {
2779
5876
  }
2780
5877
  export type UpsertNodeOutcome =
2781
5878
  | { result: "inserted" | "updated"; node_id: string }
2782
- | { result: "refused"; reason: "cross_network"; existingNet: string | null; callerNet: string | null }
5879
+ | { result: "refused"; reason: "cross_network" | "token_node_mismatch" | "owner_mismatch"; existingNet: string | null; callerNet: string | null }
2783
5880
  | { result: "skipped"; reason: "missing_node_id" };
2784
5881
 
2785
5882
  const _norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x);
2786
5883
 
5884
+ /**
5885
+ * A capability that changes peer delivery semantics is trusted only when the
5886
+ * authenticating ntok is immutably bound to the exact reported node. Legacy
5887
+ * alias-only tokens may still heartbeat, but cannot opt a node into #698.
5888
+ */
5889
+ export function trustedConfigSnapshotForNode(
5890
+ snapshot: unknown | null | undefined,
5891
+ callerTokenId: string | null | undefined,
5892
+ nodeId: string,
5893
+ ): unknown | null | undefined {
5894
+ if (!snapshot || typeof snapshot !== "object") return snapshot;
5895
+ const copy = { ...(snapshot as Record<string, unknown>) };
5896
+ const token = callerTokenId
5897
+ ? db.get<{ bound_node_id: string | null }>(
5898
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1",
5899
+ callerTokenId,
5900
+ )
5901
+ : null;
5902
+ if (token?.bound_node_id !== nodeId) delete copy.peer_reply_inbox_capable;
5903
+ return copy;
5904
+ }
5905
+
2787
5906
  export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): UpsertNodeOutcome {
2788
5907
  if (!input.node_id) return { result: "skipped", reason: "missing_node_id" };
2789
- const existing = db.get<{ network_id: string | null }>(
2790
- "SELECT network_id FROM nodes WHERE node_id = ?1",
5908
+ const existing = db.get<{ network_id: string | null; owner_user_id: string | null }>(
5909
+ "SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1",
2791
5910
  input.node_id,
2792
5911
  );
2793
5912
  const callerNet = input.callerNetworkId;
2794
5913
 
5914
+ // RFC-036 — when a token was minted with a node_id binding, a heartbeat may
5915
+ // report only that exact node. Legacy unbound ntok rows remain compatible,
5916
+ // but they can never consume owner-gated schedule intents.
5917
+ if (input.callerTokenId) {
5918
+ const token = db.get<{ bound_node_id: string | null; user_id: string; network_id: string | null }>(
5919
+ "SELECT bound_node_id, user_id, network_id FROM api_tokens WHERE token_id = ?1",
5920
+ input.callerTokenId,
5921
+ );
5922
+ if (token?.bound_node_id && token.bound_node_id !== input.node_id) {
5923
+ return { result: "refused", reason: "token_node_mismatch", existingNet: existing?.network_id ?? null, callerNet };
5924
+ }
5925
+ if (token?.network_id && _norm(token.network_id) !== _norm(callerNet)) {
5926
+ return { result: "refused", reason: "cross_network", existingNet: existing?.network_id ?? null, callerNet };
5927
+ }
5928
+ }
5929
+ if (existing?.owner_user_id && input.callerUserId !== existing.owner_user_id) {
5930
+ return { result: "refused", reason: "owner_mismatch", existingNet: existing.network_id, callerNet };
5931
+ }
5932
+
2795
5933
  // Legacy / first-write paths: row missing OR network_id NULL → claim.
2796
5934
  const isLegacy = !existing
2797
5935
  || existing.network_id === null
@@ -2837,17 +5975,29 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
2837
5975
  callerNet ?? null,
2838
5976
  ],
2839
5977
  );
2840
- if (input.config_snapshot) {
2841
- // RFC-026 §9.3 / #338 PR2 — promote daemon self-declare fields to
2842
- // first-class indexable columns alongside the snapshot blob. The
2843
- // snapshot stays the source of truth for non-list reads; the columns
2844
- // exist so `list_host_supervisors` doesn't JSON.parse on every call.
2845
- // typeof-narrow the array fields (don't trust shape per
2846
- // [[feedback_typeof_narrow_extracted_fields]]; zod has already
2847
- // narrowed, but the input.config_snapshot type is `unknown` here).
2848
- const snap = input.config_snapshot as Record<string, unknown> | null;
2849
- const runtimesRaw = snap?.runtimes_supported;
2850
- const allowedRaw = snap?.allowed_secret_keys;
5978
+ const trustedSnapshot = trustedConfigSnapshotForNode(
5979
+ input.config_snapshot,
5980
+ input.callerTokenId,
5981
+ input.node_id,
5982
+ );
5983
+ if (trustedSnapshot) {
5984
+ // RFC-026 §9.3 / #338 PR2+PR3 — promote daemon self-declare fields
5985
+ // to first-class indexable columns alongside the snapshot blob.
5986
+ // The snapshot stays the source of truth for non-list reads; the
5987
+ // columns exist so `list_host_supervisors` doesn't JSON.parse on
5988
+ // every call. typeof-narrow per
5989
+ // per team rule (typeof-narrow extracted JSON fields at the boundary) — zod narrowed but
5990
+ // input.config_snapshot is typed `unknown` here.
5991
+ //
5992
+ // PR3 nit ①: read from nested `daemon_capabilities.*` (canonical
5993
+ // per RFC §9.3 + matches existing hub create_node reads at
5994
+ // tools.ts:2010/2075). PR2 ate from top-level keys, which the
5995
+ // hub create_node path never read → max_concurrent_children
5996
+ // backpressure was dead config + allowlist enforcement bypassed.
5997
+ const snap = trustedSnapshot as Record<string, unknown> | null;
5998
+ const caps = (snap?.daemon_capabilities ?? null) as Record<string, unknown> | null;
5999
+ const runtimesRaw = caps?.runtimes_supported;
6000
+ const allowedRaw = caps?.allowed_secret_keys;
2851
6001
  const runtimesJson = Array.isArray(runtimesRaw) && runtimesRaw.every(s => typeof s === "string")
2852
6002
  ? JSON.stringify(runtimesRaw)
2853
6003
  : null;
@@ -2856,7 +6006,7 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
2856
6006
  : null;
2857
6007
  db.run(
2858
6008
  `UPDATE nodes SET config_snapshot = ?1, runtimes_supported = ?2, allowed_secret_keys = ?3 WHERE node_id = ?4`,
2859
- [JSON.stringify(input.config_snapshot), runtimesJson, allowedJson, input.node_id],
6009
+ [JSON.stringify(trustedSnapshot), runtimesJson, allowedJson, input.node_id],
2860
6010
  );
2861
6011
  // RFC-024 — finalize any pending/restarting update whose target
2862
6012
  // patch is now reflected in the snapshot. Closes the
@@ -2866,7 +6016,7 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
2866
6016
  // update_id to call ack_config_update(applied) itself. Hub does
2867
6017
  // it on the new child's behalf by content-matching the patch
2868
6018
  // against the reported snapshot.
2869
- finalizePendingMatchingUpdates(input.node_id, input.config_snapshot);
6019
+ finalizePendingMatchingUpdates(input.node_id, trustedSnapshot);
2870
6020
  }
2871
6021
  // RFC-026 §2.5 step 4 — on every register/report_status, opportunistically
2872
6022
  // close out any pending create_node request whose child_name matches
@@ -2926,6 +6076,15 @@ export function finalizePendingMatchingUpdates(
2926
6076
 
2927
6077
  const snapModel: string | null | undefined = snapshot?.model;
2928
6078
  const snapFlags: Record<string, unknown> = (snapshot?.flags && typeof snapshot.flags === "object") ? snapshot.flags : {};
6079
+ // #260 P5 — snapshot.channels is the (sorted, bare-type) channel set
6080
+ // the node currently has forked. agent-node's buildConfigSnapshot
6081
+ // always emits it (even as []), so the "absent field" case here means
6082
+ // an older pre-#260-P5 agent-node — for those, don't finalize a
6083
+ // channels-carrying patch (would false-positive on the OTHER fields).
6084
+ const snapChannelsPresent = Array.isArray(snapshot?.channels);
6085
+ const snapChannels: string[] = snapChannelsPresent
6086
+ ? (snapshot.channels as unknown[]).filter((c): c is string => typeof c === "string")
6087
+ : [];
2929
6088
 
2930
6089
  const finalizedIds: string[] = [];
2931
6090
 
@@ -2950,6 +6109,35 @@ export function finalizePendingMatchingUpdates(
2950
6109
  if (snapFlags[k] !== v) { matches = false; break; }
2951
6110
  }
2952
6111
  }
6112
+ // #260 P5 — channels field content-match. Without this the
6113
+ // node's very first startup report_status matches (patch.flags
6114
+ // is `{}` for a channels-only update, so the flags loop is
6115
+ // trivially satisfied) and hub prematurely marks the update
6116
+ // applied, deleting the pending row + bumping config_revision
6117
+ // BEFORE the doorbell reaches the child. Codex catch on PR #411.
6118
+ //
6119
+ // Set-equality is enough because the patch side comes from
6120
+ // narrowChannelsPatch → already deduped + case-folded + in
6121
+ // EDITABLE_CHANNELS iteration order; the snapshot side comes
6122
+ // from buildConfigSnapshot which mirrors the same shape.
6123
+ if (matches && Array.isArray(patch.channels)) {
6124
+ if (!snapChannelsPresent) {
6125
+ // Pre-#260-P5 agent-node — no channels field in snapshot,
6126
+ // so we can't prove the apply landed. Skip finalize; hub's
6127
+ // F-B reaper still supersedes this eventually.
6128
+ matches = false;
6129
+ } else {
6130
+ const wanted = new Set<string>(patch.channels);
6131
+ const have = new Set<string>(snapChannels);
6132
+ if (wanted.size !== have.size) {
6133
+ matches = false;
6134
+ } else {
6135
+ for (const w of wanted) {
6136
+ if (!have.has(w)) { matches = false; break; }
6137
+ }
6138
+ }
6139
+ }
6140
+ }
2953
6141
  }
2954
6142
 
2955
6143
  if (!matches) continue;