@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.40

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 (117) 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 +84 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-fallback.test.ts +267 -0
  10. package/src/api-nodes-shape.test.ts +107 -13
  11. package/src/auth.ts +93 -14
  12. package/src/avatar-validate.test.ts +163 -0
  13. package/src/avatar-validate.ts +103 -0
  14. package/src/boot-side-effect.test.ts +124 -0
  15. package/src/config-apply-sec1.test.ts +107 -0
  16. package/src/config-apply-validate.test.ts +92 -0
  17. package/src/config-apply-validate.ts +81 -3
  18. package/src/create-node-tool-schema.test.ts +39 -0
  19. package/src/create-node-validate.test.ts +80 -1
  20. package/src/create-node-validate.ts +49 -5
  21. package/src/create-node.ts +51 -1
  22. package/src/dashboard-slash-routing-http.test.ts +103 -0
  23. package/src/db-adapter-guard.test.ts +85 -0
  24. package/src/db-adapter.ts +64 -23
  25. package/src/db-timestamp.test.ts +38 -0
  26. package/src/db-timestamp.ts +15 -0
  27. package/src/db.ts +622 -33
  28. package/src/delivered-without-push.test.ts +140 -0
  29. package/src/desktop-message.test.ts +286 -0
  30. package/src/ext-token-shared.test.ts +60 -0
  31. package/src/ext-validation-regression.test.ts +131 -0
  32. package/src/external-schedule-edits-http.test.ts +316 -0
  33. package/src/external-schedule-edits.ts +314 -0
  34. package/src/file-download-authz-dev-open.test.ts +135 -0
  35. package/src/file-download-authz.test.ts +403 -0
  36. package/src/file-download-date-bucket.test.ts +436 -0
  37. package/src/file-network-scope-dev-open.test.ts +97 -0
  38. package/src/file-network-scope.test.ts +731 -0
  39. package/src/health-redaction.test.ts +637 -0
  40. package/src/inbox-count-sse.test.ts +310 -0
  41. package/src/inbox-count.ts +29 -0
  42. package/src/index.ts +15 -2187
  43. package/src/lifecycle-guard.ts +50 -0
  44. package/src/list-host-supervisors.test.ts +314 -0
  45. package/src/mcp-write-network-resolution.test.ts +429 -0
  46. package/src/network-name-http.test.ts +71 -0
  47. package/src/network-scope.ts +101 -0
  48. package/src/node-attrs-validate.ts +88 -0
  49. package/src/node-attrs.test.ts +213 -0
  50. package/src/node-id-alias.test.ts +147 -0
  51. package/src/node-lifecycle-controllable.test.ts +64 -0
  52. package/src/node-lifecycle-controllable.ts +51 -0
  53. package/src/observer-avatar-http.test.ts +424 -0
  54. package/src/observer-push.test.ts +252 -0
  55. package/src/package-version-consistency.test.ts +25 -0
  56. package/src/peer-reply-atomic.test.ts +455 -0
  57. package/src/peer-reply-capability.test.ts +70 -0
  58. package/src/probe-validate.test.ts +221 -0
  59. package/src/probe-validate.ts +104 -0
  60. package/src/probe.test.ts +80 -0
  61. package/src/probe.ts +200 -0
  62. package/src/push.ts +163 -7
  63. package/src/reply-target-derivation.test.ts +96 -0
  64. package/src/require-explicit-test-db.ts +9 -0
  65. package/src/resolve-port.test.ts +57 -0
  66. package/src/resolve-port.ts +44 -0
  67. package/src/response-charset.test.ts +130 -0
  68. package/src/rest-explicit-columns-http.test.ts +212 -0
  69. package/src/rest-identity.test.ts +129 -0
  70. package/src/rest-identity.ts +100 -0
  71. package/src/rest-projections.ts +67 -0
  72. package/src/rest-write-network-resolution.test.ts +133 -0
  73. package/src/runtime-label.test.ts +22 -0
  74. package/src/runtime-label.ts +21 -0
  75. package/src/scheduled-run-terminal.test.ts +268 -0
  76. package/src/scheduled-tasks-http.test.ts +479 -0
  77. package/src/scheduled-tasks.ts +549 -0
  78. package/src/send-reply-agent-warning.test.ts +202 -0
  79. package/src/send-reply-alias-optional.test.ts +131 -0
  80. package/src/send-reply-attachments.test.ts +437 -0
  81. package/src/server.ts +3591 -0
  82. package/src/shared/external-schedule-contract.ts +60 -0
  83. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  84. package/src/shared/probe-host-allowlist.ts +128 -0
  85. package/src/side-thread-command-transport.test.ts +302 -0
  86. package/src/side-thread-command-transport.ts +318 -0
  87. package/src/side-thread-http-integration.test.ts +229 -0
  88. package/src/side-thread-http.ts +504 -0
  89. package/src/side-thread-production.test.ts +72 -0
  90. package/src/side-thread-production.ts +118 -0
  91. package/src/side-thread.test.ts +1067 -0
  92. package/src/side-thread.ts +2495 -0
  93. package/src/skillhub-http.test.ts +88 -0
  94. package/src/start-node.test.ts +109 -0
  95. package/src/stop-delete-node.test.ts +952 -0
  96. package/src/task-auth-origin-http.test.ts +87 -0
  97. package/src/task-auth-origin.test.ts +24 -0
  98. package/src/task-auth-origin.ts +7 -0
  99. package/src/task-consumption.test.ts +471 -0
  100. package/src/task-diagnostic-http.test.ts +145 -0
  101. package/src/task-diagnostic.test.ts +115 -0
  102. package/src/task-diagnostic.ts +118 -0
  103. package/src/task-idempotency-mcp.test.ts +142 -0
  104. package/src/task-idempotency.test.ts +60 -0
  105. package/src/task-idempotency.ts +75 -0
  106. package/src/task-lifecycle-watcher.test.ts +200 -0
  107. package/src/task-lifecycle-watcher.ts +49 -0
  108. package/src/task-network-resolution-http.test.ts +137 -0
  109. package/src/tasks-pagination-http.test.ts +137 -0
  110. package/src/tools.ts +2952 -191
  111. package/src/update-provider.test.ts +234 -0
  112. package/src/upload-early-reject-guard.test.ts +155 -0
  113. package/src/uploads-http.test.ts +92 -14
  114. package/src/uploads.test.ts +66 -0
  115. package/src/uploads.ts +130 -5
  116. package/src/vault.test.ts +200 -0
  117. package/src/vault.ts +156 -0
package/src/tools.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { parseDbTimestampMs } from "./db-timestamp.js";
2
3
  import { z } from "zod/v4";
3
- import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken } from "./db.js";
4
- import { pushEvent } from "./push.js";
4
+ import { parseAliasFilter } from "./alias-filter.js";
5
+ import { createHash } from "node:crypto";
6
+ import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
7
+ import { getSSEStats, hasSubscribers, hasUserSubscribers, pushEvent, pushNetworkObserverEvent, pushUserEvent } from "./push.js";
8
+ import { assertNodeActive } from "./lifecycle-guard.js";
9
+ import { pendingInboxCount } from "./inbox-count.js";
5
10
  import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
11
+ import { canRestWriteNetwork, getUserNetworkIds, singleNetworkId } from "./network-scope.js";
6
12
  import {
7
13
  buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
8
14
  validateName as validateChildName,
@@ -22,17 +28,39 @@ import {
22
28
  startPendingEnvGcTimer,
23
29
  startSweeperTimer,
24
30
  auditCreateNode,
31
+ auditCreateNodeStrict,
25
32
  resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
26
33
  } from "./create-node.js";
34
+ import {
35
+ vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
36
+ VaultError,
37
+ } from "./vault.js";
38
+ import { stripHostLocalPathsForCrossHostSafe, validateAttachments } from "./uploads.js";
39
+ import {
40
+ validateBaseUrl as _validateBaseUrl,
41
+ SUPPORTED_VENDORS,
42
+ ProbeValidationError,
43
+ } from "./probe-validate.js";
44
+ import {
45
+ putPendingProbeSecret,
46
+ newProbeId,
47
+ finalizeProbeAck,
48
+ startPendingProbeGcTimer,
49
+ startProbeSweeperTimer,
50
+ } from "./probe.js";
27
51
  import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias } from "./rename.js";
28
52
  import {
29
53
  ALLOWED_FLAGS,
30
54
  SECURITY_SENSITIVE_FLAGS,
55
+ EDITABLE_CHANNELS,
31
56
  computeApplyMode,
32
57
  validatePatch,
58
+ narrowChannelsPatch,
33
59
  isAllowedToChangeFlag,
34
60
  } from "./config-apply-validate.js";
35
61
  import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
62
+ import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
63
+ import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
36
64
 
37
65
  function ts(): string {
38
66
  return new Date().toTimeString().slice(0, 8);
@@ -45,7 +73,55 @@ function parseMetaJson(value: unknown): unknown | null {
45
73
 
46
74
  function normalizeMetaJson(meta: unknown): string | null {
47
75
  if (!meta || typeof meta !== "object") return null;
48
- try { return JSON.stringify(meta); } catch { return null; }
76
+ // #222 cross-host safety — strip `path` from meta.attachments[] when
77
+ // `file_id` is also present. Shared helper lives in uploads.ts so the
78
+ // REST handler in index.ts can apply the identical sanitization
79
+ // (otherwise REST and MCP transports would have different meta_json
80
+ // shapes for the same send).
81
+ try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
82
+ }
83
+
84
+ // ── #1281 — 子节点生命周期工具的参数名统一 ────────────────────────────
85
+ //
86
+ // stop_node / start_node / delete_node 历史上用 `child_node_id`(RFC-027),
87
+ // restart_node / update_node_config 用 `node_id`。同一个对象(子节点)两个名字,
88
+ // 调用方按一个工具的习惯给另一个传就吃 -32602。两侧都已是**已发布契约**
89
+ // (start_node 随 #1273 合入),且 hub API 在 RFC-030 混版滚动期老客户端还在
90
+ // 发老名——所以不能硬改名,改为**两个名字都接受**:
91
+ // · canonical = `node_id`(= DB 列 nodes.node_id、= create_node 的输出、
92
+ // restart/update 历史用名;「建完节点→再操作它」用同一个名才是傻瓜式)。
93
+ // · `child_node_id` 作为**兼容 alias 保留** + deprecation 注释;不加运行时
94
+ // warning(老客户端是设计内兼容,不是错误,别刷屏)。
95
+ // create_node 的 `daemon_node_id` 指宿主 daemon,是另一层语义,不在本次统一内。
96
+ //
97
+ // server.tool 收的是 ZodRawShape(裸对象),挂不了 .refine(),所以「至少一个
98
+ // 必填」+「两个都传须相等」在 handler 侧用 resolveNodeIdArg 兜——返回结构化
99
+ // ok:false 反而比 schema 层的 -32602 更友好。两个名字在 schema 层都做成
100
+ // optional + 宽松 .min(1).max(200)(不带 regex),使命名与校验强度同时统一:
101
+ // 对 restart/update 不变(本就宽松),对 stop/start/delete 是放宽(原 regex 会
102
+ // 在 schema 层挡掉 alias/杂串,放宽后落到查表返回 node_not_found,对合法调用
103
+ // 方无影响,只是错误形态更一致)。
104
+ const NODE_ID_ALIAS_FIELDS = {
105
+ node_id: z.string().min(1).max(200).optional()
106
+ .describe("Target child node ID (canonical, #1281). The value create_node returns. Must be in caller's network."),
107
+ child_node_id: z.string().min(1).max(200).optional()
108
+ .describe("DEPRECATED alias for node_id (#1281): kept for back-compat with pre-unification clients; prefer node_id."),
109
+ } as const;
110
+
111
+ /** #1281 — 把 node_id / child_node_id 收敛成单一内部 id。两个都传且不等 ⇒
112
+ * node_id_conflict(挡「node_id 填 A、child_node_id 填 B」的手滑,比单纯二选一强);
113
+ * 都不传 ⇒ node_id_required。纯函数,模块级导出以便单测直接验解析逻辑。 */
114
+ export function resolveNodeIdArg(a: { node_id?: string; child_node_id?: string }):
115
+ { ok: true; node_id: string } | { ok: false; error: string; message: string } {
116
+ const n = a.node_id, c = a.child_node_id;
117
+ if (n && c && n !== c) {
118
+ 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)" };
119
+ }
120
+ const id = n ?? c;
121
+ if (!id) {
122
+ 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)" };
123
+ }
124
+ return { ok: true, node_id: id };
49
125
  }
50
126
 
51
127
  export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null) {
@@ -71,30 +147,213 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
71
147
  }],
72
148
  };
73
149
  };
74
- // If enforceNetworkId is set, override any client-supplied network_id
75
- const getNetworkId = (clientNetId?: string | null) => enforceNetworkId ?? clientNetId ?? null;
150
+ // MCP-side auth context in the shape network-scope.ts helpers take.
151
+ // null = legacy global-token / open-dev mode (unscoped, allow-all).
152
+ const mcpAuthCtx = enforceUserId ? { userId: enforceUserId, networkId: enforceNetworkId ?? null } : null;
153
+
154
+ // If enforceNetworkId is set (ntok_), override any client-supplied
155
+ // network_id. #517: for utok_ with no explicit network_id, fall back to
156
+ // the user's single membership via the SAME singleNetworkId used by REST
157
+ // POST /api/task (network-scope.ts) — utok_ rows carry network_id=null by
158
+ // design, so without this fallback single-network nodes could read
159
+ // everything but write nothing. Multi-network stays null (ambiguous) and
160
+ // canWrite rejects with network_id_required.
161
+ const getNetworkId = (clientNetId?: string | null) => {
162
+ const explicit = enforceNetworkId ?? clientNetId ?? null;
163
+ if (explicit !== null || !enforceUserId) return explicit;
164
+ return singleNetworkId({ networkId: null, networkIds: getUserNetworkIds(enforceUserId) });
165
+ };
76
166
 
77
- // Check write access. For ntok_ the network is enforced by the token.
78
- // For utok_ (no enforced network) we accept the network_id supplied in the
79
- // request and verify the user has a write role on it.
167
+ // Check write access — delegates to the shared canRestWriteNetwork so MCP
168
+ // and REST cannot drift again (#517). isAdmin=false: MCP has no admin
169
+ // bypass today; keep behavior identical to before the extraction.
80
170
  const canWrite = (effectiveNetworkId?: string | null): boolean => {
81
- if (!enforceUserId) return true; // legacy global token mode, allow
82
171
  const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
83
- if (!netId) return false; // no network resolvable
84
- const role = getUserNetworkRole(enforceUserId, netId);
85
- return !!role && role !== "viewer"; // owner/admin/member can write
172
+ return canRestWriteNetwork(mcpAuthCtx, netId, false);
86
173
  };
87
174
 
175
+ // #517: name the REAL cause. The old catch-all blamed permissions for
176
+ // what was actually an unresolvable network, sending operators down the
177
+ // wrong debugging path (roles/membership) for hours.
88
178
  const writeDeniedReply = (effectiveNetworkId?: string | null, action = "write") => {
89
179
  const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
90
- const message = !netId
91
- ? "network_id required (utok current_network is null; pass explicit network_id from /api/auth/me networks[0].network_id)"
92
- : action === "send_task"
180
+ let error: string;
181
+ let message: string;
182
+ if (!netId) {
183
+ const memberships = enforceUserId ? getUserNetworkIds(enforceUserId) : [];
184
+ error = "network_id_required";
185
+ message = memberships.length === 0
186
+ ? "user token has no network memberships; join or create a network first"
187
+ : `user token spans ${memberships.length} networks; pass network_id explicitly (see /api/auth/me networks[].network_id)`;
188
+ } else if (enforceUserId && !getUserNetworkRole(enforceUserId, netId)) {
189
+ error = "access_denied";
190
+ message = "access denied to requested network (not a member)";
191
+ } else {
192
+ error = "permission_denied";
193
+ message = action === "send_task"
93
194
  ? "Viewer role cannot send tasks"
94
195
  : "Viewer role cannot write to this network";
95
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied", message }) }] };
196
+ }
197
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error, message }) }] };
96
198
  };
97
199
 
200
+ const skillHubReply = (value: Record<string, unknown>) => ({
201
+ content: [{ type: "text" as const, text: JSON.stringify(value) }],
202
+ });
203
+
204
+ // SkillHub is a network registry, not a public filesystem. Content is an
205
+ // immutable SKILL.md snapshot; identity comes only from the authenticated
206
+ // MCP principal (never from request fields).
207
+ server.tool(
208
+ "submit_skill",
209
+ "Submit an immutable SKILL.md version to the caller's network SkillHub. Node identity is token-bound. New submissions require review.",
210
+ {
211
+ slug: z.string().min(2).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
212
+ name: z.string().min(1).max(120),
213
+ description: z.string().max(1000).optional(),
214
+ version: z.string().min(1).max(40).regex(/^[0-9A-Za-z]+(?:[._-][0-9A-Za-z]+)*$/),
215
+ content: z.string().min(1).max(128 * 1024).refine(value => !value.includes("\0"), "content must not contain NUL bytes"),
216
+ network_id: z.string().max(200).optional(),
217
+ },
218
+ async ({ slug, name, description, version, content, network_id: clientNetId }) => {
219
+ const effectiveNetId = getNetworkId(clientNetId);
220
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
221
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
222
+ const sourceType = callerTokenIsNetwork ? "node" : "user";
223
+ if (sourceType === "node" && !callerAlias) return skillHubReply({ ok: false, error: "node_identity_unbound" });
224
+ const contentHash = createHash("sha256").update(content, "utf8").digest("hex");
225
+ const existing = db.get<any>(
226
+ `SELECT skill_id, content_hash, status FROM skillhub_skills
227
+ WHERE network_id = ?1 AND slug = ?2 AND version = ?3`,
228
+ effectiveNetId, slug, version,
229
+ );
230
+ if (existing) {
231
+ if (existing.content_hash === contentHash) {
232
+ return skillHubReply({ ok: true, idempotent: true, skill_id: existing.skill_id, status: existing.status });
233
+ }
234
+ return skillHubReply({ ok: false, error: "skill_version_conflict", hint: "publish changed content under a new version" });
235
+ }
236
+ const skillId = `skill_${uuidv4()}`;
237
+ try {
238
+ db.run(
239
+ `INSERT INTO skillhub_skills
240
+ (skill_id, network_id, slug, name, description, version, content, content_hash, status, source_type, source_alias, created_by_user)
241
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
242
+ [skillId, effectiveNetId, slug, name.trim(), description?.trim() || "", version, content, contentHash, sourceType, callerAlias, enforceUserId || null],
243
+ );
244
+ } catch (error: any) {
245
+ // Two submitters may pass the preflight SELECT together. Resolve the
246
+ // UNIQUE race into the same deterministic idempotent/conflict contract
247
+ // instead of leaking a storage exception as HTTP/MCP 500.
248
+ if (!/unique|duplicate key/i.test(error?.message || "")) throw error;
249
+ const winner = db.get<any>(
250
+ `SELECT skill_id, content_hash, status FROM skillhub_skills
251
+ WHERE network_id = ?1 AND slug = ?2 AND version = ?3`,
252
+ effectiveNetId, slug, version,
253
+ );
254
+ if (!winner) throw error;
255
+ if (winner.content_hash === contentHash) {
256
+ return skillHubReply({ ok: true, idempotent: true, skill_id: winner.skill_id, status: winner.status });
257
+ }
258
+ return skillHubReply({ ok: false, error: "skill_version_conflict", hint: "publish changed content under a new version" });
259
+ }
260
+ db.run(
261
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
262
+ VALUES (?1, ?2, 'skill_submit', 'skill', ?3, ?4, ?5)`,
263
+ [enforceUserId || null, callerAlias || null, skillId, JSON.stringify({ slug, version, source_type: sourceType }), effectiveNetId],
264
+ );
265
+ return skillHubReply({ ok: true, skill_id: skillId, status: "pending", source_type: sourceType, source_alias: callerAlias });
266
+ },
267
+ );
268
+
269
+ server.tool(
270
+ "list_skills",
271
+ "List published skills in a network. Owners/admins may include pending review items.",
272
+ {
273
+ network_id: z.string().max(200).optional(),
274
+ include_pending: z.boolean().optional(),
275
+ query: z.string().max(120).optional(),
276
+ limit: z.number().int().min(1).max(200).optional(),
277
+ },
278
+ async ({ network_id: clientNetId, include_pending, query, limit }) => {
279
+ const effectiveNetId = getNetworkId(clientNetId);
280
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "read");
281
+ const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
282
+ if (enforceUserId && !role) return writeDeniedReply(effectiveNetId, "read");
283
+ // An ntok_ belongs to a node even when it was minted by the network
284
+ // owner. Never inherit the owner's review power through that token.
285
+ const reviewer = !callerTokenIsNetwork && (role === "owner" || role === "admin");
286
+ const showPending = !!include_pending && reviewer;
287
+ const params: unknown[] = [effectiveNetId];
288
+ let sql = `SELECT skill_id, slug, name, description, version, status, source_type, source_alias,
289
+ created_at, updated_at, reviewed_at, review_note
290
+ FROM skillhub_skills WHERE network_id = ?1`;
291
+ if (!showPending) sql += ` AND status = 'published'`;
292
+ if (query?.trim()) {
293
+ params.push(`%${query.trim()}%`);
294
+ sql += ` AND (slug LIKE ?${params.length} OR name LIKE ?${params.length} OR description LIKE ?${params.length})`;
295
+ }
296
+ sql += ` ORDER BY updated_at DESC LIMIT ${limit ?? 100}`;
297
+ return skillHubReply({ ok: true, reviewer, skills: db.all(sql, ...params) });
298
+ },
299
+ );
300
+
301
+ server.tool(
302
+ "get_skill",
303
+ "Read one SkillHub SKILL.md. Pending content is visible only to owners/admins.",
304
+ { skill_id: z.string().regex(/^skill_[A-Za-z0-9_-]+$/).max(200), network_id: z.string().max(200).optional() },
305
+ async ({ skill_id, network_id: clientNetId }) => {
306
+ const effectiveNetId = getNetworkId(clientNetId);
307
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "read");
308
+ const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
309
+ if (enforceUserId && !role) return writeDeniedReply(effectiveNetId, "read");
310
+ const row = db.get<any>(
311
+ `SELECT skill_id, slug, name, description, version, content, status,
312
+ source_type, source_alias, created_at, updated_at, reviewed_at, review_note
313
+ FROM skillhub_skills WHERE skill_id = ?1 AND network_id = ?2`,
314
+ skill_id, effectiveNetId,
315
+ );
316
+ if (!row) return skillHubReply({ ok: false, error: "skill_not_found" });
317
+ const reviewer = !callerTokenIsNetwork && (role === "owner" || role === "admin");
318
+ if (row.status !== "published" && !reviewer) {
319
+ return skillHubReply({ ok: false, error: "skill_not_found" });
320
+ }
321
+ return skillHubReply({ ok: true, skill: row });
322
+ },
323
+ );
324
+
325
+ server.tool(
326
+ "review_skill",
327
+ "Publish or reject a pending SkillHub submission. Network owner/admin only.",
328
+ {
329
+ skill_id: z.string().regex(/^skill_[A-Za-z0-9_-]+$/).max(200),
330
+ decision: z.enum(["published", "rejected"]),
331
+ note: z.string().max(1000).optional(),
332
+ network_id: z.string().max(200).optional(),
333
+ },
334
+ async ({ skill_id, decision, note, network_id: clientNetId }) => {
335
+ const effectiveNetId = getNetworkId(clientNetId);
336
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
337
+ const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
338
+ if (callerTokenIsNetwork || (role !== "owner" && role !== "admin")) return skillHubReply({ ok: false, error: "skill_review_admin_required" });
339
+ const row = db.get<any>(`SELECT status FROM skillhub_skills WHERE skill_id = ?1 AND network_id = ?2`, skill_id, effectiveNetId);
340
+ if (!row) return skillHubReply({ ok: false, error: "skill_not_found" });
341
+ if (row.status !== "pending") return skillHubReply({ ok: false, error: "skill_not_pending", status: row.status });
342
+ const updated = db.run(
343
+ `UPDATE skillhub_skills SET status = ?1, review_note = ?2, reviewed_by_user = ?3,
344
+ reviewed_at = datetime('now'), updated_at = datetime('now') WHERE skill_id = ?4 AND network_id = ?5 AND status = 'pending'`,
345
+ [decision, note?.trim() || null, enforceUserId || null, skill_id, effectiveNetId],
346
+ );
347
+ if (updated.changes !== 1) return skillHubReply({ ok: false, error: "skill_not_pending" });
348
+ db.run(
349
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
350
+ VALUES (?1, ?2, 'skill_review', 'skill', ?3, ?4, ?5)`,
351
+ [enforceUserId || null, callerAlias || null, skill_id, JSON.stringify({ decision }), effectiveNetId],
352
+ );
353
+ return skillHubReply({ ok: true, skill_id, status: decision });
354
+ },
355
+ );
356
+
98
357
  const addScope = (sql: string, params: any[], networkId?: string | null, column = "network_id"): string => {
99
358
  if (!networkId) return sql;
100
359
  sql += ` AND ${column} = ?${params.length + 1}`;
@@ -104,13 +363,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
104
363
 
105
364
  type ReadScope = { networkId?: string | null; networkIds?: string[] | null; denied?: string };
106
365
 
107
- const getReadableNetworkIds = (): string[] => {
108
- if (!enforceUserId) return [];
109
- return db.all<{ network_id: string }>(
110
- "SELECT network_id FROM network_members WHERE user_id = ?1",
111
- enforceUserId
112
- ).map((row) => row.network_id);
113
- };
366
+ // Delegates to the shared membership query (network-scope.ts) — was a
367
+ // byte-for-byte duplicate of getUserNetworkIds before #517.
368
+ const getReadableNetworkIds = (): string[] =>
369
+ enforceUserId ? getUserNetworkIds(enforceUserId) : [];
114
370
 
115
371
  const resolveReadScope = (clientNetId?: string | null): ReadScope => {
116
372
  if (!enforceUserId) return { networkId: clientNetId ?? null, networkIds: null };
@@ -125,6 +381,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
125
381
  return { networkId: null, networkIds: getReadableNetworkIds() };
126
382
  };
127
383
 
384
+ // RFC-027 §2.3 race-free invariant — assertNodeActive lives in
385
+ // server/src/lifecycle-guard.ts so REST handlers in server/src/index.ts
386
+ // can use the SAME code path. PR1.1 had it inline here; PR1.2a
387
+ // (#346 review catch) extracted because the closure scope made it
388
+ // unreachable from REST and left the §2.3 race open on dashboard
389
+ // Dispatch (POST /api/task + /api/broadcast). Per
390
+ // 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.
391
+
128
392
  const addReadScope = (sql: string, params: any[], scope: ReadScope, column = "network_id"): string => {
129
393
  if (scope.networkId) {
130
394
  sql += ` AND ${column} = ?${params.length + 1}`;
@@ -145,6 +409,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
145
409
  | { state: "offline"; alias: string; session: any; message: string }
146
410
  | { state: "not_found"; alias: string; message: string };
147
411
 
412
+ // Only call after the authenticated, network-scoped lookup resolved a
413
+ // concrete target. Error paths intentionally omit this identity object.
414
+ const actualTo = (target: Exclude<DeliveryTarget, { state: "not_found" }>, networkId?: string | null) => ({
415
+ alias: target.alias,
416
+ to_node_id: target.session?.node_id ?? null,
417
+ network_id: networkId ?? null,
418
+ });
419
+
148
420
  const scopedSessionStatus = (alias: string, networkId?: string | null) => {
149
421
  const params: any[] = [alias];
150
422
  let sql = "SELECT status, updated_at, last_seen_at, node_id FROM sessions WHERE alias = ?1";
@@ -265,6 +537,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
265
537
  uptime_seconds: z.number().nullable().optional(),
266
538
  in_flight_count: z.number().nullable().optional(),
267
539
  }).optional().describe("Per-agent process telemetry reported by agent-node"),
540
+ external_schedules: z.object({
541
+ observed_at: z.string().datetime({ offset: true }).max(64),
542
+ schedules: z.array(z.object({
543
+ id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/),
544
+ name: z.string().min(1).max(200),
545
+ kind: z.enum(["cron", "systemd", "tmux", "playwright", "custom"]),
546
+ frequency: z.string().min(1).max(120),
547
+ last_run_at: z.string().datetime({ offset: true }).max(64).nullable(),
548
+ last_status: z.enum(["success", "failed", "running", "unknown"]),
549
+ last_error: z.string().max(500).nullable(),
550
+ next_run_at: z.string().datetime({ offset: true }).max(64).nullable(),
551
+ log_ref: z.string().min(1).max(255).nullable(),
552
+ enabled: z.boolean(),
553
+ // RFC-036 — only agent-node managed cron markers advertise write
554
+ // capability. Legacy/other kinds omit these fields and stay read-only.
555
+ editable: z.boolean().optional(),
556
+ revision: z.number().int().min(0).optional(),
557
+ }).strict()).max(64),
558
+ error: z.enum(["invalid_manifest", "unsafe_manifest", "read_failed"]).optional(),
559
+ }).strict().optional().describe("Bounded node-reported external schedule snapshot; never includes host paths or commands"),
268
560
  // RFC-024 B6 — masked snapshot of the node's effective config
269
561
  // (model + 6 dashboard-editable flags). Secrets ARE NOT in this
270
562
  // shape (env._envRef stays on host); the dashboard reads this
@@ -278,9 +570,68 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
278
570
  flags: z.record(z.unknown()).optional(),
279
571
  config_revision: z.number().int().min(0).optional(),
280
572
  config_update_capable: z.boolean().optional(),
573
+ peer_reply_inbox_capable: z.literal(true).optional(),
574
+ side_thread_capability: z.object({
575
+ supported: z.boolean(),
576
+ runtime: z.string().max(64),
577
+ runtimeVersion: z.string().max(64),
578
+ topology: z.string().max(64),
579
+ evidenceRevision: z.string().max(100),
580
+ mode: z.literal("native-exact-fork").optional(),
581
+ exactBoundary: z.object({ through: z.boolean(), before: z.boolean() }).strict().optional(),
582
+ reason: z.enum(["runtime", "version", "topology", "experimental-api", "exact-boundary"]).optional(),
583
+ }).strict().optional(),
584
+ // RFC-026 P2 / #338 — daemon role surfaced for hub /api/nodes
585
+ // discovery (#337 extracts this field). "host_supervisor" =
586
+ // anet daemon. Default-stripping zod would drop this otherwise.
587
+ role: z.string().max(64).optional().nullable(),
588
+ // RFC-026 §9.3 / #338 PR3 — daemon self-declare nested under
589
+ // `daemon_capabilities` (canonical shape per existing hub reads
590
+ // at tools.ts:2010/2075 — PR1/PR2 placed these top-level, hub
591
+ // never saw them, max_concurrent_children stayed default + the
592
+ // allowlists stayed unenforced. PR3 nit ① per 通信龙).
593
+ // Soft caps avoid abuse via attacker daemon.
594
+ daemon_capabilities: z.object({
595
+ runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
596
+ allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
597
+ max_concurrent_children: z.number().int().min(1).max(1000).optional(),
598
+ // #1353 —— daemon **当下**能不能创建节点。与 runtimes_supported 不是一回事:
599
+ // 后者是声明(我支持哪些 runtime),这个是实际能力。一个 daemon 可以声明支持
600
+ // 三种 runtime,同时因为 ANET_BIN pin 解析不出来而一种也创建不了。
601
+ //
602
+ // 🔴 为什么 hub 需要看见它:pin 只存在于 /etc/anet-daemon/path.conf 或
603
+ // 显式开启的环境变量里,**重启不带环境就会丢**。丢了之后 daemon 照常注册、
604
+ // 在线、收 doorbell,hub 返回 ok:true + request_id —— 而节点永远不出现。
605
+ // 在此之前 hub 完全看不出区别,Dashboard 的「选服务器」还会把它列为可选。
606
+ can_create_nodes: z.boolean().optional(),
607
+ // 只收**代码**,不收原始报错文本 —— 上游 unsafePathHelp() 的消息里带完整机器路径,
608
+ // 而这个字段会走到 Dashboard。enum 而不是 string:一个自由文本字段等于给
609
+ // 「把路径塞进来」留了口子。
610
+ // #1353 —— 四类失败,修法完全不同:
611
+ // identity 重装 anet / unset ANET_BIN_ABS
612
+ // source 写 /etc/anet-daemon/path.conf(**要 sudo**)
613
+ // shape 换成 realpath
614
+ // permission **一行 chmod go-w**
615
+ // 混成一条会让人修错方向 —— 2026-08-28 我一天里撞到其中两类,看到的是同一条错误。
616
+ //
617
+ // 🔴 `anet_bin_pin_unresolved` **必须留着**:已经在跑的 agent-node@2.5.0-preview.40
618
+ // 发的就是这个值。zod 对象里任何一个字段验证失败会让**整个 report_status 被拒**,
619
+ // 不只是丢掉这一格 —— 那会让所有 .40 daemon 在 hub 上变成失联。
620
+ // 删一个 enum 值的代价,远大于多留一个。
621
+ // 🔴 仍然是 enum 不是 string:自由文本字段就是给机器路径留的口子,
622
+ // 而上游 e.message 里带的正是完整路径。
623
+ create_nodes_blocked_reason: z.enum([
624
+ "anet_bin_pin_unresolved", // 兼容:.40 及更早
625
+ "anet_bin_identity",
626
+ "anet_bin_source",
627
+ "anet_bin_shape",
628
+ "anet_bin_permission",
629
+ "anet_bin_unknown",
630
+ ]).optional(),
631
+ }).optional(),
281
632
  }).optional().describe("RFC-024 — masked node config snapshot"),
282
633
  },
283
- 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 }) => {
634
+ 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 }) => {
284
635
  const effectiveNetId = getNetworkId(netId);
285
636
  const sessionNetId = effectiveNetId ?? "default";
286
637
  if (!callerTokenIsNetwork || !enforceNetworkId) {
@@ -318,6 +669,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
318
669
  };
319
670
  }
320
671
  }
672
+ // #203 identity guard — network tokens must not silently rebind their
673
+ // own name via report_status. Without this, a runtime whose ALIAS
674
+ // drifted (env leak / wrong --alias / CurrentAliasResolver seeded from
675
+ // the wrong node_id) could rewrite api_tokens.name and cause every
676
+ // subsequent send_task from this token to be attributed to the drifted
677
+ // alias — the observed #203 symptom (grokB's send arriving as
678
+ // from=grokA). Only the legit rename path (rename.ts) may cross the
679
+ // token→alias binding. Symmetric to fromIdentityMismatchReply on the
680
+ // send side (test198).
681
+ if (callerTokenIsNetwork && callerAlias && !canonical.renamed && effectiveAlias !== callerAlias) {
682
+ return {
683
+ content: [{
684
+ type: "text" as const,
685
+ text: JSON.stringify({
686
+ ok: false,
687
+ error: "alias_identity_mismatch",
688
+ message: "report_status alias does not match the token-bound node alias; use anet node rename to change identity",
689
+ token_alias: callerAlias,
690
+ reported_alias: effectiveAlias,
691
+ }),
692
+ }],
693
+ };
694
+ }
321
695
  console.log(`[${ts()}] ${effectiveAlias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
322
696
  if (callerTokenIsNetwork && callerTokenId) {
323
697
  try {
@@ -342,6 +716,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
342
716
  const processCpuPct = typeof proc?.cpu_pct === "number" ? proc.cpu_pct : null;
343
717
  const processUptimeSeconds = typeof proc?.uptime_seconds === "number" ? proc.uptime_seconds : null;
344
718
  const processInFlightCount = typeof proc?.in_flight_count === "number" ? proc.in_flight_count : null;
719
+ const externalSchedulesJson = externalSchedules === undefined ? null : JSON.stringify(externalSchedules);
720
+ const trustedCurrentSnapshot = node_id
721
+ ? trustedConfigSnapshotForNode(cfgSnap ?? null, callerTokenId ?? null, node_id)
722
+ : null;
723
+ const peerReplyInboxCapable = !!(
724
+ trustedCurrentSnapshot
725
+ && typeof trustedCurrentSnapshot === "object"
726
+ && (trustedCurrentSnapshot as Record<string, unknown>).peer_reply_inbox_capable === true
727
+ );
345
728
  const statusHostTelemetry = host ? {
346
729
  hostname: hostHostname,
347
730
  ip: hostIp,
@@ -366,8 +749,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
366
749
  // Only delete same-alias sessions within the same network
367
750
  db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [effectiveAlias, resume_id, sessionNetId]);
368
751
  db.run(
369
- `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)
370
- 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'))
752
+ `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)
753
+ 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'))
371
754
  ON CONFLICT(resume_id) DO UPDATE SET
372
755
  alias = COALESCE(?2, sessions.alias), tmux_name = COALESCE(?3, sessions.tmux_name),
373
756
  server = COALESCE(?4, sessions.server), ip = COALESCE(?5, sessions.ip),
@@ -392,8 +775,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
392
775
  process_cpu_pct = COALESCE(?31, sessions.process_cpu_pct),
393
776
  process_uptime_seconds = COALESCE(?32, sessions.process_uptime_seconds),
394
777
  process_in_flight_count = COALESCE(?33, sessions.process_in_flight_count),
778
+ external_schedules = COALESCE(?34, sessions.external_schedules),
779
+ peer_reply_inbox_capable = ?35,
395
780
  last_seen_at = datetime('now'), updated_at = datetime('now')`,
396
- [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]
781
+ [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]
397
782
  );
398
783
  if (host || proc) {
399
784
  db.run(
@@ -443,6 +828,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
443
828
  upsertNodeWithSec1Guard({
444
829
  node_id,
445
830
  callerNetworkId: effectiveNetId ?? null,
831
+ callerUserId: enforceUserId ?? null,
832
+ callerTokenId: callerTokenId ?? null,
446
833
  node_name: nn || effectiveAlias,
447
834
  alias: effectiveAlias,
448
835
  runtime: nodeRuntime,
@@ -534,6 +921,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
534
921
  } else {
535
922
  updatedTaskId = task;
536
923
  }
924
+ if (updatedTaskId) syncScheduledRunForTask(updatedTaskId, effectiveNetId);
537
925
  });
538
926
  // Log event after transaction
539
927
  if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
@@ -594,7 +982,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
594
982
  const rows0 = db.get<{ cnt: number }>(countSql, ...countParams);
595
983
  console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
596
984
  const rowsParams: any[] = [alias];
597
- let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json
985
+ let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json,
986
+ CASE WHEN type = 'task' THEN COALESCE(task_id, id) ELSE task_id END AS task_id
598
987
  FROM inbox WHERE session_name = ?1 AND acked = 0`;
599
988
  rowsSql = addReadScope(rowsSql, rowsParams, readScope);
600
989
  rowsSql += ` ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
@@ -618,13 +1007,32 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
618
1007
  alias: z.string().min(1).max(200).describe("Session alias"),
619
1008
  message_id: z.string().min(1).max(200),
620
1009
  response: z.string().max(10000).optional(),
1010
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
621
1011
  },
622
- async ({ alias, message_id, response }) => {
623
- const effectiveNetId = getNetworkId(null);
1012
+ async ({ alias, message_id, response, network_id: netId }) => {
1013
+ const effectiveNetId = getNetworkId(netId);
624
1014
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
625
1015
  console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
626
- const ackParams: any[] = [message_id, alias];
627
- let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2";
1016
+ // New task consumers ACK by the logical tasks.task_id exposed by
1017
+ // get_inbox. Keep exact inbox.id support for legacy consumers and for
1018
+ // non-task messages. Restrict the lookup to pending rows: after a retry
1019
+ // the original row can have id == task_id but is already ACKed, while
1020
+ // the current row has a fresh transport id and the same logical task_id.
1021
+ const inboxTaskParams: any[] = [message_id, alias];
1022
+ let inboxTaskSql = `SELECT id, type, COALESCE(task_id, id) AS task_id
1023
+ FROM inbox
1024
+ WHERE session_name = ?2 AND acked = 0
1025
+ AND (id = ?1 OR (type = 'task' AND task_id = ?1))`;
1026
+ inboxTaskSql = addScope(inboxTaskSql, inboxTaskParams, effectiveNetId);
1027
+ inboxTaskSql += " ORDER BY created_at DESC LIMIT 1";
1028
+ const inboxTask = db.get<{ id: string; type: string; task_id: string }>(inboxTaskSql, ...inboxTaskParams);
1029
+ if (!inboxTask) {
1030
+ return {
1031
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "message not found or already acknowledged" }) }],
1032
+ };
1033
+ }
1034
+ const ackParams: any[] = [inboxTask.id, alias];
1035
+ let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2 AND acked = 0";
628
1036
  ackSql = addScope(ackSql, ackParams, effectiveNetId);
629
1037
  const result = db.run(ackSql, ackParams);
630
1038
  if (result.changes === 0) {
@@ -634,11 +1042,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
634
1042
  }
635
1043
  // V2: sync tasks table — ack_inbox means delivered→acked
636
1044
  try {
637
- const taskParams: any[] = [message_id];
1045
+ if (inboxTask?.type !== "task") {
1046
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }] };
1047
+ }
1048
+ const taskParams: any[] = [inboxTask.task_id];
638
1049
  let taskSql = "UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'";
639
1050
  taskSql = addScope(taskSql, taskParams, effectiveNetId);
640
1051
  const ackResult = db.run(taskSql, taskParams);
641
- if (ackResult.changes > 0) logTaskEvent(message_id, "delivered", "acked", alias);
1052
+ if (ackResult.changes > 0) logTaskEvent(inboxTask.task_id, "delivered", "acked", alias);
642
1053
  } catch {}
643
1054
  return {
644
1055
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }],
@@ -646,22 +1057,208 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
646
1057
  }
647
1058
  );
648
1059
 
1060
+ // #520 — two monotonic runtime-evidence levels for exact tasks.
1061
+ //
1062
+ // runtime_submitted_at means agent-node handed the body to the vendor
1063
+ // runtime. consumed_at is stronger: an attributable turn-start or first
1064
+ // activity event came back. Merely fetching/acking an inbox row sets neither.
1065
+ // Node identity comes exclusively from the ntok; callers cannot self-report
1066
+ // an alias or node_id. A consumed mark also fills runtime_submitted_at because
1067
+ // that stronger fact logically implies submission.
1068
+ const markTaskRuntimeEvidence = (
1069
+ taskIds: string[],
1070
+ level: "submitted" | "consumed",
1071
+ taskContexts: Array<{ task_id: string; thread_id: string; turn_id: string }> = [],
1072
+ ) => {
1073
+ const task_ids = taskIds;
1074
+ if (!callerTokenIsNetwork || !callerAlias || !enforceNetworkId) {
1075
+ return {
1076
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_required" }) }],
1077
+ };
1078
+ }
1079
+ // PgAdapter.transaction currently opens a fresh subprocess/connection
1080
+ // per statement. The all-or-nothing batch promise cannot be made there;
1081
+ // refuse evidence rather than publish a partially stamped batch.
1082
+ if (db.dialect !== "sqlite") {
1083
+ return {
1084
+ content: [{ type: "text" as const, text: JSON.stringify({
1085
+ ok: false,
1086
+ error: "task_runtime_evidence_backend_unsupported",
1087
+ }) }],
1088
+ };
1089
+ }
1090
+
1091
+ const uniqueTaskIds = [...new Set(task_ids)];
1092
+ if (uniqueTaskIds.length !== task_ids.length) {
1093
+ return {
1094
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_id" }) }],
1095
+ };
1096
+ }
1097
+
1098
+ if (level !== "consumed" && taskContexts.length > 0) {
1099
+ return {
1100
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "runtime_context_requires_consumed" }) }],
1101
+ };
1102
+ }
1103
+ const contextByTaskId = new Map(taskContexts.map((context) => [context.task_id, context]));
1104
+ if (contextByTaskId.size !== taskContexts.length) {
1105
+ return {
1106
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_context" }) }],
1107
+ };
1108
+ }
1109
+ const foreignContext = taskContexts.find((context) => !uniqueTaskIds.includes(context.task_id));
1110
+ if (foreignContext) {
1111
+ return {
1112
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task_context_not_requested", task_id: foreignContext.task_id }) }],
1113
+ };
1114
+ }
1115
+
1116
+ const placeholders = uniqueTaskIds.map((_, i) => `?${i + 2}`).join(", ");
1117
+
1118
+ // Keep ownership preflight and every stamp in one SQLite transaction.
1119
+ // This closes the cross-process preflight→UPDATE race: no other writer
1120
+ // can reassign a task between the identity decision and the write.
1121
+ const outcome = db.transaction(() => {
1122
+ const canonicalCaller = resolveCanonicalAlias(enforceNetworkId, callerAlias).alias;
1123
+ const callerSession = db.get<{ node_id: string | null }>(
1124
+ `SELECT node_id FROM sessions
1125
+ WHERE network_id = ?1 AND alias = ?2
1126
+ ORDER BY updated_at DESC LIMIT 1`,
1127
+ enforceNetworkId,
1128
+ canonicalCaller,
1129
+ );
1130
+ const rows = db.all<{ task_id: string; to_node_id: string | null; to_name: string; thread_id: string | null; turn_id: string | null }>(
1131
+ `SELECT task_id, to_node_id, to_name, thread_id, turn_id FROM tasks
1132
+ WHERE network_id = ?1 AND task_id IN (${placeholders})`,
1133
+ enforceNetworkId,
1134
+ ...uniqueTaskIds,
1135
+ );
1136
+ const owned = new Map(rows.map((row) => [row.task_id, row]));
1137
+ const rejectedTaskId = uniqueTaskIds.find((taskId) => {
1138
+ const row = owned.get(taskId);
1139
+ if (!row) return true;
1140
+ // Prefer immutable node identity whenever the task has one. Direct
1141
+ // and legacy token-bound sessions legitimately have NULL node_id;
1142
+ // only that shape may fall back to the canonical token alias.
1143
+ if (row.to_node_id) return row.to_node_id !== callerSession?.node_id;
1144
+ return resolveCanonicalAlias(enforceNetworkId, row.to_name).alias !== canonicalCaller;
1145
+ });
1146
+ if (rejectedTaskId) {
1147
+ return { ok: false as const, error: "task_not_owned", task_id: rejectedTaskId };
1148
+ }
1149
+ const conflictingContext = uniqueTaskIds.find((taskId) => {
1150
+ const context = contextByTaskId.get(taskId);
1151
+ if (!context) return false;
1152
+ const row = owned.get(taskId)!;
1153
+ return (row.thread_id !== null && row.thread_id !== context.thread_id)
1154
+ || (row.turn_id !== null && row.turn_id !== context.turn_id);
1155
+ });
1156
+ if (conflictingContext) {
1157
+ return { ok: false as const, error: "task_runtime_context_conflict", task_id: conflictingContext };
1158
+ }
1159
+
1160
+ for (const taskId of uniqueTaskIds) {
1161
+ const row = owned.get(taskId)!;
1162
+ const ownershipSql = row.to_node_id
1163
+ ? "to_node_id = ?3"
1164
+ : "to_node_id IS NULL AND to_name = ?3";
1165
+ const ownerValue = row.to_node_id ?? row.to_name;
1166
+ let updateResult;
1167
+ if (level === "consumed") {
1168
+ const context = contextByTaskId.get(taskId);
1169
+ updateResult = db.run(
1170
+ `UPDATE tasks SET
1171
+ runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now')),
1172
+ consumed_at = COALESCE(consumed_at, datetime('now')),
1173
+ thread_id = COALESCE(thread_id, ?4),
1174
+ turn_id = COALESCE(turn_id, ?5)
1175
+ WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
1176
+ [enforceNetworkId, taskId, ownerValue, context?.thread_id ?? null, context?.turn_id ?? null],
1177
+ );
1178
+ } else {
1179
+ updateResult = db.run(
1180
+ `UPDATE tasks SET runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now'))
1181
+ WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
1182
+ [enforceNetworkId, taskId, ownerValue],
1183
+ );
1184
+ }
1185
+ if (updateResult.changes !== 1) {
1186
+ // A zero-row write after a successful preflight is an invariant
1187
+ // failure, never a successful evidence report.
1188
+ throw new Error(`task_runtime_evidence_write_race:${taskId}`);
1189
+ }
1190
+ }
1191
+ const evidenceRows = db.all<{
1192
+ task_id: string;
1193
+ runtime_submitted_at: string;
1194
+ consumed_at: string | null;
1195
+ thread_id: string | null;
1196
+ turn_id: string | null;
1197
+ }>(
1198
+ `SELECT task_id, runtime_submitted_at, consumed_at, thread_id, turn_id FROM tasks
1199
+ WHERE network_id = ?1 AND task_id IN (${placeholders})`,
1200
+ enforceNetworkId,
1201
+ ...uniqueTaskIds,
1202
+ );
1203
+ return { ok: true as const, tasks: evidenceRows };
1204
+ });
1205
+
1206
+ return {
1207
+ content: [{
1208
+ type: "text" as const,
1209
+ text: JSON.stringify(outcome),
1210
+ }],
1211
+ };
1212
+ };
1213
+
1214
+ const taskRuntimeEvidenceSchema = {
1215
+ task_ids: z.array(z.string().min(1).max(200)).min(1).max(100),
1216
+ task_contexts: z.array(z.object({
1217
+ task_id: z.string().min(1).max(200),
1218
+ thread_id: z.string().min(1).max(500),
1219
+ turn_id: z.string().min(1).max(500),
1220
+ }).strict()).max(100).optional(),
1221
+ };
1222
+
1223
+ server.tool(
1224
+ "mark_tasks_runtime_submitted",
1225
+ "Internal agent-node signal: exact task bodies were submitted to this token-bound node's vendor runtime.",
1226
+ taskRuntimeEvidenceSchema,
1227
+ async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "submitted", task_contexts),
1228
+ );
1229
+
1230
+ server.tool(
1231
+ "mark_tasks_consumed",
1232
+ "Internal agent-node signal: exact tasks produced attributable turn-start/activity evidence in this token-bound node's runtime.",
1233
+ taskRuntimeEvidenceSchema,
1234
+ async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "consumed", task_contexts),
1235
+ );
1236
+
649
1237
  // ═══════════════════════════════════════════
650
1238
  // Hub Tools (5)
651
1239
  // ═══════════════════════════════════════════
652
1240
 
653
1241
  server.tool(
654
1242
  "get_all_status",
655
- "Get status of all sessions. Hub uses this for the patrol loop.",
1243
+ "Get status of all sessions. Hub uses this for the patrol loop. " +
1244
+ "Pass filter_alias (comma-separated) when you only care about specific " +
1245
+ "nodes — the unfiltered result is one row per session with 31 columns and " +
1246
+ "is large enough on a real fleet that callers cannot read it.",
656
1247
  {
657
1248
  filter_status: z.string().max(50).optional(),
658
1249
  filter_server: z.string().max(200).optional(),
1250
+ // 2026-08-17: on a 222-session hub the unfiltered response is ~259 KB, past
1251
+ // what an MCP client can take in one result — so the caller that wanted the
1252
+ // status of THREE nodes could not get it from this tool at all. The patrol
1253
+ // loop still wants everything, hence optional rather than required.
1254
+ filter_alias: z.string().max(2000).optional()
1255
+ .describe("One alias, or several separated by commas. Exact matches only."),
659
1256
  network_id: z.string().max(200).optional().describe("Filter by network"),
660
1257
  },
661
- async ({ filter_status, filter_server, network_id: netId }) => {
1258
+ async ({ filter_status, filter_server, filter_alias, network_id: netId }) => {
662
1259
  const readScope = resolveReadScope(netId);
663
1260
  if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
664
- console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${readScope.networkId ? " net=" + readScope.networkId.slice(0, 12) : ""}`);
1261
+ 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) : ""}`);
665
1262
 
666
1263
  // Round-2/4 review ③: stale-marking moved to startStaleSessionSweeper()
667
1264
  // (background timer, ~60s cadence). Read path no longer fires UPDATE.
@@ -670,20 +1267,42 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
670
1267
  sql = addReadScope(sql, params, readScope);
671
1268
  if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
672
1269
  if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
1270
+ const aliasFilter = parseAliasFilter(filter_alias);
1271
+ const aliases = aliasFilter.aliases;
1272
+ if (aliasFilter.sql) {
1273
+ sql += aliasFilter.sql;
1274
+ params.push(...aliases);
1275
+ }
673
1276
  sql += " ORDER BY updated_at DESC";
674
1277
  const sessions = db.all(sql, ...params);
675
1278
 
1279
+ // `summary` has always counted every session in the read scope, ignoring
1280
+ // filter_status / filter_server — and now filter_alias. That is fine for
1281
+ // the patrol loop, but a caller who asked about three aliases and gets
1282
+ // back three rows plus "idle: 96" can easily read the 96 as being about
1283
+ // their three. A count that does not say what it counted invites exactly
1284
+ // that. So the response now says so, rather than the semantics changing
1285
+ // under existing callers.
676
1286
  const summaryParams: any[] = [];
677
1287
  let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
678
1288
  summarySql = addReadScope(summarySql, summaryParams, readScope);
679
1289
  summarySql += " GROUP BY status";
680
1290
  const summary = db.all(summarySql, ...summaryParams);
681
1291
 
1292
+ const filtered = !!(filter_status || filter_server || aliases.length > 0);
682
1293
  return {
683
1294
  content: [
684
1295
  {
685
1296
  type: "text" as const,
686
- text: JSON.stringify({ ok: true, sessions, summary }),
1297
+ text: JSON.stringify({
1298
+ ok: true,
1299
+ sessions,
1300
+ summary,
1301
+ summary_scope: filtered
1302
+ ? "every session in the read scope — NOT narrowed by the filters applied to `sessions`"
1303
+ : "every session in the read scope",
1304
+ sessions_returned: sessions.length,
1305
+ }),
687
1306
  },
688
1307
  ],
689
1308
  };
@@ -741,7 +1360,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
741
1360
  },
742
1361
  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);
743
1362
  const effectiveNetId = getNetworkId(netId);
744
- const metaJson = normalizeMetaJson(meta);
1363
+ const authOrigin: TaskAuthOrigin = callerTokenIsNetwork
1364
+ ? "node"
1365
+ : enforceUserId
1366
+ ? "user"
1367
+ : "legacy";
1368
+ const metaJson = normalizeMetaJson(stampTaskAuthOrigin(meta, authOrigin));
745
1369
 
746
1370
  // Role check FIRST — round5 follow-up (通信牛 oracle catch):
747
1371
  // the explicit-parent verification below distinguishes
@@ -816,6 +1440,45 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
816
1440
  const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
817
1441
  if (target.state === "not_found") return deliveryTargetReply(target)!;
818
1442
 
1443
+ // Dashboard sends carry a random client_request_id inside meta. Derive
1444
+ // the task primary key from authenticated scope + that request id so a
1445
+ // lost HTTP response can be retried safely, even after a hub restart.
1446
+ // The existing row must match the full request; key reuse with changed
1447
+ // content/target fails closed instead of silently returning another task.
1448
+ const clientRequestId = clientRequestIdFromMeta(meta);
1449
+ const id = clientRequestId
1450
+ ? idempotentTaskId(effectiveNetId ?? null, from_session, clientRequestId)
1451
+ : uuidv4();
1452
+ if (clientRequestId) {
1453
+ const existing = db.get<StoredIdempotentTask & { to_node_id: string | null }>(
1454
+ "SELECT task_id, from_name, to_node_id, to_name, priority, content, network_id, meta_json, status FROM tasks WHERE task_id = ?1",
1455
+ [id],
1456
+ );
1457
+ if (existing) {
1458
+ if (!idempotentTaskMatches(existing, {
1459
+ fromName: from_session, toName: targetAlias, priority, content: task,
1460
+ networkId: effectiveNetId ?? null, metaJson,
1461
+ })) {
1462
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1463
+ ok: false, error: "idempotency_conflict",
1464
+ message: "client_request_id was already used with a different task payload",
1465
+ }) }] };
1466
+ }
1467
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1468
+ ok: true, message_id: existing.task_id, task_id: existing.task_id,
1469
+ task_status: existing.status, idempotent_replay: true,
1470
+ // A replay acknowledges the already-persisted dispatch. Use that
1471
+ // row rather than today's session record so alias reuse or a
1472
+ // restarted process cannot rewrite historical recipient identity.
1473
+ actual_to: {
1474
+ alias: existing.to_name,
1475
+ to_node_id: existing.to_node_id ?? null,
1476
+ network_id: existing.network_id ?? null,
1477
+ },
1478
+ }) }] };
1479
+ }
1480
+ }
1481
+
819
1482
  // #212 dedup guardrail. If this exact (from, to, content) has already
820
1483
  // been delivered within COMMHUB_SEND_DEDUP_WINDOW_MS (default 5 min)
821
1484
  // we refuse the call and surface a structured `duplicate_send`
@@ -836,18 +1499,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
836
1499
  return { content: [{ type: "text" as const, text: JSON.stringify(payload) }] };
837
1500
  }
838
1501
 
1502
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 1/6).
1503
+ {
1504
+ const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
1505
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1506
+ }
1507
+
839
1508
  console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
840
- const id = uuidv4();
841
1509
  const fromNodeId = resolveNodeIdForAlias(from_session, effectiveNetId);
842
1510
  const targetNodeId = target.session?.node_id ?? null;
1511
+ const resolvedActualTo = actualTo(target, effectiveNetId);
843
1512
  // 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
844
1513
  // dashboard 在派任务一刻就反映出"任务已下发",不再等 agent 的
845
1514
  // report_status 心跳;status 字段交给 agent,避免与 working/idle
846
1515
  // 报告冲突)。
847
1516
  db.transaction(() => {
848
1517
  db.run(
849
- `INSERT INTO inbox (id, session_name, node_id, type, priority, content, context, from_session, requires_response, network_id, meta_json)
850
- VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, ?7, 'reply', ?8, ?9)`,
1518
+ `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, context, from_session, requires_response, network_id, meta_json)
1519
+ VALUES (?1, ?1, ?2, ?3, 'task', ?4, ?5, ?6, ?7, 'reply', ?8, ?9)`,
851
1520
  [id, targetAlias, targetNodeId, priority, task, context ?? null, from_session, effectiveNetId ?? null, metaJson]
852
1521
  );
853
1522
  db.run(
@@ -877,9 +1546,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
877
1546
  let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
878
1547
  pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
879
1548
  const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
880
- if (target.state === "online") {
881
- pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
882
- }
1549
+ // 🔴 不要用 last_seen_at 的时间戳去猜「有没有人在听」——SSE 订阅者注册表
1550
+ // 就是那件事本身。pushEvent 在没有订阅者时已经是 no-op,所以这道闸不提供
1551
+ // 任何保护,只会把**连着但最近没心跳**的会话排除掉;而全网「停发心跳/状态类
1552
+ // 消息」的省额度规则让安静恰恰是常态。上面那段注释早就写着 "Push
1553
+ // unconditionally",同一类缺陷上次也是打在 Dashboard 任务上(f015d9d6:
1554
+ // 任务标了 delivered,会话却从未收到推送)。
1555
+ pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
1556
+ const actuallyDelivered = hasSubscribers(targetAlias, effectiveNetId);
1557
+ // #461 network observer summary — unconditional (task row exists
1558
+ // even when the target is offline/queued), metadata only.
1559
+ pushNetworkObserverEvent(effectiveNetId, { type: "new_task", task_id: id, from: from_session, to: targetAlias, status: actuallyDelivered ? "delivered" : "queued", priority });
883
1560
 
884
1561
  if (target.state === "offline") {
885
1562
  return {
@@ -894,6 +1571,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
894
1571
  task_id: id,
895
1572
  message_id: id,
896
1573
  session_status: target.session.status ?? "offline",
1574
+ actual_to: resolvedActualTo,
897
1575
  ...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
898
1576
  }),
899
1577
  }],
@@ -907,6 +1585,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
907
1585
  text: JSON.stringify({
908
1586
  ok: true,
909
1587
  message_id: id,
1588
+ actual_to: resolvedActualTo,
910
1589
  ...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
911
1590
  session_status: target.session?.status ?? "unknown",
912
1591
  }),
@@ -923,14 +1602,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
923
1602
  alias: z.string().min(1).max(200).describe("Target session alias"),
924
1603
  message: z.string().min(1).max(10000).describe("Message content"),
925
1604
  from_session: z.string().max(200).optional(),
1605
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
926
1606
  },
927
- async ({ alias, message, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
928
- const effectiveNetId = getNetworkId(null);
1607
+ async ({ alias, message, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1608
+ const effectiveNetId = getNetworkId(netId);
929
1609
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
930
1610
  const canonical = resolveCanonicalAlias(effectiveNetId, alias);
931
1611
  const targetAlias = canonical.alias;
932
1612
  const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
933
1613
  if (target.state === "not_found") return deliveryTargetReply(target)!;
1614
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 2/6).
1615
+ {
1616
+ const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
1617
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1618
+ }
934
1619
  console.log(`[${ts()}] ${from_session} → send_message → ${targetAlias}: ${message.slice(0, 60)}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
935
1620
  const id = uuidv4();
936
1621
  db.run(
@@ -939,9 +1624,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
939
1624
  [id, targetAlias, target.session?.node_id ?? null, message, from_session, effectiveNetId ?? null]
940
1625
  );
941
1626
 
942
- if (target.state === "online") {
943
- pushEvent(targetAlias, { type: "new_message", from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
944
- }
1627
+ // 同 send_task:可达性用订阅者注册表判,不用心跳时间戳猜。
1628
+ pushEvent(targetAlias, { type: "new_message", inbox_count: pendingInboxCount(targetAlias, effectiveNetId), from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
945
1629
 
946
1630
  const offlineReply = deliveryTargetReply(target, { message_id: id });
947
1631
  if (offlineReply) {
@@ -969,96 +1653,253 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
969
1653
  }
970
1654
  );
971
1655
 
972
- // ── V2: send_reply (关联 task_id,不触发 think) ──
973
- server.tool(
974
- "send_reply",
975
- "Send a reply to a task. Linked to task_id via in_reply_to. Does NOT trigger agent processing.",
976
- {
977
- alias: z.string().min(1).max(200).describe("Target session alias"),
1656
+ // ── V2/V3 reply primitives ──
1657
+ const replyToolSchema = {
1658
+ // #1085 — alias is OPTIONAL: when omitted (or empty) and `in_reply_to`
1659
+ // is set, the hub derives the target from the original task's
1660
+ // `from_name` (it already SELECTs that task by in_reply_to below).
1661
+ // The SDK-runtime bridge (agent-node/src/commhub-mcp.ts) exposes
1662
+ // send_reply as (task_id, text, status) with NO alias, so before this
1663
+ // it always sent alias=undefined → -32602 "expected string, received
1664
+ // undefined at alias". The target was already knowable from the task,
1665
+ // so requiring the caller to pass it was a redundant-param trap.
1666
+ alias: z.string().min(1).max(200).optional().describe("Target session alias — optional; derived from in_reply_to's task sender when omitted"),
978
1667
  text: z.string().min(1).max(10000).describe("Reply content"),
979
1668
  in_reply_to: z.string().max(200).optional().describe("Original task/message ID"),
980
1669
  status: z.enum(["replied", "failed", "cancelled"]).optional().default("replied").describe("Task outcome"),
981
1670
  from_session: z.string().max(200).optional(),
982
- },
983
- async ({ alias, text, in_reply_to, status: replyStatus, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
984
- const effectiveNetId = getNetworkId(null);
1671
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1672
+ // #507 — top-level attachments (parity with REST /api/task L506). MCP Zod
1673
+ // otherwise silently strips unknown fields, so a caller passing
1674
+ // `attachments` before this schema entry existed would see `ok:true`
1675
+ // and never learn the attachments were dropped. Validated by
1676
+ // validateAttachments (uploads.ts) — the same helper the REST path uses.
1677
+ 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."),
1678
+ // #507 — optional structured metadata (parity with send_task L837). If
1679
+ // both `attachments` (top-level) and `meta.attachments` are supplied,
1680
+ // top-level wins (same rule as REST /api/task L2101).
1681
+ meta: z.any().optional().describe("Optional structured reply metadata, e.g. { attachments: [...] }."),
1682
+ };
1683
+ const handleReply = async (args: any, peerCapabilityRequired: boolean) => {
1684
+ const { alias, text, in_reply_to, status: replyStatus = "replied", from_session: _fromIn, network_id: netId, attachments, meta } = args;
1685
+ const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1686
+ const effectiveNetId = getNetworkId(netId);
985
1687
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
986
- console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
987
- const id = uuidv4();
988
- let taskBefore: { status: string } | null = null;
989
- if (in_reply_to) {
990
- const taskParams: any[] = [in_reply_to];
991
- let taskSql = "SELECT status FROM tasks WHERE task_id = ?1";
992
- taskSql = addScope(taskSql, taskParams, effectiveNetId);
993
- taskBefore = db.get<{ status: string }>(taskSql, ...taskParams) ?? null;
994
- if (!taskBefore) {
995
- return {
996
- content: [{
997
- type: "text" as const,
998
- text: JSON.stringify({
999
- ok: false,
1000
- error: "reply_task_not_found",
1001
- message: `cannot apply reply: task not found (${in_reply_to})`,
1002
- in_reply_to,
1003
- reply_queued: false,
1004
- }),
1005
- }],
1006
- };
1688
+ if (peerCapabilityRequired && !in_reply_to) {
1689
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "peer_reply_task_required" }) }] };
1690
+ }
1691
+
1692
+ // #507 — validate attachments BEFORE any DB write. Rejects malformed
1693
+ // input (bad file_id, >20 items, size > cap, non-object item, wrong
1694
+ // type field) with an explicit error the caller can act on. Empty /
1695
+ // absent attachments return { ok: true, attachments: [] } — the
1696
+ // reverse-(e) invariant (no attachments → behavior unchanged) is
1697
+ // pinned by tests that assert byte-identical response shape
1698
+ // before/after this validation runs.
1699
+ const attachmentsResult = validateAttachments(attachments ?? (meta && typeof meta === "object" ? (meta as any).attachments : undefined));
1700
+ if (!attachmentsResult.ok) {
1701
+ return {
1702
+ content: [{
1703
+ type: "text" as const,
1704
+ text: JSON.stringify({
1705
+ ok: false,
1706
+ error: "bad_attachments",
1707
+ message: attachmentsResult.error,
1708
+ }),
1709
+ }],
1710
+ };
1711
+ }
1712
+ const mergedMeta = attachmentsResult.attachments.length
1713
+ ? { ...(meta && typeof meta === "object" ? meta : {}), attachments: attachmentsResult.attachments }
1714
+ : meta;
1715
+ const metaJson = normalizeMetaJson(mergedMeta);
1716
+
1717
+ // #1085 — derive the reply target when the caller omitted `alias`.
1718
+ // The original task's `from_name` IS the reply target; the hub knows
1719
+ // it from `in_reply_to`. Without this, an SDK-runtime send_reply
1720
+ // (schema has no alias) died with -32602 before reaching this handler.
1721
+ let effectiveAlias: string | undefined =
1722
+ (typeof alias === "string" && alias.trim()) ? alias : undefined;
1723
+ if (!effectiveAlias) {
1724
+ if (!in_reply_to) {
1725
+ 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" }) }] };
1007
1726
  }
1008
- if (!["created", "delivered", "acked", "running"].includes(taskBefore.status)) {
1009
- return {
1010
- content: [{
1011
- type: "text" as const,
1012
- text: JSON.stringify({
1013
- ok: false,
1014
- error: "reply_task_terminal",
1015
- message: `cannot apply reply: task is already terminal (${taskBefore.status})`,
1016
- in_reply_to,
1017
- task_status: taskBefore.status,
1018
- reply_queued: false,
1019
- }),
1020
- }],
1021
- };
1727
+ const derivParams: any[] = [in_reply_to];
1728
+ let derivSql = "SELECT from_name FROM tasks WHERE task_id = ?1";
1729
+ derivSql = addScope(derivSql, derivParams, effectiveNetId);
1730
+ const derived = db.get<{ from_name: string }>(derivSql, ...derivParams);
1731
+ if (!derived?.from_name) {
1732
+ 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})` }) }] };
1022
1733
  }
1734
+ effectiveAlias = derived.from_name;
1735
+ }
1736
+
1737
+ const canonicalReplyTarget = resolveCanonicalAlias(effectiveNetId, effectiveAlias);
1738
+ const replyTargetAlias = canonicalReplyTarget.alias;
1739
+ 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}]` : ""}`);
1740
+ const id = uuidv4();
1741
+ const replyTargetNodeId = resolveNodeIdForAlias(replyTargetAlias, effectiveNetId);
1742
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 3/6).
1743
+ {
1744
+ const lc = assertNodeActive(replyTargetAlias, effectiveNetId ?? null);
1745
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1023
1746
  }
1024
- const replyTargetNodeId = resolveNodeIdForAlias(alias, effectiveNetId);
1025
- const replyLogged = db.transaction(() => {
1747
+ const replyOutcome = db.transaction(() => {
1748
+ type ReplyTask = {
1749
+ status: string;
1750
+ from_node_id: string | null;
1751
+ from_name: string;
1752
+ to_name: string;
1753
+ to_node_id: string | null;
1754
+ };
1755
+ let taskBefore: ReplyTask | null = null;
1756
+ let callerNodeId: string | null = null;
1757
+ if (in_reply_to) {
1758
+ const taskParams: any[] = [in_reply_to];
1759
+ let taskSql = "SELECT status, from_node_id, from_name, to_name, to_node_id FROM tasks WHERE task_id = ?1";
1760
+ taskSql = addScope(taskSql, taskParams, effectiveNetId);
1761
+ taskBefore = db.get<ReplyTask>(taskSql, ...taskParams) ?? null;
1762
+ if (!taskBefore) return { ok: false as const, error: "reply_task_not_found" as const };
1763
+ if (!["created", "delivered", "acked", "running"].includes(taskBefore.status)) {
1764
+ return { ok: false as const, error: "reply_task_terminal" as const, taskStatus: taskBefore.status };
1765
+ }
1766
+
1767
+ // V3 atomic peer replies are capability-negotiated. Both identity
1768
+ // bindings and the recipient capability are re-read inside this
1769
+ // transaction before any inbox/task/run write. Legacy/unbound rows
1770
+ // fail toward compatibility send_reply, which terminalizes the
1771
+ // original task instead of creating a second response-requiring row.
1772
+ if (peerCapabilityRequired) {
1773
+ if (!taskBefore.from_node_id) {
1774
+ // Origin type is an intrinsic property of the task and must be
1775
+ // decided before caller capability. A Dashboard/human task still
1776
+ // needs the established send_reply terminalization path when the
1777
+ // replying node uses a legacy/unbound token.
1778
+ return { ok: false as const, error: "peer_reply_origin_not_node" as const };
1779
+ }
1780
+ if (!callerTokenIsNetwork || !callerTokenId || !enforceNetworkId) {
1781
+ return { ok: false as const, error: "peer_reply_node_token_required" as const };
1782
+ }
1783
+ const token = db.get<{ bound_node_id: string | null }>(
1784
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
1785
+ callerTokenId,
1786
+ enforceNetworkId,
1787
+ );
1788
+ if (!token?.bound_node_id || !taskBefore.to_node_id) {
1789
+ return { ok: false as const, error: "peer_reply_unsupported" as const };
1790
+ }
1791
+ if (token.bound_node_id !== taskBefore.to_node_id) {
1792
+ return { ok: false as const, error: "reply_task_not_owned" as const };
1793
+ }
1794
+ callerNodeId = token.bound_node_id;
1795
+ const canonicalTarget = replyTargetAlias;
1796
+ const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, taskBefore.from_name).alias;
1797
+ if (canonicalTarget !== canonicalOrigin) {
1798
+ return { ok: false as const, error: "reply_target_mismatch" as const };
1799
+ }
1800
+ const recipient = db.get<{ peer_reply_inbox_capable: number }>(
1801
+ `SELECT peer_reply_inbox_capable FROM sessions
1802
+ WHERE node_id = ?1 AND network_id = ?2 AND alias = ?3
1803
+ ORDER BY updated_at DESC LIMIT 1`,
1804
+ taskBefore.from_node_id,
1805
+ enforceNetworkId,
1806
+ canonicalOrigin,
1807
+ );
1808
+ if (recipient?.peer_reply_inbox_capable !== 1) {
1809
+ return { ok: false as const, error: "peer_reply_unsupported" as const };
1810
+ }
1811
+ } else if (callerTokenIsNetwork && enforceNetworkId) {
1812
+ // Compatibility send_reply is still an exact task transition for
1813
+ // node callers. Bind it to the token-authenticated assignee alias
1814
+ // and immutable original sender. Alias binding survives a
1815
+ // legitimate node-id rotation without letting another node close
1816
+ // the task.
1817
+ const canonicalCaller = resolveCanonicalAlias(enforceNetworkId, from_session).alias;
1818
+ const canonicalAssignee = resolveCanonicalAlias(enforceNetworkId, taskBefore.to_name).alias;
1819
+ if (canonicalCaller !== canonicalAssignee) {
1820
+ return { ok: false as const, error: "reply_task_not_owned" as const };
1821
+ }
1822
+ const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, taskBefore.from_name).alias;
1823
+ if (replyTargetAlias !== canonicalOrigin) {
1824
+ return { ok: false as const, error: "reply_target_mismatch" as const };
1825
+ }
1826
+ }
1827
+
1828
+ }
1829
+
1830
+ // #507 — write meta_json on inbox insert (parity with send_task L952).
1831
+ // Prior to this the attachments field on a send_reply call was
1832
+ // silently stripped by MCP Zod, leaving `ok:true` with no persisted
1833
+ // meta.
1026
1834
  db.run(
1027
- `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
1028
- VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7)`,
1029
- [id, alias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null]
1835
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id, meta_json)
1836
+ VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7, ?8)`,
1837
+ [id, replyTargetAlias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null, metaJson]
1030
1838
  );
1031
1839
 
1032
1840
  // 更新 tasks 表
1033
1841
  if (in_reply_to) {
1034
- const updateParams: any[] = [replyStatus, text, in_reply_to];
1035
- let updateSql = `UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now')
1036
- WHERE task_id = ?3 AND status IN ('created', 'delivered', 'acked', 'running')`;
1842
+ // #507 — persist meta_json onto the tasks row too, so
1843
+ // dashboard's task view sees the reply's attachments alongside
1844
+ // the reply text (parity with send_task L957/959 which writes
1845
+ // meta_json into both inbox and tasks). The COALESCE guards
1846
+ // against clobbering pre-existing meta_json when this reply
1847
+ // brings no attachments (metaJson === null → keep the existing
1848
+ // task meta_json unchanged). Reverse-(e) invariant.
1849
+ const updateParams: any[] = [replyStatus, text, metaJson, in_reply_to];
1850
+ let updateSql = `UPDATE tasks
1851
+ SET status = ?1,
1852
+ result = ?2,
1853
+ completed_at = datetime('now'),
1854
+ meta_json = COALESCE(?3, meta_json)
1855
+ WHERE task_id = ?4 AND status IN ('created', 'delivered', 'acked', 'running')`;
1856
+ if (peerCapabilityRequired && callerTokenIsNetwork && taskBefore) {
1857
+ if (taskBefore.to_node_id) {
1858
+ updateParams.push(callerNodeId);
1859
+ updateSql += ` AND to_node_id = ?${updateParams.length}`;
1860
+ } else {
1861
+ updateParams.push(taskBefore.to_name);
1862
+ updateSql += ` AND to_node_id IS NULL AND to_name = ?${updateParams.length}`;
1863
+ }
1864
+ }
1037
1865
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1038
1866
  const result = db.run(updateSql, updateParams);
1039
1867
  if (result.changes === 0) {
1040
- console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
1041
- return false;
1868
+ throw new Error(`reply_atomic_cas_failed:${in_reply_to}`);
1042
1869
  }
1043
- return true;
1870
+ syncScheduledRunForTask(in_reply_to, effectiveNetId);
1871
+ return { ok: true as const, replyLogged: true };
1044
1872
  }
1045
- return false;
1873
+ return { ok: true as const, replyLogged: false };
1046
1874
  });
1047
1875
 
1048
- if (in_reply_to && !replyLogged) {
1876
+ if (!replyOutcome.ok) {
1877
+ const taskStatus = "taskStatus" in replyOutcome ? replyOutcome.taskStatus : undefined;
1878
+ const messages: Record<string, string> = {
1879
+ reply_task_not_found: `cannot apply reply: task not found (${in_reply_to})`,
1880
+ reply_task_terminal: `cannot apply reply: task is already terminal (${taskStatus})`,
1881
+ peer_reply_node_token_required: "atomic peer reply requires a node token",
1882
+ peer_reply_unsupported: "recipient or caller does not support atomic peer replies",
1883
+ peer_reply_origin_not_node: "original task sender is not a node; use send_reply",
1884
+ reply_node_identity_unbound: "cannot apply reply: node identity is not token-bound",
1885
+ reply_task_not_owned: "cannot apply reply: task is not owned by this node",
1886
+ reply_target_mismatch: "cannot apply reply: target is not the original task sender",
1887
+ };
1049
1888
  return {
1050
1889
  content: [{
1051
1890
  type: "text" as const,
1052
1891
  text: JSON.stringify({
1053
1892
  ok: false,
1054
- error: "reply_not_applied",
1055
- message: "reply was not applied to task",
1893
+ error: replyOutcome.error,
1894
+ message: messages[replyOutcome.error],
1056
1895
  in_reply_to,
1896
+ ...(taskStatus ? { task_status: taskStatus } : {}),
1057
1897
  reply_queued: false,
1058
1898
  }),
1059
1899
  }],
1060
1900
  };
1061
1901
  }
1902
+ const replyLogged = replyOutcome.replyLogged;
1062
1903
 
1063
1904
  // Log event after commit (outside transaction)
1064
1905
  if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
@@ -1095,16 +1936,71 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1095
1936
  }
1096
1937
  }
1097
1938
 
1098
- const session = scopedSessionStatus(alias, effectiveNetId);
1099
- pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
1939
+ const session = scopedSessionStatus(replyTargetAlias, effectiveNetId);
1940
+ pushEvent(replyTargetAlias, { type: "new_reply", inbox_count: pendingInboxCount(replyTargetAlias, effectiveNetId), from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
1941
+ // #461 network observer summary — ids + routing only, no reply text.
1942
+ pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to ?? null, message_id: id, from: from_session, to: replyTargetAlias, status: replyStatus });
1943
+
1944
+ // #498 compatibility tripwire. Legacy send_reply is intentionally kept
1945
+ // for old agents and Dashboard callers during rollout, but it is not the
1946
+ // capability-negotiated atomic peer primitive. Warn only for an actual
1947
+ // agent target; Dashboard hub/api replies remain quiet. New agents call
1948
+ // send_peer_reply and never see this warning.
1949
+ const targetIsAgent = replyTargetNodeId !== null
1950
+ && replyTargetAlias !== "hub"
1951
+ && replyTargetAlias !== "api";
1952
+ const warning = !peerCapabilityRequired && targetIsAgent
1953
+ ? `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.)`
1954
+ : undefined;
1955
+
1956
+ // #507 — echo attachments READ BACK FROM DB (lead 2b5f6634): the point
1957
+ // of the echo is to prove attachments actually landed in storage, not
1958
+ // to reflect the in-memory variable we tried to write. Reading
1959
+ // `attachmentsResult.attachments` here would still show `ok:true` +
1960
+ // full echo if the UPDATE failed with `changes=0` (e.g. task moved to
1961
+ // terminal between the pre-check and the transaction). SELECT from
1962
+ // inbox.meta_json (always written, uses the id we just generated) so
1963
+ // the echo means "these attachments are on disk now". Absent field
1964
+ // when the caller sent no attachments — reverse-(e) invariant: a
1965
+ // no-attachment call returns the exact same response shape as before
1966
+ // this PR. Persisted-attachments field is only added when the caller
1967
+ // actually asked for attachments.
1968
+ let attachmentsSaved: unknown[] | null = null;
1969
+ if (attachmentsResult.attachments.length > 0) {
1970
+ const persistedRow = db.get<{ meta_json: string | null }>(
1971
+ "SELECT meta_json FROM inbox WHERE id = ?1", [id]
1972
+ );
1973
+ const persistedMeta = parseMetaJson(persistedRow?.meta_json ?? null);
1974
+ attachmentsSaved = persistedMeta && typeof persistedMeta === "object" && Array.isArray((persistedMeta as any).attachments)
1975
+ ? (persistedMeta as any).attachments
1976
+ : [];
1977
+ }
1100
1978
 
1101
1979
  return {
1102
1980
  content: [{
1103
1981
  type: "text" as const,
1104
- text: JSON.stringify({ ok: true, message_id: id, session_status: session?.status ?? "unknown" }),
1982
+ text: JSON.stringify({
1983
+ ok: true,
1984
+ message_id: id,
1985
+ session_status: session?.status ?? "unknown",
1986
+ ...(warning ? { warning } : {}),
1987
+ ...(attachmentsSaved !== null ? { attachments_saved: attachmentsSaved } : {}),
1988
+ }),
1105
1989
  }],
1106
1990
  };
1107
- }
1991
+ };
1992
+
1993
+ server.tool(
1994
+ "send_reply",
1995
+ "Reply to a Dashboard/UI-originated task. Also serves as the terminal legacy fallback when atomic peer reply capability is unavailable.",
1996
+ replyToolSchema,
1997
+ (args) => handleReply(args, false),
1998
+ );
1999
+ server.tool(
2000
+ "send_peer_reply",
2001
+ "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.",
2002
+ replyToolSchema,
2003
+ (args) => handleReply(args, true),
1108
2004
  );
1109
2005
 
1110
2006
  // ── V2: send_ack (不入 inbox,仅更新状态) ──
@@ -1114,9 +2010,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1114
2010
  {
1115
2011
  task_id: z.string().min(1).max(200).describe("Task ID to acknowledge"),
1116
2012
  from_session: z.string().max(200).optional(),
2013
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1117
2014
  },
1118
- async ({ task_id, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1119
- const effectiveNetId = getNetworkId(null);
2015
+ async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2016
+ const effectiveNetId = getNetworkId(netId);
1120
2017
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1121
2018
  console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
1122
2019
  const updateParams: any[] = [task_id];
@@ -1140,9 +2037,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1140
2037
  {
1141
2038
  task_id: z.string().min(1).max(200).describe("Task ID to retry"),
1142
2039
  from_session: z.string().max(200).optional(),
2040
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1143
2041
  },
1144
- async ({ task_id, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1145
- const effectiveNetId = getNetworkId(null);
2042
+ async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2043
+ const effectiveNetId = getNetworkId(netId);
1146
2044
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1147
2045
  console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
1148
2046
  // Find the original task
@@ -1156,6 +2054,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1156
2054
  if (!["failed", "expired", "cancelled"].includes(task.status)) {
1157
2055
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task status is ${task.status}, not retryable` }) }] };
1158
2056
  }
2057
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 4/6).
2058
+ {
2059
+ const lc = assertNodeActive(task.to_name, effectiveNetId ?? task.network_id ?? null);
2060
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
2061
+ }
1159
2062
  db.transaction(() => {
1160
2063
  // Reset task status
1161
2064
  const updateParams: any[] = [task_id];
@@ -1163,17 +2066,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1163
2066
  WHERE task_id = ?1`;
1164
2067
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1165
2068
  db.run(updateSql, updateParams);
2069
+ syncScheduledRunForTask(task_id, effectiveNetId ?? task.network_id ?? null);
1166
2070
  // Re-queue in inbox with new ID (original ID may already exist)
1167
2071
  const retryInboxId = uuidv4();
1168
2072
  db.run(
1169
- `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id)
1170
- VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7)`,
1171
- [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]
2073
+ `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id)
2074
+ VALUES (?1, ?2, ?3, ?4, 'task', ?5, ?6, ?7, 'reply', ?8)`,
2075
+ [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]
1172
2076
  );
1173
2077
  });
1174
2078
  logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
1175
2079
  // SSE push (unconditional — channel is keyed by alias, not network)
1176
- pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session }, effectiveNetId ?? task.network_id ?? null);
2080
+ 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);
2081
+ pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: task.to_name, status: "delivered", priority: task.priority });
1177
2082
  return {
1178
2083
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
1179
2084
  };
@@ -1213,19 +2118,64 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1213
2118
  from_name: z.string().max(200).optional().describe("Filter by sender"),
1214
2119
  from_node_id: z.string().max(200).optional().describe("Filter by immutable sender node_id"),
1215
2120
  network_id: z.string().max(200).optional().describe("Filter by network"),
2121
+ before_created_at: z.string().max(64).optional().describe("Pagination cursor timestamp"),
2122
+ before_task_id: z.string().max(200).optional().describe("Pagination cursor task id"),
2123
+ durable_cursor: z.boolean().optional().describe("Require immutable node-scoped cursor protocol"),
2124
+ durable_terminal_cursor: z.boolean().optional().describe("Read terminal outbound journal in monotonic order"),
2125
+ after_terminal_seq: z.number().int().min(0).optional().describe("Exclusive terminal journal watermark"),
1216
2126
  limit: z.number().min(1).max(100).optional().default(20),
1217
2127
  },
1218
- async ({ alias, status, from_name, from_node_id, network_id: netId, limit }) => {
2128
+ 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 }) => {
1219
2129
  const readScope = resolveReadScope(netId);
1220
2130
  if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
1221
- 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";
2131
+ if (durable_cursor) {
2132
+ if (!callerTokenIsNetwork) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_required" }) }] };
2133
+ const boundNodeId = callerTokenId && enforceNetworkId
2134
+ ? db.get<{ bound_node_id: string | null }>(
2135
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
2136
+ callerTokenId,
2137
+ enforceNetworkId,
2138
+ )?.bound_node_id ?? null
2139
+ : null;
2140
+ if (!boundNodeId || from_node_id !== boundNodeId) {
2141
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "from_node_id_identity_mismatch" }) }] };
2142
+ }
2143
+ }
2144
+ if (durable_terminal_cursor) {
2145
+ if (!durable_cursor) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "durable_cursor_required" }) }] };
2146
+ const params: any[] = [enforceNetworkId, from_node_id, after_terminal_seq ?? 0, limit];
2147
+ const tasks = db.all(
2148
+ `SELECT t.task_id, t.from_node_id, t.from_name, t.to_node_id, t.to_name, t.priority,
2149
+ t.status, t.content, t.result, t.created_at, t.runtime_submitted_at,
2150
+ t.consumed_at, t.thread_id, t.turn_id, t.completed_at, e.terminal_seq
2151
+ FROM task_terminal_events e
2152
+ JOIN tasks t ON t.task_id = e.task_id
2153
+ WHERE COALESCE(e.network_id, 'default') = COALESCE(?1, 'default')
2154
+ AND e.from_node_id = ?2 AND e.terminal_seq > ?3
2155
+ ORDER BY e.terminal_seq ASC LIMIT ?4`,
2156
+ ...params,
2157
+ );
2158
+ return { content: [{ type: "text" as const, text: JSON.stringify({
2159
+ ok: true,
2160
+ capability: "list_tasks.immutable-terminal-sequence.v2",
2161
+ tasks,
2162
+ count: tasks.length,
2163
+ next_terminal_seq: tasks.length ? tasks[tasks.length - 1]?.terminal_seq : (after_terminal_seq ?? 0),
2164
+ has_more: tasks.length === limit,
2165
+ }) }] };
2166
+ }
2167
+ 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";
1222
2168
  const params: any[] = [];
1223
2169
  sql = addReadScope(sql, params, readScope);
1224
2170
  if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
1225
2171
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
1226
2172
  if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
1227
2173
  if (from_node_id) { sql += ` AND from_node_id = ?${params.length + 1}`; params.push(from_node_id); }
1228
- sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
2174
+ if (before_created_at && before_task_id) {
2175
+ sql += ` AND (created_at < ?${params.length + 1} OR (created_at = ?${params.length + 1} AND task_id < ?${params.length + 2}))`;
2176
+ params.push(before_created_at, before_task_id);
2177
+ }
2178
+ sql += ` ORDER BY created_at DESC, task_id DESC LIMIT ?${params.length + 1}`;
1229
2179
  params.push(limit);
1230
2180
  const tasks = db.all(sql, ...params);
1231
2181
 
@@ -1239,7 +2189,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1239
2189
  return {
1240
2190
  content: [{
1241
2191
  type: "text" as const,
1242
- text: JSON.stringify({ ok: true, tasks, count: tasks.length, stats }),
2192
+ text: JSON.stringify({
2193
+ ok: true,
2194
+ ...(durable_cursor ? { capability: "list_tasks.immutable-node-cursor.v1" } : {}),
2195
+ tasks,
2196
+ count: tasks.length,
2197
+ next_cursor: tasks.length === limit ? {
2198
+ before_created_at: tasks[tasks.length - 1]?.created_at,
2199
+ before_task_id: tasks[tasks.length - 1]?.task_id,
2200
+ } : null,
2201
+ stats,
2202
+ }),
1243
2203
  }],
1244
2204
  };
1245
2205
  }
@@ -1253,24 +2213,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1253
2213
  task_id: z.string().min(1).max(200).describe("Task ID to cancel"),
1254
2214
  reason: z.string().max(1000).optional().describe("Cancellation reason"),
1255
2215
  from_session: z.string().max(200).optional(),
2216
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1256
2217
  },
1257
- async ({ task_id, reason, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1258
- const effectiveNetId = getNetworkId(null);
2218
+ async ({ task_id, reason, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2219
+ const effectiveNetId = getNetworkId(netId);
1259
2220
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1260
2221
  console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
1261
2222
  const updateParams: any[] = [reason || "cancelled by " + from_session, task_id];
1262
2223
  let updateSql = `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
1263
2224
  WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`;
1264
2225
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1265
- const result = db.run(updateSql, updateParams);
1266
- // Also ack the inbox entry to prevent agent from picking it up
1267
- if (result.changes > 0) {
1268
- const inboxParams: any[] = [task_id];
1269
- let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
1270
- inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
1271
- db.run(inboxSql, inboxParams);
1272
- logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
1273
- }
2226
+ const result = db.transaction(() => {
2227
+ const updated = db.run(updateSql, updateParams);
2228
+ // Also ack the inbox entry to prevent agent from picking it up.
2229
+ if (updated.changes > 0) {
2230
+ const inboxParams: any[] = [task_id];
2231
+ let inboxSql = "UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0";
2232
+ inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
2233
+ db.run(inboxSql, inboxParams);
2234
+ syncScheduledRunForTask(task_id, effectiveNetId);
2235
+ }
2236
+ return updated;
2237
+ });
2238
+ if (result.changes > 0) logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
1274
2239
  return {
1275
2240
  content: [{ type: "text" as const, text: JSON.stringify({ ok: result.changes > 0, task_id, cancelled: result.changes > 0 }) }],
1276
2241
  };
@@ -1285,9 +2250,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1285
2250
  task_id: z.string().min(1).max(200).describe("Task ID to reassign"),
1286
2251
  new_alias: z.string().min(1).max(200).describe("Target agent alias"),
1287
2252
  from_session: z.string().max(200).optional(),
2253
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1288
2254
  },
1289
- async ({ task_id, new_alias, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1290
- const effectiveNetId = getNetworkId(null);
2255
+ async ({ task_id, new_alias, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2256
+ const effectiveNetId = getNetworkId(netId);
1291
2257
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1292
2258
  console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
1293
2259
  const taskParams: any[] = [task_id];
@@ -1303,10 +2269,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1303
2269
  const reassignedAlias = canonical.alias;
1304
2270
  const target = resolveDeliveryTarget(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
1305
2271
  const newNodeId = target.state === "not_found" ? null : (target.session?.node_id ?? null);
2272
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 5/6).
2273
+ {
2274
+ const lc = assertNodeActive(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
2275
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
2276
+ }
1306
2277
  db.transaction(() => {
1307
2278
  // Ack old inbox to prevent original agent from picking it up
1308
2279
  const inboxParams: any[] = [task_id];
1309
- let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
2280
+ let inboxSql = "UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0";
1310
2281
  inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
1311
2282
  db.run(inboxSql, inboxParams);
1312
2283
 
@@ -1316,28 +2287,141 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1316
2287
  db.run(updateSql, updateParams);
1317
2288
 
1318
2289
  const newInboxId = uuidv4();
1319
- 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)",
1320
- [newInboxId, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
2290
+ 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)",
2291
+ [newInboxId, task_id, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
1321
2292
  });
1322
2293
  logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${reassignedAlias}`);
1323
- 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);
2294
+ 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);
2295
+ pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: reassignedAlias, status: "delivered", priority: task.priority });
1324
2296
  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 } : {}) }) }] };
1325
2297
  }
1326
2298
  );
1327
2299
 
1328
2300
  server.tool(
1329
- "broadcast",
1330
- "Send a message to multiple sessions.",
2301
+ "send_desktop_message",
2302
+ "Push a message to a user's active Desktop/web clients in one network. Targets user identity, not node alias.",
1331
2303
  {
2304
+ to_user_id: z.string().min(1).max(200).optional().describe("Target user_id. Mutually checked with to_username when both are supplied."),
2305
+ to_username: z.string().min(1).max(50).optional().describe("Target username. Mutually checked with to_user_id when both are supplied."),
2306
+ title: z.string().max(200).optional(),
1332
2307
  message: z.string().min(1).max(10000),
1333
- filter_server: z.string().max(200).optional(),
1334
- filter_status: z.string().max(50).optional(),
1335
- network_id: z.string().max(200).optional().describe("Broadcast within a specific network"),
2308
+ severity: z.enum(["info", "success", "warning", "error"]).optional().default("info"),
2309
+ kind: z.string().min(1).max(80).optional().default("agent_message"),
2310
+ meta: z.record(z.unknown()).optional(),
2311
+ from_session: z.string().max(200).optional(),
2312
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens; ntok stays bound to its network)."),
1336
2313
  },
1337
- async ({ message, filter_server, filter_status, network_id: netId }) => {
2314
+ async ({ to_user_id, to_username, title, message, severity, kind, meta, from_session: _fromIn, network_id: netId }) => {
2315
+ const fromMismatch = fromIdentityMismatchReply(_fromIn);
2316
+ if (fromMismatch) return fromMismatch;
2317
+ const from_session = defaultFrom(_fromIn);
1338
2318
  const effectiveNetId = getNetworkId(netId);
1339
- if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1340
- console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${effectiveNetId ? " [net=" + effectiveNetId.slice(0, 12) + "]" : ""}`);
2319
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
2320
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
2321
+
2322
+ if (!to_user_id && !to_username) {
2323
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_required", message: "to_user_id or to_username is required" }) }] };
2324
+ }
2325
+
2326
+ const byId = to_user_id
2327
+ ? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE user_id = ?1", to_user_id)
2328
+ : null;
2329
+ const byName = to_username
2330
+ ? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE username = ?1", to_username)
2331
+ : null;
2332
+
2333
+ if (to_user_id && !byId) {
2334
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_user_id" }) }] };
2335
+ }
2336
+ if (to_username && !byName) {
2337
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_username" }) }] };
2338
+ }
2339
+ if (byId && byName && byId.user_id !== byName.user_id) {
2340
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_mismatch", to_user_id, to_username }) }] };
2341
+ }
2342
+
2343
+ const target = byId ?? byName;
2344
+ if (!target) {
2345
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found" }) }] };
2346
+ }
2347
+
2348
+ const targetRole = getUserNetworkRole(target.user_id, effectiveNetId);
2349
+ if (!targetRole) {
2350
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_in_network", network_id: effectiveNetId }) }] };
2351
+ }
2352
+
2353
+ const messageId = `dm_${uuidv4()}`;
2354
+ const event = {
2355
+ type: "desktop_message",
2356
+ message_id: messageId,
2357
+ kind,
2358
+ from: from_session,
2359
+ title: title || null,
2360
+ message,
2361
+ severity,
2362
+ created_at: new Date().toISOString(),
2363
+ ...(meta && Object.keys(meta).length ? { meta } : {}),
2364
+ };
2365
+
2366
+ db.run(
2367
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
2368
+ VALUES (?1, ?2, 'send_desktop_message', 'user', ?3, ?4, ?5)`,
2369
+ [
2370
+ enforceUserId || null,
2371
+ callerAlias || null,
2372
+ target.user_id,
2373
+ JSON.stringify({
2374
+ message_id: messageId,
2375
+ to_username: target.username,
2376
+ from: from_session,
2377
+ severity,
2378
+ kind,
2379
+ title: title || null,
2380
+ meta_keys: meta && typeof meta === "object" ? Object.keys(meta) : [],
2381
+ }),
2382
+ effectiveNetId,
2383
+ ],
2384
+ );
2385
+ // #1459 — pushUserEvent 在没有订阅者时静默 return,而这个工具既不写
2386
+ // inbox、也没有任何 user 级回读路径(/api/messages 只 SELECT FROM inbox)。
2387
+ // 也就是说用户 dashboard 关着的时候这条消息就永久没了。
2388
+ //
2389
+ // 这里先把**丢失变可见**:`ok` 仍表示「请求被接受、审计已落库」,
2390
+ // 新增 `delivered` 表示「此刻真的有人收到」。判据取自订阅者注册表,
2391
+ // 不是猜的。持久化 + 重连补投是另一条(按 user_id 寻址的新收件表,#1459)。
2392
+ //
2393
+ // 🔴 `reason` 报的是**观测到的事实**(此刻没有活订阅者),不是它的后果。
2394
+ // 今天「没有活订阅者」确实等于「丢了」,但持久化落地之后同一个事实会
2395
+ // 变成「已入库、等重连补投」。把值取成 no_live_subscriber 而不是
2396
+ // "lost"/"dropped",就是为了那天不用改写历史含义 —— 届时按需细分
2397
+ // (如 lost vs queued)即可,现在不预先把语义焊死。
2398
+ const delivered = hasUserSubscribers(effectiveNetId, target.user_id);
2399
+ pushUserEvent(effectiveNetId, target.user_id, event);
2400
+
2401
+ return { content: [{ type: "text" as const, text: JSON.stringify({
2402
+ ok: true,
2403
+ message_id: messageId,
2404
+ delivered,
2405
+ ...(delivered ? {} : { reason: "no_live_subscriber" }),
2406
+ delivered_to_user_id: target.user_id,
2407
+ network_id: effectiveNetId,
2408
+ }) }] };
2409
+ },
2410
+ );
2411
+
2412
+ server.tool(
2413
+ "broadcast",
2414
+ "Send a message to multiple sessions.",
2415
+ {
2416
+ message: z.string().min(1).max(10000),
2417
+ filter_server: z.string().max(200).optional(),
2418
+ filter_status: z.string().max(50).optional(),
2419
+ network_id: z.string().max(200).optional().describe("Broadcast within a specific network"),
2420
+ },
2421
+ async ({ message, filter_server, filter_status, network_id: netId }) => {
2422
+ const effectiveNetId = getNetworkId(netId);
2423
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
2424
+ console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${effectiveNetId ? " [net=" + effectiveNetId.slice(0, 12) + "]" : ""}`);
1341
2425
  let sql = "SELECT alias, node_id, network_id FROM sessions WHERE alias IS NOT NULL";
1342
2426
  const params: any[] = [];
1343
2427
  sql = addScope(sql, params, effectiveNetId);
@@ -1346,26 +2430,39 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1346
2430
 
1347
2431
  const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
1348
2432
  const ids: string[] = [];
2433
+ // #1440 ① — the push loop must walk the recipients that actually
2434
+ // got an inbox row, not every candidate target. Walking `targets`
2435
+ // announced a broadcast to nodes the lifecycle guard had just
2436
+ // skipped, i.e. nodes with nothing to fetch.
2437
+ const delivered: Array<{ alias: string; netId: string | null }> = [];
1349
2438
 
1350
2439
  for (const t of targets) {
2440
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 6/6).
2441
+ // Broadcast skips non-active recipients silently rather than
2442
+ // failing the entire send — broadcast semantics are best-effort
2443
+ // per-recipient and a stopped node simply gets nothing.
2444
+ const netId = effectiveNetId ?? t.network_id ?? null;
2445
+ const lc = assertNodeActive(t.alias, netId);
2446
+ if (!lc.ok) continue;
1351
2447
  const id = uuidv4();
1352
2448
  db.run(
1353
2449
  `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
1354
2450
  VALUES (?1, ?2, ?3, 'broadcast', 'normal', ?4, 'hub', ?5)`,
1355
- [id, t.alias, t.node_id ?? null, message, effectiveNetId ?? t.network_id ?? null]
2451
+ [id, t.alias, t.node_id ?? null, message, netId]
1356
2452
  );
1357
2453
  ids.push(id);
2454
+ delivered.push({ alias: t.alias, netId });
1358
2455
  }
1359
2456
 
1360
- for (const t of targets) {
1361
- pushEvent(t.alias, { type: "broadcast", inbox_count: 1 }, effectiveNetId ?? t.network_id ?? null);
2457
+ for (const d of delivered) {
2458
+ pushEvent(d.alias, { type: "broadcast", inbox_count: pendingInboxCount(d.alias, d.netId) }, d.netId);
1362
2459
  }
1363
2460
 
1364
2461
  return {
1365
2462
  content: [
1366
2463
  {
1367
2464
  type: "text" as const,
1368
- text: JSON.stringify({ ok: true, recipients: targets.length, message_ids: ids }),
2465
+ text: JSON.stringify({ ok: true, recipients: ids.length, message_ids: ids }),
1369
2466
  },
1370
2467
  ],
1371
2468
  };
@@ -1458,15 +2555,34 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1458
2555
  "update_node_config",
1459
2556
  "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.",
1460
2557
  {
1461
- node_id: z.string().min(1).max(200).describe("Target node ID (not alias). Must be in caller's network."),
2558
+ ...NODE_ID_ALIAS_FIELDS,
1462
2559
  base_revision: z.number().int().min(0).describe("Current revision per the dashboard's last GET — 409 if hub's current revision differs."),
1463
2560
  patch: z.object({
1464
2561
  model: z.string().max(200).optional(),
1465
2562
  flags: z.record(z.unknown()).optional(),
2563
+ // #260 P5 — channel enable/disable. Restart-tier field (agent-node
2564
+ // reads config.channels once at boot to fork per-channel workers,
2565
+ // so the swap takes effect via process restart). Not
2566
+ // SECURITY_SENSITIVE — this is a lifecycle op, gated by SEC-1
2567
+ // (network scope) only.
2568
+ //
2569
+ // Deliberately `z.array(z.unknown()).max(16)` (mirrors flags's
2570
+ // `z.record(z.unknown())`): trust nothing at the wire boundary,
2571
+ // narrow the same way `narrowChannelsPatch` narrows raw untrusted
2572
+ // JSON (typeof + allowlist + dedup + case-fold). A strict
2573
+ // `z.array(z.string())` would fail-fast on a single non-string
2574
+ // entry, but the wire contract wants junk silently dropped so a
2575
+ // dashboard fat-finger doesn't turn into a 400 the user has to
2576
+ // interpret. validatePatch then re-rejects if the caller bypassed
2577
+ // narrowing.
2578
+ channels: z.array(z.unknown()).max(16).optional(),
1466
2579
  }).describe("Fields to update. Empty patch → no-op (use restart_node for that)."),
1467
2580
  network_id: z.string().max(200).optional(),
1468
2581
  },
1469
- async ({ node_id: nodeId, base_revision: baseRev, patch, network_id: clientNetId }) => {
2582
+ async ({ node_id, child_node_id, base_revision: baseRev, patch, network_id: clientNetId }) => {
2583
+ const idArg = resolveNodeIdArg({ node_id, child_node_id });
2584
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
2585
+ const nodeId = idArg.node_id;
1470
2586
  const effectiveNetId = getNetworkId(clientNetId);
1471
2587
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
1472
2588
 
@@ -1480,6 +2596,50 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1480
2596
 
1481
2597
  const model = typeof patch.model === "string" ? patch.model : undefined;
1482
2598
  const flags = (patch.flags && typeof patch.flags === "object") ? patch.flags as Record<string, unknown> : {};
2599
+ // Narrow untrusted `channels` at the boundary (typeof + allowlist +
2600
+ // dedup + case-fold), and distinguish two very different cases
2601
+ // that both narrow to `[]`:
2602
+ // (a) `patch.channels === []` (explicit "disable all editable
2603
+ // channels"). Downstream must proceed and write channels=[]
2604
+ // so the node's next restart forks no workers.
2605
+ // (b) `patch.channels === ["commhub"]` or similar — the caller
2606
+ // sent items but every single one was invalid (dashboard PR
2607
+ // #31 still ships commhub, and a typo like "telegarm" would
2608
+ // hit the same path). Downstream MUST NOT treat this as
2609
+ // (a) — silently converting to disable-all would nuke the
2610
+ // user's existing telegram/feishu workers.
2611
+ //
2612
+ // The reject error is `channels_all_invalid` so the dashboard
2613
+ // can surface the mismatch instead of showing a false success.
2614
+ let channels: string[] | undefined = undefined;
2615
+ if (patch.channels !== undefined) {
2616
+ if (!Array.isArray(patch.channels)) {
2617
+ // Zod already permits arrays only; belt+braces if it drifts.
2618
+ return {
2619
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_patch", field: "channels", reason: "must be an array" }) }],
2620
+ };
2621
+ }
2622
+ const narrowed = narrowChannelsPatch(patch.channels) ?? [];
2623
+ if (patch.channels.length === 0) {
2624
+ channels = []; // (a) explicit disable-all
2625
+ } else if (narrowed.length === 0) {
2626
+ // (b) every entry was invalid — refuse to write. Distinct
2627
+ // error so the dashboard can surface the failure.
2628
+ return {
2629
+ content: [{
2630
+ type: "text" as const,
2631
+ text: JSON.stringify({
2632
+ ok: false,
2633
+ error: "channels_all_invalid",
2634
+ requested: patch.channels,
2635
+ message: "every requested channel is unknown or unsupported; use an explicit empty array to disable-all",
2636
+ }),
2637
+ }],
2638
+ };
2639
+ } else {
2640
+ channels = narrowed;
2641
+ }
2642
+ }
1483
2643
 
1484
2644
  // SEC-2 (final policy 2026-06-28) — security-sensitive flags
1485
2645
  // (permissionMode / dangerouslySkipPermissions / teammateMode)
@@ -1506,7 +2666,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1506
2666
  };
1507
2667
  }
1508
2668
 
1509
- const validationFail = validatePatch(model, flags);
2669
+ const validationFail = validatePatch(model, flags, channels);
1510
2670
  if (validationFail) {
1511
2671
  return {
1512
2672
  content: [{
@@ -1584,8 +2744,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1584
2744
 
1585
2745
  // Compute apply_mode + persist + push doorbell.
1586
2746
  const updateId = `cu_${uuidv4()}`;
1587
- const applyMode = computeApplyMode(model, flags);
1588
- const patchJson = JSON.stringify({ ...(model !== undefined ? { model } : {}), flags });
2747
+ const applyMode = computeApplyMode(model, flags, channels);
2748
+ const patchJson = JSON.stringify({
2749
+ ...(model !== undefined ? { model } : {}),
2750
+ flags,
2751
+ ...(channels !== undefined ? { channels } : {}),
2752
+ });
1589
2753
  const networkId = node.network_id || "default";
1590
2754
  db.run(
1591
2755
  `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)`,
@@ -1727,10 +2891,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1727
2891
  "restart_node",
1728
2892
  "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).",
1729
2893
  {
1730
- node_id: z.string().min(1).max(200),
2894
+ ...NODE_ID_ALIAS_FIELDS,
1731
2895
  network_id: z.string().max(200).optional(),
1732
2896
  },
1733
- async ({ node_id: nodeId, network_id: clientNetId }) => {
2897
+ async ({ node_id, child_node_id, network_id: clientNetId }) => {
2898
+ const idArg = resolveNodeIdArg({ node_id, child_node_id });
2899
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
2900
+ const nodeId = idArg.node_id;
1734
2901
  const effectiveNetId = getNetworkId(clientNetId);
1735
2902
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
1736
2903
 
@@ -1762,10 +2929,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1762
2929
  }
1763
2930
  const updateId = `cu_${uuidv4()}`;
1764
2931
  const networkId = node.network_id || "default";
1765
- db.run(
1766
- `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)`,
1767
- [updateId, nodeId, networkId, node.config_revision || 0, Date.now(), callerTokenId || "unknown"],
1768
- );
2932
+ // RFC-027 PR1.2a latent fix (#346 ack): restart_node must reset
2933
+ // lifecycle_state to 'active'. Otherwise a node that was previously
2934
+ // stop_node'd → 'stopped' would, after restart, stay marked
2935
+ // 'stopped' in the nodes table → the 6 MCP + 2 REST inbox guards
2936
+ // would refuse every routing attempt → node silently unreachable
2937
+ // (no error to operator, just no traffic). Pair the schema flip
2938
+ // with the config_updates INSERT inside one tx so we don't
2939
+ // half-commit if the dispatch INSERT throws.
2940
+ db.transaction(() => {
2941
+ db.run(
2942
+ `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)`,
2943
+ [updateId, nodeId, networkId, node.config_revision || 0, Date.now(), callerTokenId || "unknown"],
2944
+ );
2945
+ db.run(
2946
+ `UPDATE nodes SET lifecycle_state = 'active' WHERE node_id = ?1`,
2947
+ [nodeId],
2948
+ );
2949
+ });
1769
2950
  pushEvent(node.alias, { type: "restart", update_id: updateId }, networkId);
1770
2951
  return {
1771
2952
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update_id: updateId, apply_mode: "restart_only" }) }],
@@ -1798,6 +2979,143 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1798
2979
  throw e;
1799
2980
  };
1800
2981
 
2982
+ // RFC-026 §9.2.1 / #338 PR2 — list_host_supervisors.
2983
+ // Surfaces host_supervisor daemons in the caller's network with
2984
+ // online flag + declared capabilities. Replaces the prior `node_daemon_`
2985
+ // prefix heuristic (dashboard /api/anet/node-create) + the role-extract
2986
+ // path on /api/nodes (#337). Member脱敏 strips host_telemetry IP /
2987
+ // cpu / mem (per RFC-026 §6 #3 — daemons can leak internal topology
2988
+ // to non-admin readers). Revoked daemon ntoks filtered out via
2989
+ // join on api_tokens.revoked_at.
2990
+ server.tool(
2991
+ "list_host_supervisors",
2992
+ "List host_supervisor daemon nodes in the caller's network (online status + runtimes_supported + allowed_secret_keys + telemetry; member-脱敏). RFC-026 §9.2.1.",
2993
+ {
2994
+ network_id: z.string().max(200).optional(),
2995
+ },
2996
+ async ({ network_id: clientNetId }) => {
2997
+ // SEC-1 — use resolveReadScope, NOT getNetworkId. getNetworkId is
2998
+ // for write-tool helpers and pairs with `canWrite`; READ tools that
2999
+ // bypass canWrite and trust getNetworkId leak across tenants
3000
+ // (PR2 v1 BLOCKER per 通信龙 audit — utok_ caller with
3001
+ // enforceNetworkId=null could pass any network_id and read daemon
3002
+ // names + allowed_secret_keys for tenants they're not a member of).
3003
+ // resolveReadScope checks network_members for the user + denies
3004
+ // on non-membership, mirroring REST resolveRestNetworkScope.
3005
+ const readScope = resolveReadScope(clientNetId);
3006
+ if (readScope.denied) {
3007
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
3008
+ }
3009
+ // Caller role for 脱敏 decision (admin/owner = full, others = masked).
3010
+ // Use the resolved scope's networkId (the verified one), not the
3011
+ // unchecked clientNetId.
3012
+ const scopedNetId = readScope.networkId ?? null;
3013
+ const callerRole = enforceUserId && scopedNetId
3014
+ ? getUserNetworkRole(enforceUserId, scopedNetId)
3015
+ : null;
3016
+ // Network-bound tokens get full telemetry within their bound network
3017
+ // (already gate-locked by enforceNetworkId path of resolveReadScope).
3018
+ const isPrivileged = callerRole === "admin" || callerRole === "owner" || (!enforceUserId);
3019
+
3020
+ // Pull candidate daemons. Active-token EXISTS subquery handles BOTH
3021
+ // revoked daemon ntoks (revoked_at set) AND DELETEd token rows
3022
+ // (the standard revokeToken path deletes the row, not flagging
3023
+ // revoked_at — PR2 v1 SHOULD-FIX per 通信龙 audit). EXISTS naturally
3024
+ // dedupes if a daemon ever had multiple tokens (e.g. rotation; nit ⚪).
3025
+ let sql = `
3026
+ SELECT
3027
+ n.node_id, n.alias, n.hostname, n.network_id,
3028
+ n.runtimes_supported, n.allowed_secret_keys,
3029
+ n.created_at, n.updated_at,
3030
+ s.last_seen_at AS session_last_seen,
3031
+ s.status AS session_status,
3032
+ s.cpu_cores AS session_cpu_cores,
3033
+ s.mem_total_gb AS session_mem_total_gb,
3034
+ s.ip AS session_ip,
3035
+ n.config_snapshot
3036
+ FROM nodes n
3037
+ LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
3038
+ WHERE EXISTS (
3039
+ SELECT 1 FROM api_tokens t
3040
+ WHERE t.network_id = n.network_id
3041
+ AND t.name = 'node:' || n.alias
3042
+ AND t.revoked_at IS NULL
3043
+ )
3044
+ `;
3045
+ const sqlParams: any[] = [];
3046
+ sql = addReadScope(sql, sqlParams, readScope, "n.network_id");
3047
+ sql += ` ORDER BY n.updated_at DESC`;
3048
+ const rows = db.all<Record<string, any>>(sql, ...sqlParams);
3049
+
3050
+ // Filter to role=host_supervisor (read from config_snapshot;
3051
+ // schema-promoted columns runtimes_supported/allowed_secret_keys
3052
+ // are pre-extracted but role isn't a first-class column).
3053
+ const nowMs = Date.now();
3054
+ // 心跳周期是 3 分钟(agent-node/src/cli.ts 的 report_status 定时器),
3055
+ // 窗口必须大于它,否则每次心跳后的 60s~180s 之间必然抖成 offline
3056
+ // (通信评审牛 #1279 独审④)。取 5min,与仓内既有 stale 口径一致。
3057
+ const ONLINE_MS = 5 * 60_000;
3058
+ const daemons = rows
3059
+ .map(r => {
3060
+ let snapRole: string | null = null;
3061
+ if (r.config_snapshot) {
3062
+ try {
3063
+ const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
3064
+ snapRole = typeof parsed?.role === "string" ? parsed.role : null;
3065
+ } catch { /* malformed snapshot — role stays null */ }
3066
+ }
3067
+ return { row: r, role: snapRole };
3068
+ })
3069
+ .filter(({ role }) => role === "host_supervisor")
3070
+ .map(({ row: r }) => {
3071
+ // online = sessions.last_seen_at within ONLINE_MS
3072
+ let online = false;
3073
+ let lastSeenAt: string | null = null;
3074
+ if (r.session_last_seen) {
3075
+ lastSeenAt = r.session_last_seen;
3076
+ const t = parseDbTimestampMs(r.session_last_seen);
3077
+ if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
3078
+ }
3079
+ // Parse self-declare arrays (default to [] for pre-PR2 daemons)
3080
+ let runtimes: string[] = [];
3081
+ let secrets: string[] = [];
3082
+ try {
3083
+ if (r.runtimes_supported) {
3084
+ const parsed = JSON.parse(r.runtimes_supported);
3085
+ runtimes = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
3086
+ }
3087
+ } catch { /* malformed — empty */ }
3088
+ try {
3089
+ if (r.allowed_secret_keys) {
3090
+ const parsed = JSON.parse(r.allowed_secret_keys);
3091
+ secrets = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
3092
+ }
3093
+ } catch { /* malformed — empty */ }
3094
+ // host_telemetry — member脱敏 drops IP/cpu/mem
3095
+ const telemetry: Record<string, unknown> = {
3096
+ alert_level: online ? "green" : "gray",
3097
+ };
3098
+ if (isPrivileged) {
3099
+ telemetry.cpu_cores = r.session_cpu_cores ?? null;
3100
+ telemetry.mem_gb = r.session_mem_total_gb ?? null;
3101
+ telemetry.ip_internal = r.session_ip ?? null;
3102
+ }
3103
+ return {
3104
+ daemon_node_id: r.node_id,
3105
+ alias: r.alias,
3106
+ hostname: r.hostname,
3107
+ online,
3108
+ last_seen_at: lastSeenAt,
3109
+ runtimes_supported: runtimes,
3110
+ allowed_secret_keys: secrets,
3111
+ host_telemetry: telemetry,
3112
+ };
3113
+ });
3114
+
3115
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, daemons, count: daemons.length }) }] };
3116
+ },
3117
+ );
3118
+
1801
3119
  // §2.5 step 1+2 — dashboard-facing tool. Validates spec, mint
1802
3120
  // child-ntok, stash env_blob in pendingEnvBlobs Map, write request
1803
3121
  // row (metadata only — no env_blob in SQL), pushEvent doorbell.
@@ -1809,7 +3127,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1809
3127
  node_spec: z.object({
1810
3128
  name: z.string().min(1).max(64),
1811
3129
  runtime: z.string().min(1).max(64),
1812
- model: z.string().min(1).max(100),
3130
+ model: z.string().min(1).max(100).optional().nullable(),
1813
3131
  flags: z.record(z.unknown()).optional(),
1814
3132
  env_refs: z.array(z.string().max(64)).optional(),
1815
3133
  channels: z.array(z.unknown()).optional(),
@@ -1840,21 +3158,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1840
3158
  }
1841
3159
 
1842
3160
  // Read daemon's host_supervisor capability + allowlist from its
1843
- // last reported config_snapshot. P1 simplification: we accept
1844
- // (allowed_runtimes empty / allowed_secret_keys absent) as
1845
- // "accept any in the global enum"; P2 will tighten when daemon
1846
- // self-publishes its own allowlist via daemon_capabilities.
3161
+ // last reported config_snapshot. PR3 (#338) canonical path is
3162
+ // `daemon_capabilities.runtimes_supported` (RFC-026 §9.3).
3163
+ // Pre-PR3 daemons (preview.10 and earlier) place these at the
3164
+ // TOP level of the snapshot rather than nested — those reads
3165
+ // return undefined here and `daemonAllowedRuntimes` stays null
3166
+ // (permissive — no allowlist enforcement); they fall back to
3167
+ // §4.2.2 structural validation only, identical to pre-PR3
3168
+ // behavior. No regression on in-flight daemons.
1847
3169
  let daemonAllowList = new Set<string>();
1848
3170
  let daemonAllowedRuntimes: string[] | null = null;
1849
3171
  try {
1850
3172
  const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
1851
- if (snap?.daemon_capabilities?.allowed_secret_keys) {
1852
- daemonAllowList = new Set(snap.daemon_capabilities.allowed_secret_keys);
3173
+ const caps = snap?.daemon_capabilities;
3174
+ if (Array.isArray(caps?.allowed_secret_keys)) {
3175
+ daemonAllowList = new Set(caps.allowed_secret_keys);
1853
3176
  }
1854
- if (Array.isArray(snap?.daemon_capabilities?.allowed_runtimes)) {
1855
- daemonAllowedRuntimes = snap.daemon_capabilities.allowed_runtimes;
3177
+ if (Array.isArray(caps?.runtimes_supported)) {
3178
+ daemonAllowedRuntimes = caps.runtimes_supported;
1856
3179
  }
1857
- } catch { /* permissive P1 fallback */ }
3180
+ } catch { /* permissive fallback */ }
1858
3181
 
1859
3182
  // §4.2.2 — structural validation (catches name/runtime/model/
1860
3183
  // flag injection at the hub edge). Daemon repeats this; double
@@ -1960,7 +3283,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1960
3283
  VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
1961
3284
  [
1962
3285
  requestId, daemon_node_id, node_spec.name, networkIdForChild,
1963
- node_spec.runtime, node_spec.model, JSON.stringify(node_spec.flags || {}),
3286
+ node_spec.runtime, node_spec.model ?? null, JSON.stringify(node_spec.flags || {}),
1964
3287
  JSON.stringify(envKeys), childTokenId, Date.now(), callerTokenId || "unknown",
1965
3288
  ],
1966
3289
  );
@@ -1970,9 +3293,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1970
3293
  // SSE handler resolves the rest via MCP call.
1971
3294
  pushEvent(daemon.alias, { type: "create_node", request_id: requestId }, networkIdForChild);
1972
3295
 
1973
- // §4.5 audit — dispatch succeeded
3296
+ // §4.5 audit — request queued and doorbell attempted. pushEvent is
3297
+ // fire-and-forget, so actual SSE delivery is intentionally not claimed.
1974
3298
  auditCreateNode({
1975
- action: "create_node_dispatched",
3299
+ action: "create_node_dispatch_attempted",
1976
3300
  user_id: enforceUserId,
1977
3301
  network_id: networkIdForChild,
1978
3302
  target_id: requestId,
@@ -1980,7 +3304,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1980
3304
  daemon_node_id,
1981
3305
  child_name: node_spec.name,
1982
3306
  runtime: node_spec.runtime,
1983
- model: node_spec.model,
3307
+ model: node_spec.model ?? null,
1984
3308
  flag_keys: Object.keys(node_spec.flags || {}),
1985
3309
  env_keys: envKeys,
1986
3310
  },
@@ -1992,6 +3316,78 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1992
3316
  },
1993
3317
  );
1994
3318
 
3319
+ // #1362 — daemon-side compensation for missed SSE doorbells. pushEvent is
3320
+ // intentionally best-effort; after a daemon connects or reconnects, it pulls
3321
+ // any still-pending requests bound to its own daemon token.
3322
+ server.tool(
3323
+ "list_my_pending_create_requests",
3324
+ "Daemon lists pending create-node requests bound to itself for SSE reconnect compensation. RFC-026/#1362.",
3325
+ {},
3326
+ async () => {
3327
+ const callerDaemon = resolveCallerDaemonTokenBound();
3328
+ if (!callerDaemon.ok) {
3329
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
3330
+ }
3331
+ const rows = db.all<{ request_id: string; child_name: string; created_at: number }>(
3332
+ `SELECT request_id, child_name, created_at
3333
+ FROM node_create_requests
3334
+ WHERE daemon_node_id = ?1
3335
+ AND network_id = ?2
3336
+ AND status = 'pending'
3337
+ ORDER BY created_at ASC`,
3338
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
3339
+ );
3340
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: rows.length, requests: rows }) }] };
3341
+ },
3342
+ );
3343
+
3344
+ // #1448 — stop/delete/start 门铃的 SSE 重连补偿,镜像 create 的
3345
+ // list_my_pending_create_requests(#1394)。pushEvent(push.ts) 是纯内存
3346
+ // fan-out:派发门铃那刻若 daemon 无 live SSE 订阅者(hub 重启/网络抖/boot
3347
+ // 窗口)就静默丢弃、不入队,于是 node_stop_requests/node_start_requests 的行
3348
+ // 永远停在 pending、节点卡死 stopping/deleting/starting。daemon 每次 SSE
3349
+ // connected 调本工具把漏掉的门铃拉回来重放(reconcilePendingLifecycleRequestsOnConnect)。
3350
+ //
3351
+ // 统一一个工具返回两张表的 pending 行(stop 表用 action 区分 stop/delete),
3352
+ // 省一次往返。鉴权同 create:token-bound daemon 身份(alias 不是安全边界),
3353
+ // 只返回绑定到本 daemon + 本网络的 pending 行。
3354
+ server.tool(
3355
+ "list_my_pending_lifecycle_requests",
3356
+ "Daemon lists pending stop/delete/start requests bound to itself for SSE reconnect compensation. Mirrors list_my_pending_create_requests (#1394/#1448).",
3357
+ {},
3358
+ async () => {
3359
+ const callerDaemon = resolveCallerDaemonTokenBound();
3360
+ if (!callerDaemon.ok) {
3361
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
3362
+ }
3363
+ const stop_requests = db.all<{ request_id: string; action: string; child_node_id: string; child_alias: string; created_at: number }>(
3364
+ `SELECT request_id, action, child_node_id, child_alias, created_at
3365
+ FROM node_stop_requests
3366
+ WHERE daemon_node_id = ?1
3367
+ AND network_id = ?2
3368
+ AND status = 'pending'
3369
+ ORDER BY created_at ASC`,
3370
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
3371
+ );
3372
+ const start_requests = db.all<{ request_id: string; child_node_id: string; child_alias: string; created_at: number }>(
3373
+ `SELECT request_id, child_node_id, child_alias, created_at
3374
+ FROM node_start_requests
3375
+ WHERE daemon_node_id = ?1
3376
+ AND network_id = ?2
3377
+ AND status = 'pending'
3378
+ ORDER BY created_at ASC`,
3379
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
3380
+ );
3381
+ return { content: [{ type: "text" as const, text: JSON.stringify({
3382
+ ok: true,
3383
+ stop_count: stop_requests.length,
3384
+ start_count: start_requests.length,
3385
+ stop_requests,
3386
+ start_requests,
3387
+ }) }] };
3388
+ },
3389
+ );
3390
+
1995
3391
  // §2.5 step 3 — daemon-facing pull. Returns full spec + env_blob +
1996
3392
  // child_ntok in one shot. Map evicted on take (one-shot consume).
1997
3393
  //
@@ -2044,7 +3440,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2044
3440
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "env_blob_unavailable" }) }] };
2045
3441
  }
2046
3442
  // Hydrate spec from row.
2047
- const specRow = db.get<{ child_name: string; runtime: string; model: string; flags_json: string }>(
3443
+ const specRow = db.get<{ child_name: string; runtime: string; model: string | null; flags_json: string }>(
2048
3444
  `SELECT child_name, runtime, model, flags_json FROM node_create_requests WHERE request_id = ?1`,
2049
3445
  request_id,
2050
3446
  );
@@ -2084,14 +3480,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2084
3480
  // record fork-side info.
2085
3481
  server.tool(
2086
3482
  "ack_create_request",
2087
- "Daemon acks a create-node request (called after fork). status='started' or 'failed'. RFC-026.",
3483
+ "Daemon acks a create-node request (called after fork). status='started' | 'failed' | 'rejected' | 'runtime_capability_check_failed'. RFC-026 §9.3 D2.",
2088
3484
  {
2089
3485
  request_id: z.string().min(1).max(200),
2090
- status: z.enum(["started", "failed", "rejected"]),
3486
+ // RFC-026 §9.3 D2 — runtime_capability_check_failed signals the
3487
+ // daemon spawned the child OK but it died within FAIL_FAST_MS
3488
+ // (5s in agent-node v2.5.0-preview.11+), indicating a
3489
+ // declaration↔reality gap on this daemon's runtimes_supported.
3490
+ // Treated terminal like 'failed' but fires a distinct audit_log
3491
+ // action so dashboards can highlight "lying daemons" separately
3492
+ // from generic spawn failures.
3493
+ status: z.enum(["started", "failed", "rejected", "runtime_capability_check_failed"]),
2091
3494
  error: z.string().max(1000).optional(),
2092
3495
  child_pid: z.number().int().optional(),
3496
+ runtime: z.string().max(64).optional(), // populated by daemon when status=runtime_capability_check_failed
2093
3497
  },
2094
- async ({ request_id, status, error: ackError, child_pid: _pid }) => {
3498
+ async ({ request_id, status, error: ackError, child_pid: _pid, runtime: ackRuntime }) => {
2095
3499
  const callerDaemon = resolveCallerDaemonTokenBound();
2096
3500
  if (!callerDaemon.ok) {
2097
3501
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
@@ -2117,7 +3521,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2117
3521
  );
2118
3522
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "awaiting_register" }) }] };
2119
3523
  }
2120
- // failed / rejected — revoke child-ntok + mark request terminal
3524
+ // failed / rejected / runtime_capability_check_failed — revoke
3525
+ // child-ntok + mark request terminal.
2121
3526
  if (row.child_token_id) {
2122
3527
  db.run(`UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = ?1 AND revoked_at IS NULL`, [row.child_token_id]);
2123
3528
  }
@@ -2125,9 +3530,1256 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2125
3530
  `UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3 WHERE request_id = ?4 AND status IN ('pending', 'delivered')`,
2126
3531
  [status, ackError || null, ackedAt, request_id],
2127
3532
  );
3533
+ // RFC-026 §9.3 D2 — surface declaration↔reality gap on a
3534
+ // distinct audit_log action so dashboards / operators can spot
3535
+ // chronically-lying daemons separate from generic spawn-failed.
3536
+ if (status === "runtime_capability_check_failed") {
3537
+ auditCreateNode({
3538
+ action: "daemon_capability_lied",
3539
+ user_id: null,
3540
+ network_id: row.network_id,
3541
+ target_id: request_id,
3542
+ detail: {
3543
+ daemon_node_id: row.daemon_node_id,
3544
+ runtime: ackRuntime || null,
3545
+ error: ackError ? ackError.slice(0, 500) : null,
3546
+ acked_at: ackedAt,
3547
+ },
3548
+ });
3549
+ }
3550
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
3551
+ },
3552
+ );
3553
+
3554
+ // ─── RFC-027 §2 — stop/delete node lifecycle ───────────────────────
3555
+ //
3556
+ // Two user-facing MCP tools (stop_node, delete_node) + two daemon-
3557
+ // facing tools (get_stop_request, ack_stop_request). State machine
3558
+ // per §2.3 — nodes.lifecycle_state ∈ {active, stopping, stopped,
3559
+ // deleting}. row-gone implies deleted. Security per §4:
3560
+ // §4.1 SEC-1: trust-root SQL join, not getNetworkId
3561
+ // §4.2 D6: delete_node refuses target.role==host_supervisor
3562
+ // §4.3 D4: in-flight inbox default-refuse + force+audit
3563
+ // §4.4 D7: daemon writes backup chmod 700, sweeper真删
3564
+ // §4.5 D8: audit_log in same SQLite tx as state UPDATE
3565
+ //
3566
+ // The dispatcher logic shared by stop_node + delete_node lives in
3567
+ // dispatchStopOrDelete; the two tool handlers thin-wrap it with
3568
+ // their respective action discriminator.
3569
+ type DispatchAction = "stop" | "delete";
3570
+ type DispatchArgs = {
3571
+ action: DispatchAction;
3572
+ child_node_id: string;
3573
+ daemon_node_id: string;
3574
+ force: boolean;
3575
+ grace_seconds: number;
3576
+ delete_config: boolean;
3577
+ confirm_alias?: string;
3578
+ };
3579
+ // RFC-027 PR2 prereq — auto-resolve daemon_node_id from child_node_id.
3580
+ // The dashboard (and most callers of stop_node / delete_node) shouldn't
3581
+ // need to track the daemon→child mapping themselves; the hub has it on
3582
+ // node_create_requests at child creation time. Returns null when no
3583
+ // creation record exists (orphan node row OR pre-RFC-026 node) — caller
3584
+ // must then pass daemon_node_id explicitly. node_id derivation:
3585
+ // `node_${request_id.replace(/^cr_/,"")}` (see create-node-daemon.ts
3586
+ // and PR1 BLOCKER-1 fix), so we reverse it: `cr_${node_id.slice(5)}`.
3587
+ const resolveDaemonForChild = (child_node_id: string): string | null => {
3588
+ if (!child_node_id.startsWith("node_")) return null;
3589
+ const requestId = `cr_${child_node_id.slice(5)}`;
3590
+ const row = db.get<{ daemon_node_id: string }>(
3591
+ `SELECT daemon_node_id FROM node_create_requests WHERE request_id = ?1`,
3592
+ requestId,
3593
+ );
3594
+ return row?.daemon_node_id ?? null;
3595
+ };
3596
+
3597
+ const dispatchStopOrDelete = (args: DispatchArgs, clientNetId?: string | null) => {
3598
+ // §4.1 — SEC-1 trust-root join: resolve target node row WITHIN the
3599
+ // caller's scope. resolveReadScope returns 'denied' if the caller
3600
+ // doesn't belong to clientNetId; for the row-existence test we
3601
+ // re-join with member's networks so an attacker can't probe by
3602
+ // node_id from a network they don't belong to.
3603
+ const scope = resolveReadScope(clientNetId);
3604
+ if (scope.denied) {
3605
+ return { ok: false, error: "forbidden_cross_tenant", message: scope.denied };
3606
+ }
3607
+ const nodeQ: any[] = [args.child_node_id];
3608
+ let nodeSql = `SELECT node_id, alias, network_id, lifecycle_state, config_snapshot, runtimes_supported
3609
+ FROM nodes WHERE node_id = ?1`;
3610
+ nodeSql = addReadScope(nodeSql, nodeQ, scope);
3611
+ const node = db.get<{
3612
+ node_id: string;
3613
+ alias: string;
3614
+ network_id: string;
3615
+ lifecycle_state: string | null;
3616
+ config_snapshot: string | null;
3617
+ }>(nodeSql, ...nodeQ);
3618
+ if (!node) return { ok: false, error: "forbidden_cross_tenant", message: "node not found in your networks" };
3619
+
3620
+ // Caller must hold a write-capable role on the resolved network
3621
+ // (admin/owner/member; viewer denied). canWrite walks getUserNetworkRole.
3622
+ if (!canWrite(node.network_id)) {
3623
+ return { ok: false, error: "permission_denied", message: "viewer role cannot stop/delete nodes" };
3624
+ }
3625
+
3626
+ // §4.2 D6 — delete refuses targets whose role is host_supervisor.
3627
+ // Stop is allowed against any node per RFC table (host_supervisor's
3628
+ // stop is functionally a no-op anyway: the daemon's children_map
3629
+ // only tracks its child nodes, never the daemon itself).
3630
+ //
3631
+ // PR1 SF-4 (#345 review) — fail-CLOSED on role read. The first cut
3632
+ // parsed config_snapshot and treated parse-fail / missing-field as
3633
+ // role=null which slipped through the gate. A daemon row with a
3634
+ // corrupt snapshot would then be deletable via delete_node. Fix:
3635
+ // - read role via two independent paths (snapshot.role + the
3636
+ // indexed `runtimes_supported` column whose presence ≈ daemon)
3637
+ // - if EITHER path indicates host_supervisor → refuse
3638
+ // - if snapshot parsed but role missing AND the node has any
3639
+ // daemon-shape evidence (non-null runtimes_supported column,
3640
+ // present in any node_create_requests as daemon_node_id) →
3641
+ // refuse defensively. The defense-in-depth daemon-side
3642
+ // children_map miss is the second layer; the hub gate must
3643
+ // itself fail-closed when role is ambiguous.
3644
+ if (args.action === "delete") {
3645
+ let role: string | null = null;
3646
+ let snapshotParseFailed = false;
3647
+ try {
3648
+ const snap = node.config_snapshot ? JSON.parse(node.config_snapshot) : null;
3649
+ role = typeof snap?.role === "string" ? snap.role : null;
3650
+ } catch { snapshotParseFailed = true; }
3651
+ const ambiguous = snapshotParseFailed || (role == null);
3652
+ // Independent corroborating check: nodes flagged as daemons by
3653
+ // create_node dispatchers will show up as daemon_node_id in
3654
+ // node_create_requests. Costs one indexed query.
3655
+ let looksLikeDaemon = false;
3656
+ if (ambiguous) {
3657
+ const m = db.get<{ n: number }>(
3658
+ `SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1`,
3659
+ node.node_id,
3660
+ );
3661
+ if ((m?.n ?? 0) > 0) looksLikeDaemon = true;
3662
+ }
3663
+ if (role === "host_supervisor" || (ambiguous && looksLikeDaemon)) {
3664
+ return {
3665
+ ok: false, error: "cannot_delete_daemon_via_delete_node",
3666
+ message: snapshotParseFailed
3667
+ ? "node config_snapshot unreadable; node is referenced as a daemon — refuse delete defensively (use delete_daemon path)"
3668
+ : "host_supervisor daemons must be removed via the dedicated delete_daemon path (RFC-027.5)",
3669
+ };
3670
+ }
3671
+ }
3672
+
3673
+ // delete_node confirm_alias gate: dashboard's二次确认 input must
3674
+ // match the actual alias byte-for-byte. Hub rejects mismatched
3675
+ // input even if dashboard UI claims it's disabled.
3676
+ if (args.action === "delete") {
3677
+ if (typeof args.confirm_alias !== "string" || args.confirm_alias !== node.alias) {
3678
+ return { ok: false, error: "confirm_alias_mismatch", message: "confirm_alias must equal the node's alias" };
3679
+ }
3680
+ }
3681
+
3682
+ // Daemon must exist + be in the same network. We do NOT enforce
3683
+ // daemon.role==host_supervisor here: a child whose creator daemon
3684
+ // got demoted should still be stoppable. The daemon will refuse
3685
+ // with noop_not_my_child if children_map doesn't know it.
3686
+ //
3687
+ // PR1.2 e2e catch (BLOCKER): pushEvent keys SSE by clientKey ≈
3688
+ // `${networkId}:${sessionName}`, and agent-node registers its SSE
3689
+ // connection under its ALIAS (not its node_id). The create_node
3690
+ // doorbell at line ~2197 correctly pushes via `daemon.alias`;
3691
+ // stop_node was pushing via `daemon_node_id` so every doorbell
3692
+ // missed every SSE client → daemon never woke up → request_id
3693
+ // stayed 'pending' forever → child never reaped. The 24 unit tests
3694
+ // in stop-delete-node.test.ts all mock pushEvent and inject the
3695
+ // post-doorbell handler call directly, so single-unit coverage
3696
+ // couldn't surface this — exactly the failure class 通信龙 said
3697
+ // the docker e2e gate exists to catch (BLOCKER-1 同源). Load the
3698
+ // alias along with node_id + network_id and dispatch to the alias.
3699
+ const daemon = db.get<{ node_id: string; alias: string; network_id: string }>(
3700
+ `SELECT node_id, alias, network_id FROM nodes WHERE node_id = ?1`, args.daemon_node_id,
3701
+ );
3702
+ if (!daemon) return { ok: false, error: "daemon_not_found" };
3703
+ if (daemon.network_id !== node.network_id) {
3704
+ // Cross-tenant child↔daemon mismatch shouldn't be possible in
3705
+ // normal flow but is a SEC-1 hardening — refuse loud.
3706
+ return { ok: false, error: "daemon_cross_tenant", message: "daemon and child are in different networks" };
3707
+ }
3708
+
3709
+ // #1448 finding-4 — daemon 必须是该 child 的**权威创建者**,镜像 start_node 的
3710
+ // daemon_child_mismatch。此前 stop/delete 接受调用方传入的 daemon_node_id,只校验
3711
+ // 它存在 + 同网,不验它真的创建了这个 child。同网调用方传错 daemon_node_id → 门铃
3712
+ // 路由到错 daemon。🔴 f3(#1453) 合入后更糟:错 daemon 无该 child 的 map entry → 走
3713
+ // 收敛分支 sweep(错机器 pgrep 空) + ack stopped → hub 假收敛 stopped、child 仍在
3714
+ // 正确机器上跑(旧 noop 至少留下可见的卡死态)。网络 scope 只挡跨租户,网内是 footgun。
3715
+ //
3716
+ // 用「确定性不匹配」判据:authority 已知(child 有 create 记录)且不等 → 拒。不对
3717
+ // authority==null(create 记录被裁剪的旧 child)加拒,避免给它们的 stop/delete 引入
3718
+ // 新失败面——handler 的 daemon_node_id??resolveDaemonForChild 已兜住无法解析的情形。
3719
+ const authoritativeDaemonId = resolveDaemonForChild(args.child_node_id);
3720
+ if (authoritativeDaemonId && authoritativeDaemonId !== args.daemon_node_id) {
3721
+ return { ok: false, error: "daemon_child_mismatch", message: "daemon_node_id is not this child's authoritative creator daemon" };
3722
+ }
3723
+
3724
+ // State machine gate.
3725
+ const state = node.lifecycle_state ?? "active";
3726
+ if (args.action === "stop" && state !== "active") {
3727
+ return { ok: false, error: state === "stopping" ? "node_already_stopping" : "node_not_active", current_state: state };
3728
+ }
3729
+ if (args.action === "delete" && state === "deleting") {
3730
+ // #1286 —— stale-deleting 重派出口。
3731
+ //
3732
+ // 在 .40 埋点修复之前,daemon 丢失 ack 会让行**永远**停在 deleting:
3733
+ // 重发 delete 被这里挡住,force=true 也一样 —— 状态机没有出口,
3734
+ // 2026-08-28 实测两行卡死(acc-…-3373 / probe-tl2),API 层面无解。
3735
+ //
3736
+ // 出口的三个条件,缺一不放行:
3737
+ // ① 调用方显式 force=true(不改默认行为 —— 正常并发的重复 delete 仍被拒)
3738
+ // ② 该 child 最近一条 delete 请求不是终态(pending/dispatched)
3739
+ // —— 终态说明 daemon 刚 ack 过,行马上会转移,不该重派
3740
+ // ③ 那条请求已经晾了超过 STALE_DELETING_MS —— 🔴 不是"刚派发就能 force":
3741
+ // 给正常的 doorbell→ack 往返留时间,否则 force 变成竞态放大器。
3742
+ const STALE_DELETING_MS = 5 * 60_000;
3743
+ const last = db.get<{ request_id: string; status: string; created_at: number }>(
3744
+ `SELECT request_id, status, created_at FROM node_stop_requests
3745
+ WHERE child_node_id = ?1 AND action = 'delete'
3746
+ ORDER BY created_at DESC LIMIT 1`, node.node_id,
3747
+ );
3748
+ const lastIsStale = !!last
3749
+ && (last.status === "pending" || last.status === "dispatched")
3750
+ && (Date.now() - last.created_at) > STALE_DELETING_MS;
3751
+ if (!(args.force === true && (lastIsStale || !last))) {
3752
+ return {
3753
+ ok: false, error: "node_already_deleting", current_state: state,
3754
+ // 🔴 告诉调用方出口在哪,而不是让他反复撞同一面墙。
3755
+ ...(last ? { last_request_id: last.request_id, last_request_status: last.status } : {}),
3756
+ hint: last && (last.status === "pending" || last.status === "dispatched")
3757
+ ? `再等一下;若超过 ${STALE_DELETING_MS / 60000} 分钟仍未收敛,带 force=true 重试可重新派发`
3758
+ : "上一条请求已终态,行应即将转移;稍后重查",
3759
+ };
3760
+ }
3761
+ // 放行:走正常派发路径,生成新 request_id。旧请求行保留(审计线索),
3762
+ // daemon 侧对未知 request 的 get_stop_request 会拿到 request_not_found,无副作用。
3763
+ }
3764
+ if (args.action === "delete" && state === "stopping") {
3765
+ // Can't start delete while a stop is mid-flight — race-prone.
3766
+ return { ok: false, error: "node_stopping_in_progress", current_state: state };
3767
+ }
3768
+
3769
+ // §4.3 D4 — in-flight inbox check. Count unacked tasks routed to
3770
+ // this node's alias (inbox routing uses session_name == alias).
3771
+ // Default refuse with the count surfaced; force=true overrides
3772
+ // and triggers the forced_stop_with_in_flight audit row.
3773
+ // SF-5 (#345 review): legacy inbox rows may have network_id=NULL
3774
+ // and would silently miss a network_id=?2 strict equality match.
3775
+ // Use COALESCE so a NULL inbox row is counted against the same
3776
+ // network as the target node (which is the only safe default
3777
+ // — pre-multi-network rows existed in single-network mode).
3778
+ const inFlightRow = db.get<{ n: number }>(
3779
+ `SELECT COUNT(*) AS n FROM inbox WHERE session_name = ?1 AND acked = 0
3780
+ AND COALESCE(network_id, ?2) = ?2`,
3781
+ node.alias, node.network_id,
3782
+ );
3783
+ const inFlight = inFlightRow?.n ?? 0;
3784
+ if (inFlight > 0 && !args.force) {
3785
+ return { ok: false, error: "node_busy_in_flight", in_flight_count: inFlight, hint: "set force=true to override (audit logged)" };
3786
+ }
3787
+
3788
+ // ── all gates passed; create request + transition state + push doorbell, transactionally ──
3789
+ const requestId = generateId("sr");
3790
+ const now = Date.now();
3791
+ const newState = args.action === "stop" ? "stopping" : "deleting";
3792
+
3793
+ // #1448 finding-5 — 记下该 child **当前活着的** ntok 的 token_id,让 ack 精确按
3794
+ // token_id 撤销,而非按 name='node:<alias>' 广撤。后者会在「同 alias 节点在这次
3795
+ // delete 未收敛的窗口内被重建(拿到同名新 token)」时,把新 token 一并误撤。这里
3796
+ // 在派发时刻定住身份;若此刻没有活 token(异常),存 null,ack 退回按 name 撤(旧语义)。
3797
+ const childTokenRow = db.get<{ token_id: string }>(
3798
+ `SELECT token_id FROM api_tokens WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
3799
+ node.network_id, `node:${node.alias}`,
3800
+ );
3801
+ const childTokenId = childTokenRow?.token_id ?? null;
3802
+
3803
+ // §4.5 D8 — lifecycle UPDATE + audit INSERTs + request INSERT atomic.
3804
+ // PR1.1: use db.transaction() (SQLiteAdapter wraps better-sqlite3's
3805
+ // native transaction; PgAdapter ships a real BEGIN/COMMIT/ROLLBACK
3806
+ // shim). Lets us drop the open-coded BEGIN/COMMIT + makes the
3807
+ // SF-2 failure-injection test (mock db.run throw inside callback)
3808
+ // reliable across both backends.
3809
+ try {
3810
+ db.transaction(() => {
3811
+ db.run(
3812
+ `INSERT INTO node_stop_requests
3813
+ (request_id, network_id, daemon_node_id, child_node_id, child_alias, child_token_id, action,
3814
+ delete_config, grace_seconds, force, in_flight_at_dispatch, created_by_token,
3815
+ status, created_at)
3816
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'pending', ?13)`,
3817
+ [
3818
+ requestId, node.network_id, args.daemon_node_id, node.node_id, node.alias, childTokenId, args.action,
3819
+ args.delete_config ? 1 : 0, args.grace_seconds, args.force ? 1 : 0, inFlight,
3820
+ callerTokenId || "unknown", now,
3821
+ ],
3822
+ );
3823
+ db.run(
3824
+ `UPDATE nodes SET lifecycle_state = ?1 WHERE node_id = ?2`,
3825
+ [newState, node.node_id],
3826
+ );
3827
+ // §4.5 D8 — audit dispatch action. Uses the STRICT variant so a
3828
+ // failed audit INSERT propagates and triggers ROLLBACK (PR1 SF-2
3829
+ // review catch — auditCreateNode's swallow-and-warn would have
3830
+ // committed the lifecycle UPDATE without an audit row, leaving
3831
+ // exactly the "deleted but no audit" window §4.5 closes).
3832
+ auditCreateNodeStrict({
3833
+ action: args.action === "stop" ? "stop_node_dispatched" : "delete_node_dispatched",
3834
+ user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
3835
+ detail: {
3836
+ child_node_id: node.node_id, child_alias: node.alias,
3837
+ daemon_node_id: args.daemon_node_id, action: args.action,
3838
+ force: args.force, delete_config: args.delete_config,
3839
+ grace_seconds: args.grace_seconds, in_flight_at_dispatch: inFlight,
3840
+ lifecycle_state_before: state, lifecycle_state_after: newState,
3841
+ ts_request: now,
3842
+ },
3843
+ });
3844
+ if (args.force && inFlight > 0) {
3845
+ auditCreateNodeStrict({
3846
+ action: "forced_stop_with_in_flight",
3847
+ user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
3848
+ detail: { in_flight_count: inFlight, child_alias: node.alias },
3849
+ });
3850
+ }
3851
+ }); // end db.transaction
3852
+ } catch (e: any) {
3853
+ return { ok: false, error: "dispatch_tx_failed", message: e?.message || String(e) };
3854
+ }
3855
+
3856
+ // SSE doorbell — daemon will pull via get_stop_request.
3857
+ // PR1.2 e2e fix: route by daemon.alias (see SELECT above for the
3858
+ // BLOCKER explanation). Mirrors create_node's pushEvent target at
3859
+ // line ~2197.
3860
+ pushEvent(daemon.alias, { type: "stop_node", request_id: requestId }, node.network_id);
3861
+ return {
3862
+ ok: true, request_id: requestId, action: args.action,
3863
+ lifecycle_state: newState, in_flight_at_dispatch: inFlight,
3864
+ };
3865
+ };
3866
+
3867
+ server.tool(
3868
+ "stop_node",
3869
+ "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).",
3870
+ {
3871
+ ...NODE_ID_ALIAS_FIELDS,
3872
+ // PR2 prereq: dashboard rarely knows daemon_node_id directly.
3873
+ // When omitted, hub resolves from the original creation record
3874
+ // (node_create_requests.daemon_node_id keyed by this child).
3875
+ daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
3876
+ force: z.boolean().optional().default(false),
3877
+ grace_seconds: z.number().int().min(5).max(60).optional().default(10),
3878
+ network_id: z.string().max(200).optional(),
3879
+ },
3880
+ async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, force, grace_seconds, network_id: clientNetId }) => {
3881
+ const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
3882
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
3883
+ const child_node_id = idArg.node_id;
3884
+ const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
3885
+ if (!resolved) {
3886
+ return { content: [{ type: "text" as const, text: JSON.stringify({
3887
+ ok: false, error: "daemon_not_resolvable",
3888
+ message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
3889
+ }) }] };
3890
+ }
3891
+ const r = dispatchStopOrDelete(
3892
+ { action: "stop", child_node_id, daemon_node_id: resolved, force: force ?? false,
3893
+ grace_seconds: grace_seconds ?? 10, delete_config: false },
3894
+ clientNetId,
3895
+ );
3896
+ return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
3897
+ },
3898
+ );
3899
+
3900
+ server.tool(
3901
+ "delete_node",
3902
+ "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.",
3903
+ {
3904
+ ...NODE_ID_ALIAS_FIELDS,
3905
+ daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
3906
+ confirm_alias: z.string().min(1).max(200),
3907
+ force: z.boolean().optional().default(false),
3908
+ grace_seconds: z.number().int().min(5).max(60).optional().default(10),
3909
+ delete_config: z.boolean().optional().default(true),
3910
+ network_id: z.string().max(200).optional(),
3911
+ },
3912
+ async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, confirm_alias, force, grace_seconds, delete_config, network_id: clientNetId }) => {
3913
+ const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
3914
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
3915
+ const child_node_id = idArg.node_id;
3916
+ const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
3917
+ if (!resolved) {
3918
+ return { content: [{ type: "text" as const, text: JSON.stringify({
3919
+ ok: false, error: "daemon_not_resolvable",
3920
+ message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
3921
+ }) }] };
3922
+ }
3923
+ const r = dispatchStopOrDelete(
3924
+ { action: "delete", child_node_id, daemon_node_id: resolved, force: force ?? false,
3925
+ grace_seconds: grace_seconds ?? 10, delete_config: delete_config ?? true, confirm_alias },
3926
+ clientNetId,
3927
+ );
3928
+ return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
3929
+ },
3930
+ );
3931
+
3932
+ server.tool(
3933
+ "get_stop_request",
3934
+ "Daemon pulls a pending stop/delete request (called when SSE stop_node doorbell arrives). RFC-027 §2.4.",
3935
+ {
3936
+ request_id: z.string().min(1).max(200),
3937
+ },
3938
+ async ({ request_id }) => {
3939
+ const callerDaemon = resolveCallerDaemonTokenBound();
3940
+ if (!callerDaemon.ok) {
3941
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
3942
+ }
3943
+ const row = db.get<{
3944
+ daemon_node_id: string; status: string; network_id: string;
3945
+ child_node_id: string; child_alias: string; action: string;
3946
+ delete_config: number; grace_seconds: number; force: number;
3947
+ }>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, action,
3948
+ delete_config, grace_seconds, force
3949
+ FROM node_stop_requests WHERE request_id = ?1`, request_id);
3950
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
3951
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
3952
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
3953
+ }
3954
+ if (row.network_id !== callerDaemon.networkId) {
3955
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
3956
+ }
3957
+ // Stamp delivered_at on first pull (idempotent — only if still pending).
3958
+ db.run(
3959
+ `UPDATE node_stop_requests SET status = 'delivered', delivered_at = ?1
3960
+ WHERE request_id = ?2 AND status = 'pending'`,
3961
+ [Date.now(), request_id],
3962
+ );
3963
+ return { content: [{ type: "text" as const, text: JSON.stringify({
3964
+ ok: true,
3965
+ request_id,
3966
+ child_node_id: row.child_node_id,
3967
+ child_alias: row.child_alias,
3968
+ action: row.action,
3969
+ delete_config: row.delete_config === 1,
3970
+ grace_seconds: row.grace_seconds,
3971
+ force: row.force === 1,
3972
+ }) }] };
3973
+ },
3974
+ );
3975
+
3976
+ server.tool(
3977
+ "ack_stop_request",
3978
+ "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.",
3979
+ {
3980
+ request_id: z.string().min(1).max(200),
3981
+ status: z.enum(["stopped", "stop_failed", "noop_not_my_child"]),
3982
+ exit_signal: z.string().max(16).optional(),
3983
+ backup_path: z.string().max(500).optional(),
3984
+ error: z.string().max(1000).optional(),
3985
+ },
3986
+ async ({ request_id, status, exit_signal, backup_path, error: ackError }) => {
3987
+ // #1358 —— 🔴 在此之前,这个工具的**每一条出口都是静默的**:到达不记、
3988
+ // 三条拒绝不记、成功收尾也不记。而隔壁 create-node 的收尾**是记的**
3989
+ // (`✓ create-node finalize`)。这种不对称最坑人:读日志的人看到 create 有、
3990
+ // delete 没有,第一反应是「delete 没发生」—— 但两者的日志覆盖面本来就不同,
3991
+ // 那条推论没有依据。排查 #1286 时我自己就差点这样读。
3992
+ console.log(`[commhub] ← ack_stop_request request=${request_id} status=${status}${exit_signal ? ` exit_signal=${exit_signal}` : ""}`);
3993
+ const callerDaemon = resolveCallerDaemonTokenBound();
3994
+ if (!callerDaemon.ok) {
3995
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
3996
+ }
3997
+ const row = db.get<{
3998
+ daemon_node_id: string; status: string; network_id: string;
3999
+ child_node_id: string; child_alias: string; child_token_id: string | null; action: string;
4000
+ in_flight_at_dispatch: number; force: number;
4001
+ }>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, child_token_id, action,
4002
+ in_flight_at_dispatch, force
4003
+ FROM node_stop_requests WHERE request_id = ?1`, request_id);
4004
+ if (!row) {
4005
+ console.warn(`[commhub] ✕ ack_stop_request rejected: request_not_found request=${request_id}`);
4006
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
4007
+ }
4008
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
4009
+ console.warn(`[commhub] ✕ ack_stop_request rejected: not_your_request request=${request_id} row_daemon=${row.daemon_node_id} caller=${callerDaemon.daemonNodeId}`);
4010
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
4011
+ }
4012
+ if (row.network_id !== callerDaemon.networkId) {
4013
+ console.warn(`[commhub] ✕ ack_stop_request rejected: cross_network_request request=${request_id} row_net=${row.network_id} caller_net=${callerDaemon.networkId}`);
4014
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
4015
+ }
4016
+ const now = Date.now();
4017
+
4018
+ // §4.5 D8 — lifecycle UPDATE + request UPDATE + audit INSERT
4019
+ // (+ DELETE for delete action) atomic via db.transaction().
4020
+ // PR1.1: replaces hand-rolled BEGIN..COMMIT so PG adapter and
4021
+ // SQLite both get correct rollback semantics from one code path.
4022
+ try {
4023
+ db.transaction(() => {
4024
+ db.run(
4025
+ `UPDATE node_stop_requests SET status = ?1, error = ?2, exit_signal = ?3,
4026
+ backup_path = ?4, acked_at = ?5
4027
+ WHERE request_id = ?6`,
4028
+ [status, ackError || null, exit_signal || null, backup_path || null, now, request_id],
4029
+ );
4030
+ if (status === "stopped" && row.action === "stop") {
4031
+ db.run(`UPDATE nodes SET lifecycle_state = 'stopped' WHERE node_id = ?1`, [row.child_node_id]);
4032
+ auditCreateNodeStrict({
4033
+ action: "stop_node_completed",
4034
+ user_id: null, network_id: row.network_id, target_id: request_id,
4035
+ detail: {
4036
+ child_node_id: row.child_node_id, child_alias: row.child_alias,
4037
+ exit_signal: exit_signal || null, ts_daemon_ack: now,
4038
+ lifecycle_state_after: "stopped",
4039
+ },
4040
+ });
4041
+ } else if (status === "stopped" && row.action === "delete") {
4042
+ // Revoke the child's ntok, then DELETE the nodes row.
4043
+ // #1448 finding-5 — 精确按派发时定住的 child_token_id 撤(镜像
4044
+ // ack_create_request 的 token_id 精撤)。旧行(升级前派发,child_token_id
4045
+ // 为 NULL)fallback 回按 name='node:<alias>' 撤——旧语义,不引入回归。
4046
+ // 按 token_id 撤避免了「同 alias 在本次 delete 窗口内被重建、拿到同名新
4047
+ // token」时把新 token 一并误撤。
4048
+ if (row.child_token_id) {
4049
+ db.run(
4050
+ `UPDATE api_tokens SET revoked_at = datetime('now')
4051
+ WHERE token_id = ?1 AND revoked_at IS NULL`,
4052
+ [row.child_token_id],
4053
+ );
4054
+ } else {
4055
+ db.run(
4056
+ `UPDATE api_tokens SET revoked_at = datetime('now')
4057
+ WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
4058
+ [row.network_id, `node:${row.child_alias}`],
4059
+ );
4060
+ }
4061
+ db.run(`DELETE FROM nodes WHERE node_id = ?1`, [row.child_node_id]);
4062
+ auditCreateNodeStrict({
4063
+ action: "delete_node_completed",
4064
+ user_id: null, network_id: row.network_id, target_id: request_id,
4065
+ detail: {
4066
+ child_node_id: row.child_node_id, child_alias: row.child_alias,
4067
+ exit_signal: exit_signal || null, backup_path: backup_path || null,
4068
+ ts_daemon_ack: now, lifecycle_state_after: "deleted",
4069
+ },
4070
+ });
4071
+ } else if (status === "stop_failed") {
4072
+ db.run(`UPDATE nodes SET lifecycle_state = 'stop_failed' WHERE node_id = ?1`, [row.child_node_id]);
4073
+ // No completion audit; the failure error is on the request row.
4074
+ }
4075
+ // noop_not_my_child: leave lifecycle_state as-is; the hub-side
4076
+ // sweeper / next reconciliation will pick it up. Don't transition.
4077
+ });
4078
+ } catch (e: any) {
4079
+ console.warn(`[commhub] ✕ ack_stop_request finalize_tx_failed request=${request_id} action=${row.action} child=${row.child_alias}: ${e?.message || e}`);
4080
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "finalize_tx_failed", message: e?.message || String(e) }) }] };
4081
+ }
4082
+
4083
+ // 🔴 关键的是最后那半句「据此做了什么」。#1286 里 hub 的代码路径经核对是
4084
+ // 正确的,缺的正是「它到底走没走那条分支」—— 只说「收到了 ack」回答不了。
4085
+ const did = status === "stopped" && row.action === "delete"
4086
+ ? "revoked ntok + DELETE FROM nodes"
4087
+ : status === "stopped" && row.action === "stop"
4088
+ ? "lifecycle_state=stopped"
4089
+ : status === "stop_failed"
4090
+ ? "lifecycle_state=stop_failed"
4091
+ : "no lifecycle transition (noop_not_my_child)";
4092
+ console.log(`[commhub] ✓ ${row.action}-node finalize: request=${request_id} child=${row.child_alias} (${row.child_node_id}) status=${status} → ${did}`);
4093
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
4094
+ },
4095
+ );
4096
+
4097
+ // start_node is deliberately daemon-mediated. A stopped child has no SSE
4098
+ // consumer of its own, so restart_node's self-doorbell cannot revive it.
4099
+ server.tool(
4100
+ "start_node",
4101
+ "Start a stopped child through its host supervisor daemon. daemon_node_id is auto-resolved from the original create request.",
4102
+ {
4103
+ ...NODE_ID_ALIAS_FIELDS,
4104
+ daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
4105
+ network_id: z.string().max(200).optional(),
4106
+ },
4107
+ async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, network_id: clientNetId }) => {
4108
+ const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
4109
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
4110
+ const child_node_id = idArg.node_id;
4111
+ const resolvedDaemonId = daemon_node_id ?? resolveDaemonForChild(child_node_id);
4112
+ if (!resolvedDaemonId) {
4113
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4114
+ ok: false, error: "daemon_not_resolvable",
4115
+ message: "no create request maps this child to a daemon",
4116
+ }) }] };
4117
+ }
4118
+ const scope = resolveReadScope(clientNetId);
4119
+ if (scope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "forbidden_cross_tenant" }) }] };
4120
+ const q: any[] = [child_node_id];
4121
+ const child = db.get<{ node_id: string; alias: string; network_id: string; lifecycle_state: string | null }>(
4122
+ addReadScope(`SELECT node_id, alias, network_id, lifecycle_state FROM nodes WHERE node_id = ?1`, q, scope), ...q,
4123
+ );
4124
+ if (!child) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "forbidden_cross_tenant" }) }] };
4125
+ if (!canWrite(child.network_id)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
4126
+ // #1448 finding-2 — stale-starting reaper,对齐 update_node_config / restart_node
4127
+ // 的 60s single-flight stale-supersede。start 只受理 'stopped';一旦卡 'starting'
4128
+ // (门铃丢 / daemon 在 UPDATE 'starting' 后死),config/restart 有 reaper、delete 有
4129
+ // 5min force,唯独 start 裸奔 → 下面的 gate 永远拦、也无人把节点拉回 stopped →
4130
+ // 永久卡 'starting'。出口:'starting' 且最近一条 in-flight start 请求晾过阈值 → 标
4131
+ // 旧请求 timeout 超越、放行重派;未过阈值则拒 node_already_starting(带 age)。
4132
+ const priorState = child.lifecycle_state ?? "active";
4133
+ if (priorState !== "stopped") {
4134
+ if (priorState !== "starting") {
4135
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_stopped", current_state: priorState }) }] };
4136
+ }
4137
+ const STALE_STARTING_MS = 60_000;
4138
+ const inFlight = db.get<{ request_id: string; created_at: number; delivered_at: number | null; acked_at: number | null }>(
4139
+ `SELECT request_id, created_at, delivered_at, acked_at FROM node_start_requests
4140
+ WHERE child_node_id = ?1 AND status IN ('pending', 'delivered')
4141
+ ORDER BY created_at DESC LIMIT 1`, child.node_id,
4142
+ );
4143
+ if (inFlight) {
4144
+ // Age anchor = COALESCE(acked_at, delivered_at, created_at),同 config reaper:
4145
+ // 已 pull/已 ack 的活着但慢的 start 会刷新存活时钟,不被误 reap。
4146
+ const ageAnchor = inFlight.acked_at ?? inFlight.delivered_at ?? inFlight.created_at;
4147
+ const age = Date.now() - ageAnchor;
4148
+ if (age <= STALE_STARTING_MS) {
4149
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4150
+ ok: false, error: "node_already_starting",
4151
+ existing_request_id: inFlight.request_id, age_ms: age,
4152
+ hint: `再等一下;超过 ${STALE_STARTING_MS / 1000}s 仍卡 starting,重试 start 会重新派发`,
4153
+ }) }] };
4154
+ }
4155
+ // Stale — 标旧请求 timeout(autocommit,先于下面 tx 的新 INSERT,解除
4156
+ // node_start_requests 对 pending/delivered 的 child 唯一约束),放行重派。
4157
+ db.run(
4158
+ `UPDATE node_start_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3`,
4159
+ [Date.now(), `superseded by new start after ${age}ms stale (> ${STALE_STARTING_MS}ms threshold)`, inFlight.request_id],
4160
+ );
4161
+ }
4162
+ // else: 'starting' 但无 in-flight 行(异常残留态)——照样放行重派以自愈。
4163
+ }
4164
+ // The caller may provide daemon_node_id, but it cannot override the
4165
+ // authoritative child->daemon relationship recorded at creation.
4166
+ const authoritativeDaemonId = resolveDaemonForChild(child_node_id);
4167
+ if (!authoritativeDaemonId || authoritativeDaemonId !== resolvedDaemonId) {
4168
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_child_mismatch" }) }] };
4169
+ }
4170
+ const daemon = db.get<{ node_id: string; alias: string; network_id: string }>(
4171
+ `SELECT node_id, alias, network_id FROM nodes WHERE node_id = ?1`, resolvedDaemonId,
4172
+ );
4173
+ if (!daemon) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found" }) }] };
4174
+ if (daemon.network_id !== child.network_id) {
4175
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_cross_tenant" }) }] };
4176
+ }
4177
+ const requestId = generateId("str");
4178
+ const now = Date.now();
4179
+ try {
4180
+ db.transaction(() => {
4181
+ db.run(
4182
+ `INSERT INTO node_start_requests
4183
+ (request_id, network_id, daemon_node_id, child_node_id, child_alias,
4184
+ created_by_token, status, created_at)
4185
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7)`,
4186
+ [requestId, child.network_id, daemon.node_id, child.node_id, child.alias, callerTokenId || "unknown", now],
4187
+ );
4188
+ // #1448 finding-2 — 也接受从 'starting' 重派(stale-supersede 后原态仍是
4189
+ // starting);两种入态都合法地转/留到 'starting'。
4190
+ db.run(`UPDATE nodes SET lifecycle_state = 'starting' WHERE node_id = ?1 AND lifecycle_state IN ('stopped', 'starting')`, [child.node_id]);
4191
+ auditCreateNodeStrict({
4192
+ action: "start_node_dispatched", user_id: enforceUserId,
4193
+ network_id: child.network_id, target_id: requestId,
4194
+ detail: { child_node_id: child.node_id, child_alias: child.alias, daemon_node_id: daemon.node_id,
4195
+ lifecycle_state_before: priorState, lifecycle_state_after: "starting", ts_request: now },
4196
+ });
4197
+ });
4198
+ } catch (e: any) {
4199
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "dispatch_tx_failed", message: e?.message || String(e) }) }] };
4200
+ }
4201
+ pushEvent(daemon.alias, { type: "start_node", request_id: requestId }, child.network_id);
4202
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, lifecycle_state: "starting" }) }] };
4203
+ },
4204
+ );
4205
+
4206
+ server.tool(
4207
+ "get_start_request",
4208
+ "Host supervisor pulls an authenticated pending start request.",
4209
+ { request_id: z.string().min(1).max(200) },
4210
+ async ({ request_id }) => {
4211
+ const callerDaemon = resolveCallerDaemonTokenBound();
4212
+ if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4213
+ const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
4214
+ `SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id = ?1`, request_id,
4215
+ );
4216
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
4217
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
4218
+ if (row.network_id !== callerDaemon.networkId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
4219
+ if (!['pending', 'delivered'].includes(row.status)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_startable", status: row.status }) }] };
4220
+ db.run(`UPDATE node_start_requests SET status='delivered', delivered_at=?1 WHERE request_id=?2 AND status='pending'`, [Date.now(), request_id]);
4221
+ 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 }) }] };
4222
+ },
4223
+ );
4224
+
4225
+ server.tool(
4226
+ "ack_start_request",
4227
+ "Host supervisor reports start completion or failure.",
4228
+ {
4229
+ request_id: z.string().min(1).max(200),
4230
+ status: z.enum(["started", "start_failed"]),
4231
+ child_pid: z.number().int().positive().optional(),
4232
+ error: z.string().max(1000).optional(),
4233
+ },
4234
+ async ({ request_id, status, child_pid, error: ackError }) => {
4235
+ const callerDaemon = resolveCallerDaemonTokenBound();
4236
+ if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4237
+ const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
4238
+ `SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id=?1`, request_id,
4239
+ );
4240
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
4241
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
4242
+ if (row.network_id !== callerDaemon.networkId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
4243
+ if (row.status === "started" || row.status === "start_failed") {
4244
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: row.status, idempotent: true }) }] };
4245
+ }
4246
+ if (!['pending', 'delivered'].includes(row.status)) {
4247
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_ackable", status: row.status }) }] };
4248
+ }
4249
+ if (status === "started" && !child_pid) {
4250
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "child_pid_required" }) }] };
4251
+ }
4252
+ const now = Date.now();
4253
+ try {
4254
+ db.transaction(() => {
4255
+ 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]);
4256
+ db.run(`UPDATE nodes SET lifecycle_state=?1 WHERE node_id=?2`, [status === "started" ? "active" : "stopped", row.child_node_id]);
4257
+ if (status === "started") auditCreateNodeStrict({
4258
+ action: "start_node_completed", network_id: row.network_id, target_id: request_id,
4259
+ detail: { child_node_id: row.child_node_id, child_alias: row.child_alias, child_pid, lifecycle_state_after: "active", ts_daemon_ack: now },
4260
+ });
4261
+ });
4262
+ } catch (e: any) {
4263
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "finalize_tx_failed", message: e?.message || String(e) }) }] };
4264
+ }
2128
4265
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
2129
4266
  },
2130
4267
  );
4268
+
4269
+ // RFC-027 PR1.1 — list_my_children: daemon-only query. Returns the
4270
+ // {child_node_id, alias, lifecycle_state} tuples for nodes whose
4271
+ // active create-request has this daemon as daemon_node_id. Used at
4272
+ // daemon boot to rebuild the in-memory childrenMap (PR1 dropped
4273
+ // every entry on daemon restart → stop/delete silently no-op'd).
4274
+ //
4275
+ // Network-scope is the daemon's own (resolveCallerDaemonTokenBound
4276
+ // already binds tokenIsNetwork to a single network). No SEC-1 leak:
4277
+ // returns ONLY children whose request landed under this daemon's
4278
+ // token + network.
4279
+ server.tool(
4280
+ "list_my_children",
4281
+ "Daemon pulls the alias + node_id list of children it spawned (for childrenMap rebuild after restart). RFC-027 PR1.1.",
4282
+ {},
4283
+ async () => {
4284
+ const callerDaemon = resolveCallerDaemonTokenBound();
4285
+ if (!callerDaemon.ok) {
4286
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4287
+ }
4288
+ // node_create_requests carries the canonical authoritative
4289
+ // child→daemon mapping (the row id IS the request, and the
4290
+ // child node_id derives from it deterministically via
4291
+ // `node_${request_id.replace(/^cr_/, "")}`). Filter to children
4292
+ // that completed registration (have a nodes row) so we don't
4293
+ // ask the daemon to pgrep for children that never came up.
4294
+ const rows = db.all<{ request_id: string; child_name: string; child_node_id: string; lifecycle_state: string | null }>(
4295
+ `SELECT ncr.request_id, ncr.child_name,
4296
+ ('node_' || substr(ncr.request_id, 4)) AS child_node_id,
4297
+ n.lifecycle_state
4298
+ FROM node_create_requests ncr
4299
+ LEFT JOIN nodes n ON n.node_id = ('node_' || substr(ncr.request_id, 4))
4300
+ WHERE ncr.daemon_node_id = ?1
4301
+ AND ncr.network_id = ?2
4302
+ AND ncr.status IN ('succeeded', 'delivered')
4303
+ AND n.node_id IS NOT NULL
4304
+ AND COALESCE(n.lifecycle_state, 'active') NOT IN ('stopped', 'stop_failed')`,
4305
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
4306
+ );
4307
+ const children = rows.map(r => ({
4308
+ child_node_id: r.child_node_id,
4309
+ alias: r.child_name,
4310
+ lifecycle_state: r.lifecycle_state ?? "active",
4311
+ }));
4312
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: children.length, children }) }] };
4313
+ },
4314
+ );
4315
+
4316
+ // ── RFC-028 P1 — Provider & Model Registry + connectivity probe ──
4317
+ // Background timers (probe GC + sweeper) idempotent; safe to call
4318
+ // per registerTools.
4319
+ startPendingProbeGcTimer();
4320
+ startProbeSweeperTimer();
4321
+
4322
+ // Helper: zod to JSON-RPC reply for ProbeValidationError + VaultError
4323
+ const probeFailReply = (e: unknown) => {
4324
+ if (e instanceof ProbeValidationError) {
4325
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: e.code, ...(e.detail || {}) }) }] };
4326
+ }
4327
+ if (e instanceof VaultError) {
4328
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: e.code, message: e.message }) }] };
4329
+ }
4330
+ throw e;
4331
+ };
4332
+
4333
+ // §2.3.4 — upsert_network_secret (OWNER-ONLY; vault write).
4334
+ server.tool(
4335
+ "upsert_network_secret",
4336
+ "Write or replace a secret value in the network's vault (AES-GCM encrypted at rest). OWNER-only. RFC-028.",
4337
+ {
4338
+ key: z.string().min(1).max(64).regex(/^[A-Z][A-Z0-9_]{0,63}$/),
4339
+ value: z.string().min(1).max(16 * 1024),
4340
+ network_id: z.string().max(200).optional(),
4341
+ },
4342
+ async ({ key, value, network_id: clientNetId }) => {
4343
+ const effectiveNetId = getNetworkId(clientNetId);
4344
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
4345
+ const callerRole = enforceUserId && effectiveNetId
4346
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
4347
+ : null;
4348
+ if (callerRole !== "owner") {
4349
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "secret_owner_only", required_role: "owner", caller_role: callerRole }) }] };
4350
+ }
4351
+ try {
4352
+ vaultUpsert(effectiveNetId || "default", key, value);
4353
+ auditCreateNode({
4354
+ action: "create_node_dispatched", // reusing audit_log shape (provider follow-up logs)
4355
+ user_id: enforceUserId, network_id: effectiveNetId, target_id: null,
4356
+ detail: { op: "vault_upsert", key, network_id: effectiveNetId },
4357
+ });
4358
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, key }) }] };
4359
+ } catch (e) {
4360
+ return probeFailReply(e);
4361
+ }
4362
+ },
4363
+ );
4364
+
4365
+ // §2.3.4b — list_network_secrets (key names only; viewer+).
4366
+ server.tool(
4367
+ "list_network_secrets",
4368
+ "List vault key NAMES (NEVER values) for a network. RFC-028.",
4369
+ { network_id: z.string().max(200).optional() },
4370
+ async ({ network_id: clientNetId }) => {
4371
+ const effectiveNetId = getNetworkId(clientNetId);
4372
+ if (!canWrite(effectiveNetId) && enforceUserId) {
4373
+ // viewer-level: allow if caller has any role in network
4374
+ const role = getUserNetworkRole(enforceUserId, effectiveNetId || "default");
4375
+ if (!role) return writeDeniedReply(effectiveNetId, "read");
4376
+ }
4377
+ const keys = vaultListKeys(effectiveNetId || "default");
4378
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, keys }) }] };
4379
+ },
4380
+ );
4381
+
4382
+ // §2.3.1 — upsert_provider (admin+).
4383
+ server.tool(
4384
+ "upsert_provider",
4385
+ "Create or update a provider (vendor + base_url + secret_key_ref + initial models). Admin+. RFC-028.",
4386
+ {
4387
+ name: z.string().min(1).max(100),
4388
+ vendor: z.string().min(1).max(64),
4389
+ base_url: z.string().min(1).max(500),
4390
+ secret_key_ref: z.string().min(1).max(64),
4391
+ models: z.array(z.object({
4392
+ model_name: z.string().min(1).max(100),
4393
+ display_name: z.string().max(100).optional(),
4394
+ context_window: z.number().int().min(0).optional(),
4395
+ supports_vision: z.boolean().optional(),
4396
+ })).optional(),
4397
+ network_id: z.string().max(200).optional(),
4398
+ },
4399
+ async ({ name, vendor, base_url, secret_key_ref, models, network_id: clientNetId }) => {
4400
+ const effectiveNetId = getNetworkId(clientNetId);
4401
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
4402
+ const callerRole = enforceUserId && effectiveNetId
4403
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
4404
+ : null;
4405
+ if (callerRole !== "admin" && callerRole !== "owner") {
4406
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_provider", required_role: "admin", caller_role: callerRole }) }] };
4407
+ }
4408
+ try {
4409
+ _validateBaseUrl(vendor, base_url);
4410
+ } catch (e) {
4411
+ return probeFailReply(e);
4412
+ }
4413
+ // Verify vault key exists (best-effort; just lists names)
4414
+ const vaultKeys = vaultListKeys(effectiveNetId || "default");
4415
+ if (!vaultKeys.includes(secret_key_ref)) {
4416
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "secret_not_in_vault", key: secret_key_ref, hint: "upsert_network_secret first" }) }] };
4417
+ }
4418
+ const providerId = `prov_${uuidv4()}`;
4419
+ try {
4420
+ db.run(
4421
+ `INSERT INTO providers (provider_id, network_id, name, vendor, base_url, secret_key_ref, created_at, created_by, enabled)
4422
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)`,
4423
+ [providerId, effectiveNetId || "default", name, vendor, base_url, secret_key_ref, Date.now(), enforceUserId || "unknown"],
4424
+ );
4425
+ } catch (e: any) {
4426
+ if (/UNIQUE constraint failed/.test(e?.message || "")) {
4427
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_name_conflict", name }) }] };
4428
+ }
4429
+ throw e;
4430
+ }
4431
+ const modelIds: string[] = [];
4432
+ if (models) {
4433
+ for (const m of models) {
4434
+ const mid = `pm_${uuidv4()}`;
4435
+ db.run(
4436
+ `INSERT INTO provider_models (model_id, provider_id, model_name, display_name, context_window, supports_vision, enabled, created_at)
4437
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)`,
4438
+ [mid, providerId, m.model_name, m.display_name ?? null, m.context_window ?? null, m.supports_vision ? 1 : 0, Date.now()],
4439
+ );
4440
+ modelIds.push(mid);
4441
+ }
4442
+ }
4443
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id: providerId, model_ids: modelIds }) }] };
4444
+ },
4445
+ );
4446
+
4447
+ // §2.3.2 — update_provider (admin+; patch semantics). RFC-028 P1.5.
4448
+ // Single tool for editing existing providers — patch model: name/base_url/
4449
+ // models/enabled all optional, at least one required. Vendor and
4450
+ // secret_key_ref are IMMUTABLE via this tool (vendor change = create+delete;
4451
+ // secret change goes through upsert_network_secret first). network_id is
4452
+ // immutable (cross-tenant move forbidden).
4453
+ //
4454
+ // base_url change re-runs validateBaseUrl with vendor read from DB row
4455
+ // (NOT from patch) — defends against trying to widen host allowlist by
4456
+ // smuggling a new vendor name. zod .strict() on the wrapping object
4457
+ // surfaces extras as -32602 at MCP boundary (R3 lock, same as
4458
+ // ack_probe_request per #308 fold-in).
4459
+ //
4460
+ // Audit: every successful update writes audit_log with before/after diff
4461
+ // of the changed fields ONLY (no secret values — secret isn't a patch
4462
+ // field, no leak path).
4463
+ server.registerTool(
4464
+ "update_provider",
4465
+ {
4466
+ description: "Edit existing provider (name/base_url/models/enabled). Patch semantics — at least one field. Vendor/secret/network_id immutable. Admin+. RFC-028 P1.5.",
4467
+ inputSchema: z.object({
4468
+ provider_id: z.string().regex(/^prov_[a-zA-Z0-9_-]+$/).max(200),
4469
+ network_id: z.string().max(200).optional(),
4470
+ patch: z.object({
4471
+ name: z.string().min(1).max(100).optional(),
4472
+ base_url: z.string().min(1).max(500).optional(),
4473
+ models: z.array(z.object({
4474
+ model_name: z.string().min(1).max(100),
4475
+ display_name: z.string().max(100).optional(),
4476
+ context_window: z.number().int().min(0).optional(),
4477
+ supports_vision: z.boolean().optional(),
4478
+ })).optional(),
4479
+ enabled: z.boolean().optional(),
4480
+ }).strict(),
4481
+ }).strict() as any,
4482
+ },
4483
+ async (args: any) => {
4484
+ const { provider_id, network_id: clientNetId, patch } = args;
4485
+ const effectiveNetId = getNetworkId(clientNetId);
4486
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
4487
+ const callerRole = enforceUserId && effectiveNetId
4488
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
4489
+ : null;
4490
+ if (callerRole !== "admin" && callerRole !== "owner") {
4491
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_provider", required_role: "admin", caller_role: callerRole }) }] };
4492
+ }
4493
+
4494
+ // Empty patch — surface noop_no_changes (don't silently succeed)
4495
+ const patchKeys = Object.keys(patch || {});
4496
+ if (patchKeys.length === 0) {
4497
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "noop_no_changes", hint: "patch must include at least one of: name, base_url, models, enabled" }) }] };
4498
+ }
4499
+
4500
+ // SEC-1: row must exist within caller's network (SQL-level enforcement)
4501
+ const row = db.get<{ provider_id: string; vendor: string; name: string; base_url: string; enabled: number }>(
4502
+ `SELECT provider_id, vendor, name, base_url, enabled FROM providers WHERE provider_id = ?1 AND network_id = ?2`,
4503
+ provider_id, effectiveNetId || "default",
4504
+ );
4505
+ if (!row) {
4506
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_not_found", provider_id }) }] };
4507
+ }
4508
+
4509
+ // base_url change → re-run validateBaseUrl with vendor from DB (not patch)
4510
+ if (patch.base_url !== undefined && patch.base_url !== row.base_url) {
4511
+ try {
4512
+ _validateBaseUrl(row.vendor, patch.base_url);
4513
+ } catch (e) {
4514
+ return probeFailReply(e);
4515
+ }
4516
+ }
4517
+
4518
+ // Diff staging — collect before/after for audit (NO secret values; secret
4519
+ // isn't a patch field). Skip fields that didn't actually change.
4520
+ const diff: Record<string, { before: unknown; after: unknown }> = {};
4521
+ const sets: string[] = [];
4522
+ const params: unknown[] = [];
4523
+ let paramIdx = 1;
4524
+ if (patch.name !== undefined && patch.name !== row.name) {
4525
+ sets.push(`name = ?${paramIdx++}`); params.push(patch.name);
4526
+ diff.name = { before: row.name, after: patch.name };
4527
+ }
4528
+ if (patch.base_url !== undefined && patch.base_url !== row.base_url) {
4529
+ sets.push(`base_url = ?${paramIdx++}`); params.push(patch.base_url);
4530
+ diff.base_url = { before: row.base_url, after: patch.base_url };
4531
+ }
4532
+ if (patch.enabled !== undefined) {
4533
+ const newEnabled = patch.enabled ? 1 : 0;
4534
+ if (newEnabled !== row.enabled) {
4535
+ sets.push(`enabled = ?${paramIdx++}`); params.push(newEnabled);
4536
+ diff.enabled = { before: row.enabled === 1, after: patch.enabled };
4537
+ }
4538
+ }
4539
+
4540
+ // Replace models list (if supplied) atomically with the providers UPDATE
4541
+ const willReplaceModels = patch.models !== undefined;
4542
+ const newModelIds: string[] = [];
4543
+
4544
+ // No-op early return (通信牛 nit): all patch fields matched the
4545
+ // existing row values + no models supplied → no real change to
4546
+ // persist. Returning here means we don't write an empty-diff
4547
+ // audit row (audit noise). Audit_log INSERT must only fire when
4548
+ // something actually changed.
4549
+ if (sets.length === 0 && !willReplaceModels) {
4550
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id, no_changes: true, hint: "patch fields matched existing row, no-op" }) }] };
4551
+ }
4552
+
4553
+ try {
4554
+ db.exec("BEGIN");
4555
+ if (sets.length > 0) {
4556
+ params.push(provider_id);
4557
+ try {
4558
+ db.run(`UPDATE providers SET ${sets.join(", ")} WHERE provider_id = ?${paramIdx}`, params);
4559
+ } catch (e: any) {
4560
+ if (/UNIQUE constraint failed/.test(e?.message || "")) {
4561
+ db.exec("ROLLBACK");
4562
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_name_conflict", name: patch.name }) }] };
4563
+ }
4564
+ throw e;
4565
+ }
4566
+ }
4567
+ if (willReplaceModels) {
4568
+ db.run(`DELETE FROM provider_models WHERE provider_id = ?1`, [provider_id]);
4569
+ for (const m of patch.models) {
4570
+ const mid = `pm_${uuidv4()}`;
4571
+ db.run(
4572
+ `INSERT INTO provider_models (model_id, provider_id, model_name, display_name, context_window, supports_vision, enabled, created_at)
4573
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)`,
4574
+ [mid, provider_id, m.model_name, m.display_name ?? null, m.context_window ?? null, m.supports_vision ? 1 : 0, Date.now()],
4575
+ );
4576
+ newModelIds.push(mid);
4577
+ }
4578
+ diff.models = { before: "(prior list)", after: `${patch.models.length} models replaced` };
4579
+ }
4580
+ db.run(
4581
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
4582
+ VALUES (?1, ?2, 'update_provider', 'provider', ?3, ?4, ?5)`,
4583
+ [enforceUserId || null, callerAlias || null, provider_id, JSON.stringify({ diff, fields_changed: Object.keys(diff) }), effectiveNetId || null],
4584
+ );
4585
+ db.exec("COMMIT");
4586
+ } catch (e: any) {
4587
+ try { db.exec("ROLLBACK"); } catch { /* ok */ }
4588
+ throw e;
4589
+ }
4590
+
4591
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4592
+ ok: true, provider_id,
4593
+ fields_changed: Object.keys(diff),
4594
+ models_replaced: willReplaceModels ? newModelIds.length : null,
4595
+ }) }] };
4596
+ },
4597
+ );
4598
+
4599
+ // §2.3.3 — list_providers (viewer+; never returns secret VALUES).
4600
+ server.tool(
4601
+ "list_providers",
4602
+ "List providers + models in the caller's network. Never returns secret VALUES. RFC-028.",
4603
+ { network_id: z.string().max(200).optional() },
4604
+ async ({ network_id: clientNetId }) => {
4605
+ const effectiveNetId = getNetworkId(clientNetId);
4606
+ // viewer access: caller must be member of network (read access)
4607
+ if (enforceUserId) {
4608
+ const role = getUserNetworkRole(enforceUserId, effectiveNetId || "default");
4609
+ if (!role) return writeDeniedReply(effectiveNetId, "read");
4610
+ }
4611
+ const providers = db.all<any>(
4612
+ `SELECT provider_id, name, vendor, base_url, secret_key_ref, enabled FROM providers WHERE network_id = ?1 AND enabled = 1 ORDER BY name`,
4613
+ effectiveNetId || "default",
4614
+ );
4615
+ const out = providers.map(p => {
4616
+ const models = db.all<any>(
4617
+ `SELECT model_id, model_name, display_name, context_window, supports_vision, enabled FROM provider_models WHERE provider_id = ?1 AND enabled = 1 ORDER BY model_name`,
4618
+ p.provider_id,
4619
+ );
4620
+ return { ...p, in_vault: true, models }; // in_vault: true since we required it on upsert
4621
+ });
4622
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, providers: out }) }] };
4623
+ },
4624
+ );
4625
+
4626
+ // §2.3.5 — probe_provider_model (admin+; dispatches to daemon).
4627
+ server.tool(
4628
+ "probe_provider_model",
4629
+ "Dispatch a connectivity probe to a daemon. Mints ephemeral secret blob; daemon pulls via get_probe_request. Admin+. RFC-028.",
4630
+ {
4631
+ provider_id: z.string().min(1).max(200),
4632
+ model_name: z.string().min(1).max(100),
4633
+ daemon_node_id: z.string().min(1).max(200),
4634
+ network_id: z.string().max(200).optional(),
4635
+ },
4636
+ async ({ provider_id, model_name, daemon_node_id, network_id: clientNetId }) => {
4637
+ const effectiveNetId = getNetworkId(clientNetId);
4638
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
4639
+ const callerRole = enforceUserId && effectiveNetId
4640
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
4641
+ : null;
4642
+ if (callerRole !== "admin" && callerRole !== "owner") {
4643
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_probe", required_role: "admin", caller_role: callerRole }) }] };
4644
+ }
4645
+ // Resolve provider + model (network-scoped). Distinguish "not found"
4646
+ // from "disabled" so the dashboard can render the right error
4647
+ // (P1.5 dashboard编辑/停用 needs this to explain why probe rejected).
4648
+ const provider = db.get<any>(
4649
+ `SELECT provider_id, vendor, base_url, secret_key_ref, enabled FROM providers WHERE provider_id = ?1 AND network_id = ?2`,
4650
+ provider_id, effectiveNetId || "default",
4651
+ );
4652
+ if (!provider) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_not_found", provider_id }) }] };
4653
+ if (provider.enabled !== 1) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_disabled", provider_id, hint: "update_provider with enabled:true to re-enable" }) }] };
4654
+ const model = db.get<any>(
4655
+ `SELECT model_id, model_name FROM provider_models WHERE provider_id = ?1 AND model_name = ?2 AND enabled = 1`,
4656
+ provider_id, model_name,
4657
+ );
4658
+ if (!model) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "model_not_found", model_name }) }] };
4659
+ // Resolve daemon (must be in caller network)
4660
+ const { row: daemon, sec1Ok } = resolveTargetNode(daemon_node_id, effectiveNetId);
4661
+ if (!daemon) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found" }) }] };
4662
+ if (!sec1Ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
4663
+ // Vault decrypt the API key (may throw VaultError)
4664
+ let apiKey: string;
4665
+ try {
4666
+ const v = vaultGet(effectiveNetId || "default", provider.secret_key_ref);
4667
+ if (!v) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "secret_not_in_vault", key: provider.secret_key_ref }) }] };
4668
+ apiKey = v;
4669
+ } catch (e) { return probeFailReply(e); }
4670
+ // Mint probe row + stash ephemeral blob
4671
+ const probeId = newProbeId();
4672
+ db.run(
4673
+ `INSERT INTO probe_results (probe_id, provider_id, model_name, daemon_node_id, network_id, status, probed_at, probed_by_user)
4674
+ VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)`,
4675
+ [probeId, provider_id, model_name, daemon_node_id, effectiveNetId || "default", Date.now(), enforceUserId || null],
4676
+ );
4677
+ putPendingProbeSecret({
4678
+ probe_id: probeId,
4679
+ daemon_node_id,
4680
+ provider_id,
4681
+ vendor: provider.vendor,
4682
+ base_url: provider.base_url,
4683
+ model_name,
4684
+ api_key: apiKey,
4685
+ network_id: effectiveNetId || "default",
4686
+ });
4687
+ pushEvent(daemon.alias, { type: "probe_provider", probe_id: probeId }, daemon.network_id || effectiveNetId || "default");
4688
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, probe_id: probeId }) }] };
4689
+ },
4690
+ );
4691
+
4692
+ // §2.3.6 — get_probe_results (viewer+; matrix renderer source).
4693
+ server.tool(
4694
+ "get_probe_results",
4695
+ "Query probe history (optionally filtered by provider/model/daemon). Used by dashboard reachability matrix. RFC-028.",
4696
+ {
4697
+ provider_id: z.string().max(200).optional(),
4698
+ model_name: z.string().max(100).optional(),
4699
+ daemon_node_id: z.string().max(200).optional(),
4700
+ limit: z.number().int().min(1).max(500).optional(),
4701
+ network_id: z.string().max(200).optional(),
4702
+ },
4703
+ async ({ provider_id, model_name, daemon_node_id, limit, network_id: clientNetId }) => {
4704
+ const effectiveNetId = getNetworkId(clientNetId);
4705
+ if (enforceUserId) {
4706
+ const role = getUserNetworkRole(enforceUserId, effectiveNetId || "default");
4707
+ if (!role) return writeDeniedReply(effectiveNetId, "read");
4708
+ }
4709
+ const where: string[] = ["network_id = ?1"];
4710
+ const params: any[] = [effectiveNetId || "default"];
4711
+ if (provider_id) { where.push(`provider_id = ?${params.length + 1}`); params.push(provider_id); }
4712
+ if (model_name) { where.push(`model_name = ?${params.length + 1}`); params.push(model_name); }
4713
+ if (daemon_node_id) { where.push(`daemon_node_id = ?${params.length + 1}`); params.push(daemon_node_id); }
4714
+ const sql = `SELECT probe_id, provider_id, model_name, daemon_node_id, status, latency_ms, error_label, probed_at, raw_status_code FROM probe_results WHERE ${where.join(" AND ")} ORDER BY probed_at DESC LIMIT ${limit ?? 100}`;
4715
+ const rows = db.all<any>(sql, ...params);
4716
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, results: rows }) }] };
4717
+ },
4718
+ );
4719
+
4720
+ // §2.3.7 daemon-facing — get_probe_request.
4721
+ server.tool(
4722
+ "get_probe_request",
4723
+ "Daemon pulls a pending probe request (called when SSE probe_provider doorbell arrives). RFC-028.",
4724
+ { probe_id: z.string().min(1).max(200) },
4725
+ async ({ probe_id }) => {
4726
+ const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
4727
+ if (!callerDaemon.ok) {
4728
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4729
+ }
4730
+ // takePendingProbeSecret enforces daemon binding + evicts
4731
+ const blob = (await import("./probe.js")).takePendingProbeSecret(probe_id, callerDaemon.daemonNodeId);
4732
+ if (!blob) {
4733
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "probe_request_unavailable", reason: "not_found_or_wrong_daemon_or_expired" }) }] };
4734
+ }
4735
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4736
+ ok: true,
4737
+ probe_id: blob.probe_id,
4738
+ vendor: blob.vendor,
4739
+ base_url: blob.base_url,
4740
+ model_name: blob.model_name,
4741
+ api_key: blob.api_key, // ephemeral; daemon writes to .env.local or in-memory only
4742
+ }) }] };
4743
+ },
4744
+ );
4745
+
4746
+ // §2.3.8 daemon-facing — ack_probe_request (STRICT whitelist via zod).
4747
+ // R3 LOCK (RFC-028 v3): ack payload has EXACTLY 4 keys. We pass a
4748
+ // ZodObject with .strict() so the MCP SDK's validateToolInput surfaces
4749
+ // any extra field (e.g. an attacker smuggling `error_message` to leak a
4750
+ // secret) as a -32602 Invalid params error at the protocol boundary
4751
+ // BEFORE our handler runs. The SDK invokes z.object(shape) by default
4752
+ // which is strip-mode; passing a fully-constructed strict object
4753
+ // bypasses that and enforces unknownKeys='strict'.
4754
+ server.registerTool(
4755
+ "ack_probe_request",
4756
+ {
4757
+ description: "Daemon acks a probe. Schema is STRICT whitelist (no error_message; v3 R3 LOCK). RFC-028.",
4758
+ // Strict-mode ZodObject so the MCP SDK's validateToolInput surfaces
4759
+ // any extra field as a -32602 Invalid params error BEFORE the
4760
+ // handler runs. server.tool() accepts only ZodRawShape (default
4761
+ // strip-mode); registerTool() accepts a constructed ZodObject and
4762
+ // honors its unknownKeys policy.
4763
+ inputSchema: z.object({
4764
+ probe_id: z.string().min(1).max(200),
4765
+ status: z.enum(["ok", "auth_fail", "quota", "rate_limit", "network_error", "timeout", "redirect_forbidden", "vendor_5xx", "other_4xx", "tls_error", "probe_resolve_unsafe_ip", "probe_target_forbidden"]),
4766
+ raw_status_code: z.number().int().min(100).max(599).optional(),
4767
+ latency_ms: z.number().int().min(0).max(60_000),
4768
+ }).strict() as any,
4769
+ },
4770
+ async (args: any) => {
4771
+ const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
4772
+ if (!callerDaemon.ok) {
4773
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4774
+ }
4775
+ try {
4776
+ const r = finalizeProbeAck(args, { network_id: callerDaemon.networkId, daemon_node_id: callerDaemon.daemonNodeId });
4777
+ return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
4778
+ } catch (e) {
4779
+ return probeFailReply(e);
4780
+ }
4781
+ },
4782
+ );
2131
4783
  }
2132
4784
 
2133
4785
  // ────────────────────────────────────────────────────────────────────
@@ -2145,6 +4797,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2145
4797
  export interface UpsertNodeWithSec1GuardInput {
2146
4798
  node_id: string;
2147
4799
  callerNetworkId: string | null;
4800
+ callerUserId?: string | null;
4801
+ callerTokenId?: string | null;
2148
4802
  node_name?: string | null;
2149
4803
  alias?: string | null;
2150
4804
  runtime?: string | null;
@@ -2157,19 +4811,60 @@ export interface UpsertNodeWithSec1GuardInput {
2157
4811
  }
2158
4812
  export type UpsertNodeOutcome =
2159
4813
  | { result: "inserted" | "updated"; node_id: string }
2160
- | { result: "refused"; reason: "cross_network"; existingNet: string | null; callerNet: string | null }
4814
+ | { result: "refused"; reason: "cross_network" | "token_node_mismatch" | "owner_mismatch"; existingNet: string | null; callerNet: string | null }
2161
4815
  | { result: "skipped"; reason: "missing_node_id" };
2162
4816
 
2163
4817
  const _norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x);
2164
4818
 
4819
+ /**
4820
+ * A capability that changes peer delivery semantics is trusted only when the
4821
+ * authenticating ntok is immutably bound to the exact reported node. Legacy
4822
+ * alias-only tokens may still heartbeat, but cannot opt a node into #698.
4823
+ */
4824
+ export function trustedConfigSnapshotForNode(
4825
+ snapshot: unknown | null | undefined,
4826
+ callerTokenId: string | null | undefined,
4827
+ nodeId: string,
4828
+ ): unknown | null | undefined {
4829
+ if (!snapshot || typeof snapshot !== "object") return snapshot;
4830
+ const copy = { ...(snapshot as Record<string, unknown>) };
4831
+ const token = callerTokenId
4832
+ ? db.get<{ bound_node_id: string | null }>(
4833
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1",
4834
+ callerTokenId,
4835
+ )
4836
+ : null;
4837
+ if (token?.bound_node_id !== nodeId) delete copy.peer_reply_inbox_capable;
4838
+ return copy;
4839
+ }
4840
+
2165
4841
  export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): UpsertNodeOutcome {
2166
4842
  if (!input.node_id) return { result: "skipped", reason: "missing_node_id" };
2167
- const existing = db.get<{ network_id: string | null }>(
2168
- "SELECT network_id FROM nodes WHERE node_id = ?1",
4843
+ const existing = db.get<{ network_id: string | null; owner_user_id: string | null }>(
4844
+ "SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1",
2169
4845
  input.node_id,
2170
4846
  );
2171
4847
  const callerNet = input.callerNetworkId;
2172
4848
 
4849
+ // RFC-036 — when a token was minted with a node_id binding, a heartbeat may
4850
+ // report only that exact node. Legacy unbound ntok rows remain compatible,
4851
+ // but they can never consume owner-gated schedule intents.
4852
+ if (input.callerTokenId) {
4853
+ const token = db.get<{ bound_node_id: string | null; user_id: string; network_id: string | null }>(
4854
+ "SELECT bound_node_id, user_id, network_id FROM api_tokens WHERE token_id = ?1",
4855
+ input.callerTokenId,
4856
+ );
4857
+ if (token?.bound_node_id && token.bound_node_id !== input.node_id) {
4858
+ return { result: "refused", reason: "token_node_mismatch", existingNet: existing?.network_id ?? null, callerNet };
4859
+ }
4860
+ if (token?.network_id && _norm(token.network_id) !== _norm(callerNet)) {
4861
+ return { result: "refused", reason: "cross_network", existingNet: existing?.network_id ?? null, callerNet };
4862
+ }
4863
+ }
4864
+ if (existing?.owner_user_id && input.callerUserId !== existing.owner_user_id) {
4865
+ return { result: "refused", reason: "owner_mismatch", existingNet: existing.network_id, callerNet };
4866
+ }
4867
+
2173
4868
  // Legacy / first-write paths: row missing OR network_id NULL → claim.
2174
4869
  const isLegacy = !existing
2175
4870
  || existing.network_id === null
@@ -2215,10 +4910,38 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
2215
4910
  callerNet ?? null,
2216
4911
  ],
2217
4912
  );
2218
- if (input.config_snapshot) {
4913
+ const trustedSnapshot = trustedConfigSnapshotForNode(
4914
+ input.config_snapshot,
4915
+ input.callerTokenId,
4916
+ input.node_id,
4917
+ );
4918
+ if (trustedSnapshot) {
4919
+ // RFC-026 §9.3 / #338 PR2+PR3 — promote daemon self-declare fields
4920
+ // to first-class indexable columns alongside the snapshot blob.
4921
+ // The snapshot stays the source of truth for non-list reads; the
4922
+ // columns exist so `list_host_supervisors` doesn't JSON.parse on
4923
+ // every call. typeof-narrow per
4924
+ // per team rule (typeof-narrow extracted JSON fields at the boundary) — zod narrowed but
4925
+ // input.config_snapshot is typed `unknown` here.
4926
+ //
4927
+ // PR3 nit ①: read from nested `daemon_capabilities.*` (canonical
4928
+ // per RFC §9.3 + matches existing hub create_node reads at
4929
+ // tools.ts:2010/2075). PR2 ate from top-level keys, which the
4930
+ // hub create_node path never read → max_concurrent_children
4931
+ // backpressure was dead config + allowlist enforcement bypassed.
4932
+ const snap = trustedSnapshot as Record<string, unknown> | null;
4933
+ const caps = (snap?.daemon_capabilities ?? null) as Record<string, unknown> | null;
4934
+ const runtimesRaw = caps?.runtimes_supported;
4935
+ const allowedRaw = caps?.allowed_secret_keys;
4936
+ const runtimesJson = Array.isArray(runtimesRaw) && runtimesRaw.every(s => typeof s === "string")
4937
+ ? JSON.stringify(runtimesRaw)
4938
+ : null;
4939
+ const allowedJson = Array.isArray(allowedRaw) && allowedRaw.every(s => typeof s === "string")
4940
+ ? JSON.stringify(allowedRaw)
4941
+ : null;
2219
4942
  db.run(
2220
- `UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
2221
- [JSON.stringify(input.config_snapshot), input.node_id],
4943
+ `UPDATE nodes SET config_snapshot = ?1, runtimes_supported = ?2, allowed_secret_keys = ?3 WHERE node_id = ?4`,
4944
+ [JSON.stringify(trustedSnapshot), runtimesJson, allowedJson, input.node_id],
2222
4945
  );
2223
4946
  // RFC-024 — finalize any pending/restarting update whose target
2224
4947
  // patch is now reflected in the snapshot. Closes the
@@ -2228,7 +4951,7 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
2228
4951
  // update_id to call ack_config_update(applied) itself. Hub does
2229
4952
  // it on the new child's behalf by content-matching the patch
2230
4953
  // against the reported snapshot.
2231
- finalizePendingMatchingUpdates(input.node_id, input.config_snapshot);
4954
+ finalizePendingMatchingUpdates(input.node_id, trustedSnapshot);
2232
4955
  }
2233
4956
  // RFC-026 §2.5 step 4 — on every register/report_status, opportunistically
2234
4957
  // close out any pending create_node request whose child_name matches
@@ -2288,6 +5011,15 @@ export function finalizePendingMatchingUpdates(
2288
5011
 
2289
5012
  const snapModel: string | null | undefined = snapshot?.model;
2290
5013
  const snapFlags: Record<string, unknown> = (snapshot?.flags && typeof snapshot.flags === "object") ? snapshot.flags : {};
5014
+ // #260 P5 — snapshot.channels is the (sorted, bare-type) channel set
5015
+ // the node currently has forked. agent-node's buildConfigSnapshot
5016
+ // always emits it (even as []), so the "absent field" case here means
5017
+ // an older pre-#260-P5 agent-node — for those, don't finalize a
5018
+ // channels-carrying patch (would false-positive on the OTHER fields).
5019
+ const snapChannelsPresent = Array.isArray(snapshot?.channels);
5020
+ const snapChannels: string[] = snapChannelsPresent
5021
+ ? (snapshot.channels as unknown[]).filter((c): c is string => typeof c === "string")
5022
+ : [];
2291
5023
 
2292
5024
  const finalizedIds: string[] = [];
2293
5025
 
@@ -2312,6 +5044,35 @@ export function finalizePendingMatchingUpdates(
2312
5044
  if (snapFlags[k] !== v) { matches = false; break; }
2313
5045
  }
2314
5046
  }
5047
+ // #260 P5 — channels field content-match. Without this the
5048
+ // node's very first startup report_status matches (patch.flags
5049
+ // is `{}` for a channels-only update, so the flags loop is
5050
+ // trivially satisfied) and hub prematurely marks the update
5051
+ // applied, deleting the pending row + bumping config_revision
5052
+ // BEFORE the doorbell reaches the child. Codex catch on PR #411.
5053
+ //
5054
+ // Set-equality is enough because the patch side comes from
5055
+ // narrowChannelsPatch → already deduped + case-folded + in
5056
+ // EDITABLE_CHANNELS iteration order; the snapshot side comes
5057
+ // from buildConfigSnapshot which mirrors the same shape.
5058
+ if (matches && Array.isArray(patch.channels)) {
5059
+ if (!snapChannelsPresent) {
5060
+ // Pre-#260-P5 agent-node — no channels field in snapshot,
5061
+ // so we can't prove the apply landed. Skip finalize; hub's
5062
+ // F-B reaper still supersedes this eventually.
5063
+ matches = false;
5064
+ } else {
5065
+ const wanted = new Set<string>(patch.channels);
5066
+ const have = new Set<string>(snapChannels);
5067
+ if (wanted.size !== have.size) {
5068
+ matches = false;
5069
+ } else {
5070
+ for (const w of wanted) {
5071
+ if (!have.has(w)) { matches = false; break; }
5072
+ }
5073
+ }
5074
+ }
5075
+ }
2315
5076
  }
2316
5077
 
2317
5078
  if (!matches) continue;