@sleep2agi/commhub-server 0.9.0-preview.3 → 0.9.0-preview.30

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 (99) 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 +188 -0
  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.ts +48 -1
  19. package/src/dashboard-slash-routing-http.test.ts +103 -0
  20. package/src/db-adapter-guard.test.ts +85 -0
  21. package/src/db-adapter.ts +64 -23
  22. package/src/db.ts +522 -32
  23. package/src/ext-token-shared.test.ts +60 -0
  24. package/src/ext-validation-regression.test.ts +131 -0
  25. package/src/external-schedule-edits-http.test.ts +316 -0
  26. package/src/external-schedule-edits.ts +314 -0
  27. package/src/file-download-authz-dev-open.test.ts +135 -0
  28. package/src/file-download-authz.test.ts +403 -0
  29. package/src/file-download-date-bucket.test.ts +436 -0
  30. package/src/file-network-scope-dev-open.test.ts +97 -0
  31. package/src/file-network-scope.test.ts +731 -0
  32. package/src/health-redaction.test.ts +637 -0
  33. package/src/index.ts +15 -2172
  34. package/src/lifecycle-guard.ts +50 -0
  35. package/src/list-host-supervisors.test.ts +314 -0
  36. package/src/mcp-write-network-resolution.test.ts +429 -0
  37. package/src/network-name-http.test.ts +71 -0
  38. package/src/network-scope.ts +101 -0
  39. package/src/node-attrs-validate.ts +88 -0
  40. package/src/node-attrs.test.ts +213 -0
  41. package/src/observer-avatar-http.test.ts +349 -0
  42. package/src/observer-push.test.ts +176 -0
  43. package/src/peer-reply-atomic.test.ts +455 -0
  44. package/src/peer-reply-capability.test.ts +70 -0
  45. package/src/probe-validate.test.ts +221 -0
  46. package/src/probe-validate.ts +104 -0
  47. package/src/probe.test.ts +80 -0
  48. package/src/probe.ts +200 -0
  49. package/src/push.ts +96 -7
  50. package/src/require-explicit-test-db.ts +9 -0
  51. package/src/resolve-port.test.ts +57 -0
  52. package/src/resolve-port.ts +44 -0
  53. package/src/response-charset.test.ts +130 -0
  54. package/src/rest-explicit-columns-http.test.ts +211 -0
  55. package/src/rest-identity.test.ts +129 -0
  56. package/src/rest-identity.ts +100 -0
  57. package/src/rest-projections.ts +67 -0
  58. package/src/rest-write-network-resolution.test.ts +133 -0
  59. package/src/runtime-label.test.ts +22 -0
  60. package/src/runtime-label.ts +21 -0
  61. package/src/scheduled-run-terminal.test.ts +268 -0
  62. package/src/scheduled-tasks-http.test.ts +479 -0
  63. package/src/scheduled-tasks.ts +549 -0
  64. package/src/send-reply-agent-warning.test.ts +202 -0
  65. package/src/send-reply-attachments.test.ts +437 -0
  66. package/src/server.ts +3492 -0
  67. package/src/shared/external-schedule-contract.ts +60 -0
  68. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  69. package/src/shared/probe-host-allowlist.ts +128 -0
  70. package/src/side-thread-command-transport.test.ts +140 -0
  71. package/src/side-thread-command-transport.ts +309 -0
  72. package/src/side-thread-http-integration.test.ts +229 -0
  73. package/src/side-thread-http.ts +504 -0
  74. package/src/side-thread.test.ts +1038 -0
  75. package/src/side-thread.ts +2241 -0
  76. package/src/skillhub-http.test.ts +88 -0
  77. package/src/stop-delete-node.test.ts +720 -0
  78. package/src/task-auth-origin-http.test.ts +87 -0
  79. package/src/task-auth-origin.test.ts +24 -0
  80. package/src/task-auth-origin.ts +7 -0
  81. package/src/task-consumption.test.ts +398 -0
  82. package/src/task-diagnostic-http.test.ts +145 -0
  83. package/src/task-diagnostic.test.ts +115 -0
  84. package/src/task-diagnostic.ts +118 -0
  85. package/src/task-idempotency-mcp.test.ts +142 -0
  86. package/src/task-idempotency.test.ts +60 -0
  87. package/src/task-idempotency.ts +75 -0
  88. package/src/task-lifecycle-watcher.test.ts +200 -0
  89. package/src/task-lifecycle-watcher.ts +49 -0
  90. package/src/task-network-resolution-http.test.ts +137 -0
  91. package/src/tasks-pagination-http.test.ts +137 -0
  92. package/src/tools.ts +2302 -169
  93. package/src/update-provider.test.ts +234 -0
  94. package/src/upload-early-reject-guard.test.ts +155 -0
  95. package/src/uploads-http.test.ts +92 -14
  96. package/src/uploads.test.ts +66 -0
  97. package/src/uploads.ts +130 -5
  98. package/src/vault.test.ts +200 -0
  99. package/src/vault.ts +156 -0
package/src/tools.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod/v4";
3
- import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken } from "./db.js";
4
- import { pushEvent } from "./push.js";
3
+ import { parseAliasFilter } from "./alias-filter.js";
4
+ import { createHash } from "node:crypto";
5
+ import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
6
+ import { getSSEStats, pushEvent, pushNetworkObserverEvent } from "./push.js";
7
+ import { assertNodeActive } from "./lifecycle-guard.js";
5
8
  import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
9
+ import { canRestWriteNetwork, getUserNetworkIds, singleNetworkId } from "./network-scope.js";
6
10
  import {
7
11
  buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
8
12
  validateName as validateChildName,
@@ -22,17 +26,39 @@ import {
22
26
  startPendingEnvGcTimer,
23
27
  startSweeperTimer,
24
28
  auditCreateNode,
29
+ auditCreateNodeStrict,
25
30
  resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
26
31
  } from "./create-node.js";
32
+ import {
33
+ vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
34
+ VaultError,
35
+ } from "./vault.js";
36
+ import { stripHostLocalPathsForCrossHostSafe, validateAttachments } from "./uploads.js";
37
+ import {
38
+ validateBaseUrl as _validateBaseUrl,
39
+ SUPPORTED_VENDORS,
40
+ ProbeValidationError,
41
+ } from "./probe-validate.js";
42
+ import {
43
+ putPendingProbeSecret,
44
+ newProbeId,
45
+ finalizeProbeAck,
46
+ startPendingProbeGcTimer,
47
+ startProbeSweeperTimer,
48
+ } from "./probe.js";
27
49
  import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias } from "./rename.js";
28
50
  import {
29
51
  ALLOWED_FLAGS,
30
52
  SECURITY_SENSITIVE_FLAGS,
53
+ EDITABLE_CHANNELS,
31
54
  computeApplyMode,
32
55
  validatePatch,
56
+ narrowChannelsPatch,
33
57
  isAllowedToChangeFlag,
34
58
  } from "./config-apply-validate.js";
35
59
  import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
60
+ import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
61
+ import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
36
62
 
37
63
  function ts(): string {
38
64
  return new Date().toTimeString().slice(0, 8);
@@ -45,7 +71,12 @@ function parseMetaJson(value: unknown): unknown | null {
45
71
 
46
72
  function normalizeMetaJson(meta: unknown): string | null {
47
73
  if (!meta || typeof meta !== "object") return null;
48
- try { return JSON.stringify(meta); } catch { return null; }
74
+ // #222 cross-host safety — strip `path` from meta.attachments[] when
75
+ // `file_id` is also present. Shared helper lives in uploads.ts so the
76
+ // REST handler in index.ts can apply the identical sanitization
77
+ // (otherwise REST and MCP transports would have different meta_json
78
+ // shapes for the same send).
79
+ try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
49
80
  }
50
81
 
51
82
  export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null) {
@@ -71,30 +102,213 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
71
102
  }],
72
103
  };
73
104
  };
74
- // If enforceNetworkId is set, override any client-supplied network_id
75
- const getNetworkId = (clientNetId?: string | null) => enforceNetworkId ?? clientNetId ?? null;
105
+ // MCP-side auth context in the shape network-scope.ts helpers take.
106
+ // null = legacy global-token / open-dev mode (unscoped, allow-all).
107
+ const mcpAuthCtx = enforceUserId ? { userId: enforceUserId, networkId: enforceNetworkId ?? null } : null;
108
+
109
+ // If enforceNetworkId is set (ntok_), override any client-supplied
110
+ // network_id. #517: for utok_ with no explicit network_id, fall back to
111
+ // the user's single membership via the SAME singleNetworkId used by REST
112
+ // POST /api/task (network-scope.ts) — utok_ rows carry network_id=null by
113
+ // design, so without this fallback single-network nodes could read
114
+ // everything but write nothing. Multi-network stays null (ambiguous) and
115
+ // canWrite rejects with network_id_required.
116
+ const getNetworkId = (clientNetId?: string | null) => {
117
+ const explicit = enforceNetworkId ?? clientNetId ?? null;
118
+ if (explicit !== null || !enforceUserId) return explicit;
119
+ return singleNetworkId({ networkId: null, networkIds: getUserNetworkIds(enforceUserId) });
120
+ };
76
121
 
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.
122
+ // Check write access — delegates to the shared canRestWriteNetwork so MCP
123
+ // and REST cannot drift again (#517). isAdmin=false: MCP has no admin
124
+ // bypass today; keep behavior identical to before the extraction.
80
125
  const canWrite = (effectiveNetworkId?: string | null): boolean => {
81
- if (!enforceUserId) return true; // legacy global token mode, allow
82
126
  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
127
+ return canRestWriteNetwork(mcpAuthCtx, netId, false);
86
128
  };
87
129
 
130
+ // #517: name the REAL cause. The old catch-all blamed permissions for
131
+ // what was actually an unresolvable network, sending operators down the
132
+ // wrong debugging path (roles/membership) for hours.
88
133
  const writeDeniedReply = (effectiveNetworkId?: string | null, action = "write") => {
89
134
  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"
135
+ let error: string;
136
+ let message: string;
137
+ if (!netId) {
138
+ const memberships = enforceUserId ? getUserNetworkIds(enforceUserId) : [];
139
+ error = "network_id_required";
140
+ message = memberships.length === 0
141
+ ? "user token has no network memberships; join or create a network first"
142
+ : `user token spans ${memberships.length} networks; pass network_id explicitly (see /api/auth/me networks[].network_id)`;
143
+ } else if (enforceUserId && !getUserNetworkRole(enforceUserId, netId)) {
144
+ error = "access_denied";
145
+ message = "access denied to requested network (not a member)";
146
+ } else {
147
+ error = "permission_denied";
148
+ message = action === "send_task"
93
149
  ? "Viewer role cannot send tasks"
94
150
  : "Viewer role cannot write to this network";
95
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied", message }) }] };
151
+ }
152
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error, message }) }] };
96
153
  };
97
154
 
155
+ const skillHubReply = (value: Record<string, unknown>) => ({
156
+ content: [{ type: "text" as const, text: JSON.stringify(value) }],
157
+ });
158
+
159
+ // SkillHub is a network registry, not a public filesystem. Content is an
160
+ // immutable SKILL.md snapshot; identity comes only from the authenticated
161
+ // MCP principal (never from request fields).
162
+ server.tool(
163
+ "submit_skill",
164
+ "Submit an immutable SKILL.md version to the caller's network SkillHub. Node identity is token-bound. New submissions require review.",
165
+ {
166
+ slug: z.string().min(2).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
167
+ name: z.string().min(1).max(120),
168
+ description: z.string().max(1000).optional(),
169
+ version: z.string().min(1).max(40).regex(/^[0-9A-Za-z]+(?:[._-][0-9A-Za-z]+)*$/),
170
+ content: z.string().min(1).max(128 * 1024).refine(value => !value.includes("\0"), "content must not contain NUL bytes"),
171
+ network_id: z.string().max(200).optional(),
172
+ },
173
+ async ({ slug, name, description, version, content, network_id: clientNetId }) => {
174
+ const effectiveNetId = getNetworkId(clientNetId);
175
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
176
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
177
+ const sourceType = callerTokenIsNetwork ? "node" : "user";
178
+ if (sourceType === "node" && !callerAlias) return skillHubReply({ ok: false, error: "node_identity_unbound" });
179
+ const contentHash = createHash("sha256").update(content, "utf8").digest("hex");
180
+ const existing = db.get<any>(
181
+ `SELECT skill_id, content_hash, status FROM skillhub_skills
182
+ WHERE network_id = ?1 AND slug = ?2 AND version = ?3`,
183
+ effectiveNetId, slug, version,
184
+ );
185
+ if (existing) {
186
+ if (existing.content_hash === contentHash) {
187
+ return skillHubReply({ ok: true, idempotent: true, skill_id: existing.skill_id, status: existing.status });
188
+ }
189
+ return skillHubReply({ ok: false, error: "skill_version_conflict", hint: "publish changed content under a new version" });
190
+ }
191
+ const skillId = `skill_${uuidv4()}`;
192
+ try {
193
+ db.run(
194
+ `INSERT INTO skillhub_skills
195
+ (skill_id, network_id, slug, name, description, version, content, content_hash, status, source_type, source_alias, created_by_user)
196
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
197
+ [skillId, effectiveNetId, slug, name.trim(), description?.trim() || "", version, content, contentHash, sourceType, callerAlias, enforceUserId || null],
198
+ );
199
+ } catch (error: any) {
200
+ // Two submitters may pass the preflight SELECT together. Resolve the
201
+ // UNIQUE race into the same deterministic idempotent/conflict contract
202
+ // instead of leaking a storage exception as HTTP/MCP 500.
203
+ if (!/unique|duplicate key/i.test(error?.message || "")) throw error;
204
+ const winner = db.get<any>(
205
+ `SELECT skill_id, content_hash, status FROM skillhub_skills
206
+ WHERE network_id = ?1 AND slug = ?2 AND version = ?3`,
207
+ effectiveNetId, slug, version,
208
+ );
209
+ if (!winner) throw error;
210
+ if (winner.content_hash === contentHash) {
211
+ return skillHubReply({ ok: true, idempotent: true, skill_id: winner.skill_id, status: winner.status });
212
+ }
213
+ return skillHubReply({ ok: false, error: "skill_version_conflict", hint: "publish changed content under a new version" });
214
+ }
215
+ db.run(
216
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
217
+ VALUES (?1, ?2, 'skill_submit', 'skill', ?3, ?4, ?5)`,
218
+ [enforceUserId || null, callerAlias || null, skillId, JSON.stringify({ slug, version, source_type: sourceType }), effectiveNetId],
219
+ );
220
+ return skillHubReply({ ok: true, skill_id: skillId, status: "pending", source_type: sourceType, source_alias: callerAlias });
221
+ },
222
+ );
223
+
224
+ server.tool(
225
+ "list_skills",
226
+ "List published skills in a network. Owners/admins may include pending review items.",
227
+ {
228
+ network_id: z.string().max(200).optional(),
229
+ include_pending: z.boolean().optional(),
230
+ query: z.string().max(120).optional(),
231
+ limit: z.number().int().min(1).max(200).optional(),
232
+ },
233
+ async ({ network_id: clientNetId, include_pending, query, limit }) => {
234
+ const effectiveNetId = getNetworkId(clientNetId);
235
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "read");
236
+ const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
237
+ if (enforceUserId && !role) return writeDeniedReply(effectiveNetId, "read");
238
+ // An ntok_ belongs to a node even when it was minted by the network
239
+ // owner. Never inherit the owner's review power through that token.
240
+ const reviewer = !callerTokenIsNetwork && (role === "owner" || role === "admin");
241
+ const showPending = !!include_pending && reviewer;
242
+ const params: unknown[] = [effectiveNetId];
243
+ let sql = `SELECT skill_id, slug, name, description, version, status, source_type, source_alias,
244
+ created_at, updated_at, reviewed_at, review_note
245
+ FROM skillhub_skills WHERE network_id = ?1`;
246
+ if (!showPending) sql += ` AND status = 'published'`;
247
+ if (query?.trim()) {
248
+ params.push(`%${query.trim()}%`);
249
+ sql += ` AND (slug LIKE ?${params.length} OR name LIKE ?${params.length} OR description LIKE ?${params.length})`;
250
+ }
251
+ sql += ` ORDER BY updated_at DESC LIMIT ${limit ?? 100}`;
252
+ return skillHubReply({ ok: true, reviewer, skills: db.all(sql, ...params) });
253
+ },
254
+ );
255
+
256
+ server.tool(
257
+ "get_skill",
258
+ "Read one SkillHub SKILL.md. Pending content is visible only to owners/admins.",
259
+ { skill_id: z.string().regex(/^skill_[A-Za-z0-9_-]+$/).max(200), network_id: z.string().max(200).optional() },
260
+ async ({ skill_id, network_id: clientNetId }) => {
261
+ const effectiveNetId = getNetworkId(clientNetId);
262
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "read");
263
+ const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
264
+ if (enforceUserId && !role) return writeDeniedReply(effectiveNetId, "read");
265
+ const row = db.get<any>(
266
+ `SELECT skill_id, slug, name, description, version, content, status,
267
+ source_type, source_alias, created_at, updated_at, reviewed_at, review_note
268
+ FROM skillhub_skills WHERE skill_id = ?1 AND network_id = ?2`,
269
+ skill_id, effectiveNetId,
270
+ );
271
+ if (!row) return skillHubReply({ ok: false, error: "skill_not_found" });
272
+ const reviewer = !callerTokenIsNetwork && (role === "owner" || role === "admin");
273
+ if (row.status !== "published" && !reviewer) {
274
+ return skillHubReply({ ok: false, error: "skill_not_found" });
275
+ }
276
+ return skillHubReply({ ok: true, skill: row });
277
+ },
278
+ );
279
+
280
+ server.tool(
281
+ "review_skill",
282
+ "Publish or reject a pending SkillHub submission. Network owner/admin only.",
283
+ {
284
+ skill_id: z.string().regex(/^skill_[A-Za-z0-9_-]+$/).max(200),
285
+ decision: z.enum(["published", "rejected"]),
286
+ note: z.string().max(1000).optional(),
287
+ network_id: z.string().max(200).optional(),
288
+ },
289
+ async ({ skill_id, decision, note, network_id: clientNetId }) => {
290
+ const effectiveNetId = getNetworkId(clientNetId);
291
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
292
+ const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
293
+ if (callerTokenIsNetwork || (role !== "owner" && role !== "admin")) return skillHubReply({ ok: false, error: "skill_review_admin_required" });
294
+ const row = db.get<any>(`SELECT status FROM skillhub_skills WHERE skill_id = ?1 AND network_id = ?2`, skill_id, effectiveNetId);
295
+ if (!row) return skillHubReply({ ok: false, error: "skill_not_found" });
296
+ if (row.status !== "pending") return skillHubReply({ ok: false, error: "skill_not_pending", status: row.status });
297
+ const updated = db.run(
298
+ `UPDATE skillhub_skills SET status = ?1, review_note = ?2, reviewed_by_user = ?3,
299
+ reviewed_at = datetime('now'), updated_at = datetime('now') WHERE skill_id = ?4 AND network_id = ?5 AND status = 'pending'`,
300
+ [decision, note?.trim() || null, enforceUserId || null, skill_id, effectiveNetId],
301
+ );
302
+ if (updated.changes !== 1) return skillHubReply({ ok: false, error: "skill_not_pending" });
303
+ db.run(
304
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
305
+ VALUES (?1, ?2, 'skill_review', 'skill', ?3, ?4, ?5)`,
306
+ [enforceUserId || null, callerAlias || null, skill_id, JSON.stringify({ decision }), effectiveNetId],
307
+ );
308
+ return skillHubReply({ ok: true, skill_id, status: decision });
309
+ },
310
+ );
311
+
98
312
  const addScope = (sql: string, params: any[], networkId?: string | null, column = "network_id"): string => {
99
313
  if (!networkId) return sql;
100
314
  sql += ` AND ${column} = ?${params.length + 1}`;
@@ -104,13 +318,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
104
318
 
105
319
  type ReadScope = { networkId?: string | null; networkIds?: string[] | null; denied?: string };
106
320
 
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
- };
321
+ // Delegates to the shared membership query (network-scope.ts) — was a
322
+ // byte-for-byte duplicate of getUserNetworkIds before #517.
323
+ const getReadableNetworkIds = (): string[] =>
324
+ enforceUserId ? getUserNetworkIds(enforceUserId) : [];
114
325
 
115
326
  const resolveReadScope = (clientNetId?: string | null): ReadScope => {
116
327
  if (!enforceUserId) return { networkId: clientNetId ?? null, networkIds: null };
@@ -125,6 +336,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
125
336
  return { networkId: null, networkIds: getReadableNetworkIds() };
126
337
  };
127
338
 
339
+ // RFC-027 §2.3 race-free invariant — assertNodeActive lives in
340
+ // server/src/lifecycle-guard.ts so REST handlers in server/src/index.ts
341
+ // can use the SAME code path. PR1.1 had it inline here; PR1.2a
342
+ // (#346 review catch) extracted because the closure scope made it
343
+ // unreachable from REST and left the §2.3 race open on dashboard
344
+ // Dispatch (POST /api/task + /api/broadcast). Per
345
+ // 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.
346
+
128
347
  const addReadScope = (sql: string, params: any[], scope: ReadScope, column = "network_id"): string => {
129
348
  if (scope.networkId) {
130
349
  sql += ` AND ${column} = ?${params.length + 1}`;
@@ -145,6 +364,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
145
364
  | { state: "offline"; alias: string; session: any; message: string }
146
365
  | { state: "not_found"; alias: string; message: string };
147
366
 
367
+ // Only call after the authenticated, network-scoped lookup resolved a
368
+ // concrete target. Error paths intentionally omit this identity object.
369
+ const actualTo = (target: Exclude<DeliveryTarget, { state: "not_found" }>, networkId?: string | null) => ({
370
+ alias: target.alias,
371
+ to_node_id: target.session?.node_id ?? null,
372
+ network_id: networkId ?? null,
373
+ });
374
+
148
375
  const scopedSessionStatus = (alias: string, networkId?: string | null) => {
149
376
  const params: any[] = [alias];
150
377
  let sql = "SELECT status, updated_at, last_seen_at, node_id FROM sessions WHERE alias = ?1";
@@ -265,6 +492,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
265
492
  uptime_seconds: z.number().nullable().optional(),
266
493
  in_flight_count: z.number().nullable().optional(),
267
494
  }).optional().describe("Per-agent process telemetry reported by agent-node"),
495
+ external_schedules: z.object({
496
+ observed_at: z.string().datetime({ offset: true }).max(64),
497
+ schedules: z.array(z.object({
498
+ id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/),
499
+ name: z.string().min(1).max(200),
500
+ kind: z.enum(["cron", "systemd", "tmux", "playwright", "custom"]),
501
+ frequency: z.string().min(1).max(120),
502
+ last_run_at: z.string().datetime({ offset: true }).max(64).nullable(),
503
+ last_status: z.enum(["success", "failed", "running", "unknown"]),
504
+ last_error: z.string().max(500).nullable(),
505
+ next_run_at: z.string().datetime({ offset: true }).max(64).nullable(),
506
+ log_ref: z.string().min(1).max(255).nullable(),
507
+ enabled: z.boolean(),
508
+ // RFC-036 — only agent-node managed cron markers advertise write
509
+ // capability. Legacy/other kinds omit these fields and stay read-only.
510
+ editable: z.boolean().optional(),
511
+ revision: z.number().int().min(0).optional(),
512
+ }).strict()).max(64),
513
+ error: z.enum(["invalid_manifest", "unsafe_manifest", "read_failed"]).optional(),
514
+ }).strict().optional().describe("Bounded node-reported external schedule snapshot; never includes host paths or commands"),
268
515
  // RFC-024 B6 — masked snapshot of the node's effective config
269
516
  // (model + 6 dashboard-editable flags). Secrets ARE NOT in this
270
517
  // shape (env._envRef stays on host); the dashboard reads this
@@ -278,9 +525,25 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
278
525
  flags: z.record(z.unknown()).optional(),
279
526
  config_revision: z.number().int().min(0).optional(),
280
527
  config_update_capable: z.boolean().optional(),
528
+ peer_reply_inbox_capable: z.literal(true).optional(),
529
+ // RFC-026 P2 / #338 — daemon role surfaced for hub /api/nodes
530
+ // discovery (#337 extracts this field). "host_supervisor" =
531
+ // anet daemon. Default-stripping zod would drop this otherwise.
532
+ role: z.string().max(64).optional().nullable(),
533
+ // RFC-026 §9.3 / #338 PR3 — daemon self-declare nested under
534
+ // `daemon_capabilities` (canonical shape per existing hub reads
535
+ // at tools.ts:2010/2075 — PR1/PR2 placed these top-level, hub
536
+ // never saw them, max_concurrent_children stayed default + the
537
+ // allowlists stayed unenforced. PR3 nit ① per 通信龙).
538
+ // Soft caps avoid abuse via attacker daemon.
539
+ daemon_capabilities: z.object({
540
+ runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
541
+ allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
542
+ max_concurrent_children: z.number().int().min(1).max(1000).optional(),
543
+ }).optional(),
281
544
  }).optional().describe("RFC-024 — masked node config snapshot"),
282
545
  },
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 }) => {
546
+ 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
547
  const effectiveNetId = getNetworkId(netId);
285
548
  const sessionNetId = effectiveNetId ?? "default";
286
549
  if (!callerTokenIsNetwork || !enforceNetworkId) {
@@ -318,6 +581,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
318
581
  };
319
582
  }
320
583
  }
584
+ // #203 identity guard — network tokens must not silently rebind their
585
+ // own name via report_status. Without this, a runtime whose ALIAS
586
+ // drifted (env leak / wrong --alias / CurrentAliasResolver seeded from
587
+ // the wrong node_id) could rewrite api_tokens.name and cause every
588
+ // subsequent send_task from this token to be attributed to the drifted
589
+ // alias — the observed #203 symptom (grokB's send arriving as
590
+ // from=grokA). Only the legit rename path (rename.ts) may cross the
591
+ // token→alias binding. Symmetric to fromIdentityMismatchReply on the
592
+ // send side (test198).
593
+ if (callerTokenIsNetwork && callerAlias && !canonical.renamed && effectiveAlias !== callerAlias) {
594
+ return {
595
+ content: [{
596
+ type: "text" as const,
597
+ text: JSON.stringify({
598
+ ok: false,
599
+ error: "alias_identity_mismatch",
600
+ message: "report_status alias does not match the token-bound node alias; use anet node rename to change identity",
601
+ token_alias: callerAlias,
602
+ reported_alias: effectiveAlias,
603
+ }),
604
+ }],
605
+ };
606
+ }
321
607
  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
608
  if (callerTokenIsNetwork && callerTokenId) {
323
609
  try {
@@ -342,6 +628,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
342
628
  const processCpuPct = typeof proc?.cpu_pct === "number" ? proc.cpu_pct : null;
343
629
  const processUptimeSeconds = typeof proc?.uptime_seconds === "number" ? proc.uptime_seconds : null;
344
630
  const processInFlightCount = typeof proc?.in_flight_count === "number" ? proc.in_flight_count : null;
631
+ const externalSchedulesJson = externalSchedules === undefined ? null : JSON.stringify(externalSchedules);
632
+ const trustedCurrentSnapshot = node_id
633
+ ? trustedConfigSnapshotForNode(cfgSnap ?? null, callerTokenId ?? null, node_id)
634
+ : null;
635
+ const peerReplyInboxCapable = !!(
636
+ trustedCurrentSnapshot
637
+ && typeof trustedCurrentSnapshot === "object"
638
+ && (trustedCurrentSnapshot as Record<string, unknown>).peer_reply_inbox_capable === true
639
+ );
345
640
  const statusHostTelemetry = host ? {
346
641
  hostname: hostHostname,
347
642
  ip: hostIp,
@@ -366,8 +661,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
366
661
  // Only delete same-alias sessions within the same network
367
662
  db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [effectiveAlias, resume_id, sessionNetId]);
368
663
  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'))
664
+ `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)
665
+ 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
666
  ON CONFLICT(resume_id) DO UPDATE SET
372
667
  alias = COALESCE(?2, sessions.alias), tmux_name = COALESCE(?3, sessions.tmux_name),
373
668
  server = COALESCE(?4, sessions.server), ip = COALESCE(?5, sessions.ip),
@@ -392,8 +687,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
392
687
  process_cpu_pct = COALESCE(?31, sessions.process_cpu_pct),
393
688
  process_uptime_seconds = COALESCE(?32, sessions.process_uptime_seconds),
394
689
  process_in_flight_count = COALESCE(?33, sessions.process_in_flight_count),
690
+ external_schedules = COALESCE(?34, sessions.external_schedules),
691
+ peer_reply_inbox_capable = ?35,
395
692
  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]
693
+ [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
694
  );
398
695
  if (host || proc) {
399
696
  db.run(
@@ -443,6 +740,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
443
740
  upsertNodeWithSec1Guard({
444
741
  node_id,
445
742
  callerNetworkId: effectiveNetId ?? null,
743
+ callerUserId: enforceUserId ?? null,
744
+ callerTokenId: callerTokenId ?? null,
446
745
  node_name: nn || effectiveAlias,
447
746
  alias: effectiveAlias,
448
747
  runtime: nodeRuntime,
@@ -534,6 +833,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
534
833
  } else {
535
834
  updatedTaskId = task;
536
835
  }
836
+ if (updatedTaskId) syncScheduledRunForTask(updatedTaskId, effectiveNetId);
537
837
  });
538
838
  // Log event after transaction
539
839
  if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
@@ -594,7 +894,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
594
894
  const rows0 = db.get<{ cnt: number }>(countSql, ...countParams);
595
895
  console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
596
896
  const rowsParams: any[] = [alias];
597
- let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json
897
+ let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json,
898
+ CASE WHEN type = 'task' THEN COALESCE(task_id, id) ELSE task_id END AS task_id
598
899
  FROM inbox WHERE session_name = ?1 AND acked = 0`;
599
900
  rowsSql = addReadScope(rowsSql, rowsParams, readScope);
600
901
  rowsSql += ` ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
@@ -618,13 +919,32 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
618
919
  alias: z.string().min(1).max(200).describe("Session alias"),
619
920
  message_id: z.string().min(1).max(200),
620
921
  response: z.string().max(10000).optional(),
922
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
621
923
  },
622
- async ({ alias, message_id, response }) => {
623
- const effectiveNetId = getNetworkId(null);
924
+ async ({ alias, message_id, response, network_id: netId }) => {
925
+ const effectiveNetId = getNetworkId(netId);
624
926
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
625
927
  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";
928
+ // New task consumers ACK by the logical tasks.task_id exposed by
929
+ // get_inbox. Keep exact inbox.id support for legacy consumers and for
930
+ // non-task messages. Restrict the lookup to pending rows: after a retry
931
+ // the original row can have id == task_id but is already ACKed, while
932
+ // the current row has a fresh transport id and the same logical task_id.
933
+ const inboxTaskParams: any[] = [message_id, alias];
934
+ let inboxTaskSql = `SELECT id, type, COALESCE(task_id, id) AS task_id
935
+ FROM inbox
936
+ WHERE session_name = ?2 AND acked = 0
937
+ AND (id = ?1 OR (type = 'task' AND task_id = ?1))`;
938
+ inboxTaskSql = addScope(inboxTaskSql, inboxTaskParams, effectiveNetId);
939
+ inboxTaskSql += " ORDER BY created_at DESC LIMIT 1";
940
+ const inboxTask = db.get<{ id: string; type: string; task_id: string }>(inboxTaskSql, ...inboxTaskParams);
941
+ if (!inboxTask) {
942
+ return {
943
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "message not found or already acknowledged" }) }],
944
+ };
945
+ }
946
+ const ackParams: any[] = [inboxTask.id, alias];
947
+ let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2 AND acked = 0";
628
948
  ackSql = addScope(ackSql, ackParams, effectiveNetId);
629
949
  const result = db.run(ackSql, ackParams);
630
950
  if (result.changes === 0) {
@@ -634,11 +954,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
634
954
  }
635
955
  // V2: sync tasks table — ack_inbox means delivered→acked
636
956
  try {
637
- const taskParams: any[] = [message_id];
957
+ if (inboxTask?.type !== "task") {
958
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }] };
959
+ }
960
+ const taskParams: any[] = [inboxTask.task_id];
638
961
  let taskSql = "UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'";
639
962
  taskSql = addScope(taskSql, taskParams, effectiveNetId);
640
963
  const ackResult = db.run(taskSql, taskParams);
641
- if (ackResult.changes > 0) logTaskEvent(message_id, "delivered", "acked", alias);
964
+ if (ackResult.changes > 0) logTaskEvent(inboxTask.task_id, "delivered", "acked", alias);
642
965
  } catch {}
643
966
  return {
644
967
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }],
@@ -646,22 +969,169 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
646
969
  }
647
970
  );
648
971
 
972
+ // #520 — two monotonic runtime-evidence levels for exact tasks.
973
+ //
974
+ // runtime_submitted_at means agent-node handed the body to the vendor
975
+ // runtime. consumed_at is stronger: an attributable turn-start or first
976
+ // activity event came back. Merely fetching/acking an inbox row sets neither.
977
+ // Node identity comes exclusively from the ntok; callers cannot self-report
978
+ // an alias or node_id. A consumed mark also fills runtime_submitted_at because
979
+ // that stronger fact logically implies submission.
980
+ const markTaskRuntimeEvidence = (
981
+ taskIds: string[],
982
+ level: "submitted" | "consumed",
983
+ ) => {
984
+ const task_ids = taskIds;
985
+ if (!callerTokenIsNetwork || !callerAlias || !enforceNetworkId) {
986
+ return {
987
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_required" }) }],
988
+ };
989
+ }
990
+ // PgAdapter.transaction currently opens a fresh subprocess/connection
991
+ // per statement. The all-or-nothing batch promise cannot be made there;
992
+ // refuse evidence rather than publish a partially stamped batch.
993
+ if (db.dialect !== "sqlite") {
994
+ return {
995
+ content: [{ type: "text" as const, text: JSON.stringify({
996
+ ok: false,
997
+ error: "task_runtime_evidence_backend_unsupported",
998
+ }) }],
999
+ };
1000
+ }
1001
+
1002
+ const uniqueTaskIds = [...new Set(task_ids)];
1003
+ if (uniqueTaskIds.length !== task_ids.length) {
1004
+ return {
1005
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_id" }) }],
1006
+ };
1007
+ }
1008
+
1009
+ const placeholders = uniqueTaskIds.map((_, i) => `?${i + 2}`).join(", ");
1010
+
1011
+ // Keep ownership preflight and every stamp in one SQLite transaction.
1012
+ // This closes the cross-process preflight→UPDATE race: no other writer
1013
+ // can reassign a task between the identity decision and the write.
1014
+ const outcome = db.transaction(() => {
1015
+ const canonicalCaller = resolveCanonicalAlias(enforceNetworkId, callerAlias).alias;
1016
+ const callerSession = db.get<{ node_id: string | null }>(
1017
+ `SELECT node_id FROM sessions
1018
+ WHERE network_id = ?1 AND alias = ?2
1019
+ ORDER BY updated_at DESC LIMIT 1`,
1020
+ enforceNetworkId,
1021
+ canonicalCaller,
1022
+ );
1023
+ const rows = db.all<{ task_id: string; to_node_id: string | null; to_name: string }>(
1024
+ `SELECT task_id, to_node_id, to_name FROM tasks
1025
+ WHERE network_id = ?1 AND task_id IN (${placeholders})`,
1026
+ enforceNetworkId,
1027
+ ...uniqueTaskIds,
1028
+ );
1029
+ const owned = new Map(rows.map((row) => [row.task_id, row]));
1030
+ const rejectedTaskId = uniqueTaskIds.find((taskId) => {
1031
+ const row = owned.get(taskId);
1032
+ if (!row) return true;
1033
+ // Prefer immutable node identity whenever the task has one. Direct
1034
+ // and legacy token-bound sessions legitimately have NULL node_id;
1035
+ // only that shape may fall back to the canonical token alias.
1036
+ if (row.to_node_id) return row.to_node_id !== callerSession?.node_id;
1037
+ return resolveCanonicalAlias(enforceNetworkId, row.to_name).alias !== canonicalCaller;
1038
+ });
1039
+ if (rejectedTaskId) {
1040
+ return { ok: false as const, error: "task_not_owned", task_id: rejectedTaskId };
1041
+ }
1042
+
1043
+ for (const taskId of uniqueTaskIds) {
1044
+ const row = owned.get(taskId)!;
1045
+ const ownershipSql = row.to_node_id
1046
+ ? "to_node_id = ?3"
1047
+ : "to_node_id IS NULL AND to_name = ?3";
1048
+ const ownerValue = row.to_node_id ?? row.to_name;
1049
+ let updateResult;
1050
+ if (level === "consumed") {
1051
+ updateResult = db.run(
1052
+ `UPDATE tasks SET
1053
+ runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now')),
1054
+ consumed_at = COALESCE(consumed_at, datetime('now'))
1055
+ WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
1056
+ [enforceNetworkId, taskId, ownerValue],
1057
+ );
1058
+ } else {
1059
+ updateResult = db.run(
1060
+ `UPDATE tasks SET runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now'))
1061
+ WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
1062
+ [enforceNetworkId, taskId, ownerValue],
1063
+ );
1064
+ }
1065
+ if (updateResult.changes !== 1) {
1066
+ // A zero-row write after a successful preflight is an invariant
1067
+ // failure, never a successful evidence report.
1068
+ throw new Error(`task_runtime_evidence_write_race:${taskId}`);
1069
+ }
1070
+ }
1071
+ const evidenceRows = db.all<{
1072
+ task_id: string;
1073
+ runtime_submitted_at: string;
1074
+ consumed_at: string | null;
1075
+ }>(
1076
+ `SELECT task_id, runtime_submitted_at, consumed_at FROM tasks
1077
+ WHERE network_id = ?1 AND task_id IN (${placeholders})`,
1078
+ enforceNetworkId,
1079
+ ...uniqueTaskIds,
1080
+ );
1081
+ return { ok: true as const, tasks: evidenceRows };
1082
+ });
1083
+
1084
+ return {
1085
+ content: [{
1086
+ type: "text" as const,
1087
+ text: JSON.stringify(outcome),
1088
+ }],
1089
+ };
1090
+ };
1091
+
1092
+ const taskRuntimeEvidenceSchema = {
1093
+ task_ids: z.array(z.string().min(1).max(200)).min(1).max(100),
1094
+ };
1095
+
1096
+ server.tool(
1097
+ "mark_tasks_runtime_submitted",
1098
+ "Internal agent-node signal: exact task bodies were submitted to this token-bound node's vendor runtime.",
1099
+ taskRuntimeEvidenceSchema,
1100
+ async ({ task_ids }) => markTaskRuntimeEvidence(task_ids, "submitted"),
1101
+ );
1102
+
1103
+ server.tool(
1104
+ "mark_tasks_consumed",
1105
+ "Internal agent-node signal: exact tasks produced attributable turn-start/activity evidence in this token-bound node's runtime.",
1106
+ taskRuntimeEvidenceSchema,
1107
+ async ({ task_ids }) => markTaskRuntimeEvidence(task_ids, "consumed"),
1108
+ );
1109
+
649
1110
  // ═══════════════════════════════════════════
650
1111
  // Hub Tools (5)
651
1112
  // ═══════════════════════════════════════════
652
1113
 
653
1114
  server.tool(
654
1115
  "get_all_status",
655
- "Get status of all sessions. Hub uses this for the patrol loop.",
1116
+ "Get status of all sessions. Hub uses this for the patrol loop. " +
1117
+ "Pass filter_alias (comma-separated) when you only care about specific " +
1118
+ "nodes — the unfiltered result is one row per session with 31 columns and " +
1119
+ "is large enough on a real fleet that callers cannot read it.",
656
1120
  {
657
1121
  filter_status: z.string().max(50).optional(),
658
1122
  filter_server: z.string().max(200).optional(),
1123
+ // 2026-08-17: on a 222-session hub the unfiltered response is ~259 KB, past
1124
+ // what an MCP client can take in one result — so the caller that wanted the
1125
+ // status of THREE nodes could not get it from this tool at all. The patrol
1126
+ // loop still wants everything, hence optional rather than required.
1127
+ filter_alias: z.string().max(2000).optional()
1128
+ .describe("One alias, or several separated by commas. Exact matches only."),
659
1129
  network_id: z.string().max(200).optional().describe("Filter by network"),
660
1130
  },
661
- async ({ filter_status, filter_server, network_id: netId }) => {
1131
+ async ({ filter_status, filter_server, filter_alias, network_id: netId }) => {
662
1132
  const readScope = resolveReadScope(netId);
663
1133
  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) : ""}`);
1134
+ 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
1135
 
666
1136
  // Round-2/4 review ③: stale-marking moved to startStaleSessionSweeper()
667
1137
  // (background timer, ~60s cadence). Read path no longer fires UPDATE.
@@ -670,20 +1140,42 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
670
1140
  sql = addReadScope(sql, params, readScope);
671
1141
  if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
672
1142
  if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
1143
+ const aliasFilter = parseAliasFilter(filter_alias);
1144
+ const aliases = aliasFilter.aliases;
1145
+ if (aliasFilter.sql) {
1146
+ sql += aliasFilter.sql;
1147
+ params.push(...aliases);
1148
+ }
673
1149
  sql += " ORDER BY updated_at DESC";
674
1150
  const sessions = db.all(sql, ...params);
675
1151
 
1152
+ // `summary` has always counted every session in the read scope, ignoring
1153
+ // filter_status / filter_server — and now filter_alias. That is fine for
1154
+ // the patrol loop, but a caller who asked about three aliases and gets
1155
+ // back three rows plus "idle: 96" can easily read the 96 as being about
1156
+ // their three. A count that does not say what it counted invites exactly
1157
+ // that. So the response now says so, rather than the semantics changing
1158
+ // under existing callers.
676
1159
  const summaryParams: any[] = [];
677
1160
  let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
678
1161
  summarySql = addReadScope(summarySql, summaryParams, readScope);
679
1162
  summarySql += " GROUP BY status";
680
1163
  const summary = db.all(summarySql, ...summaryParams);
681
1164
 
1165
+ const filtered = !!(filter_status || filter_server || aliases.length > 0);
682
1166
  return {
683
1167
  content: [
684
1168
  {
685
1169
  type: "text" as const,
686
- text: JSON.stringify({ ok: true, sessions, summary }),
1170
+ text: JSON.stringify({
1171
+ ok: true,
1172
+ sessions,
1173
+ summary,
1174
+ summary_scope: filtered
1175
+ ? "every session in the read scope — NOT narrowed by the filters applied to `sessions`"
1176
+ : "every session in the read scope",
1177
+ sessions_returned: sessions.length,
1178
+ }),
687
1179
  },
688
1180
  ],
689
1181
  };
@@ -741,7 +1233,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
741
1233
  },
742
1234
  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
1235
  const effectiveNetId = getNetworkId(netId);
744
- const metaJson = normalizeMetaJson(meta);
1236
+ const authOrigin: TaskAuthOrigin = callerTokenIsNetwork
1237
+ ? "node"
1238
+ : enforceUserId
1239
+ ? "user"
1240
+ : "legacy";
1241
+ const metaJson = normalizeMetaJson(stampTaskAuthOrigin(meta, authOrigin));
745
1242
 
746
1243
  // Role check FIRST — round5 follow-up (通信牛 oracle catch):
747
1244
  // the explicit-parent verification below distinguishes
@@ -816,6 +1313,45 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
816
1313
  const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
817
1314
  if (target.state === "not_found") return deliveryTargetReply(target)!;
818
1315
 
1316
+ // Dashboard sends carry a random client_request_id inside meta. Derive
1317
+ // the task primary key from authenticated scope + that request id so a
1318
+ // lost HTTP response can be retried safely, even after a hub restart.
1319
+ // The existing row must match the full request; key reuse with changed
1320
+ // content/target fails closed instead of silently returning another task.
1321
+ const clientRequestId = clientRequestIdFromMeta(meta);
1322
+ const id = clientRequestId
1323
+ ? idempotentTaskId(effectiveNetId ?? null, from_session, clientRequestId)
1324
+ : uuidv4();
1325
+ if (clientRequestId) {
1326
+ const existing = db.get<StoredIdempotentTask & { to_node_id: string | null }>(
1327
+ "SELECT task_id, from_name, to_node_id, to_name, priority, content, network_id, meta_json, status FROM tasks WHERE task_id = ?1",
1328
+ [id],
1329
+ );
1330
+ if (existing) {
1331
+ if (!idempotentTaskMatches(existing, {
1332
+ fromName: from_session, toName: targetAlias, priority, content: task,
1333
+ networkId: effectiveNetId ?? null, metaJson,
1334
+ })) {
1335
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1336
+ ok: false, error: "idempotency_conflict",
1337
+ message: "client_request_id was already used with a different task payload",
1338
+ }) }] };
1339
+ }
1340
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1341
+ ok: true, message_id: existing.task_id, task_id: existing.task_id,
1342
+ task_status: existing.status, idempotent_replay: true,
1343
+ // A replay acknowledges the already-persisted dispatch. Use that
1344
+ // row rather than today's session record so alias reuse or a
1345
+ // restarted process cannot rewrite historical recipient identity.
1346
+ actual_to: {
1347
+ alias: existing.to_name,
1348
+ to_node_id: existing.to_node_id ?? null,
1349
+ network_id: existing.network_id ?? null,
1350
+ },
1351
+ }) }] };
1352
+ }
1353
+ }
1354
+
819
1355
  // #212 dedup guardrail. If this exact (from, to, content) has already
820
1356
  // been delivered within COMMHUB_SEND_DEDUP_WINDOW_MS (default 5 min)
821
1357
  // we refuse the call and surface a structured `duplicate_send`
@@ -836,18 +1372,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
836
1372
  return { content: [{ type: "text" as const, text: JSON.stringify(payload) }] };
837
1373
  }
838
1374
 
1375
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 1/6).
1376
+ {
1377
+ const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
1378
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1379
+ }
1380
+
839
1381
  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
1382
  const fromNodeId = resolveNodeIdForAlias(from_session, effectiveNetId);
842
1383
  const targetNodeId = target.session?.node_id ?? null;
1384
+ const resolvedActualTo = actualTo(target, effectiveNetId);
843
1385
  // 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
844
1386
  // dashboard 在派任务一刻就反映出"任务已下发",不再等 agent 的
845
1387
  // report_status 心跳;status 字段交给 agent,避免与 working/idle
846
1388
  // 报告冲突)。
847
1389
  db.transaction(() => {
848
1390
  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)`,
1391
+ `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, context, from_session, requires_response, network_id, meta_json)
1392
+ VALUES (?1, ?1, ?2, ?3, 'task', ?4, ?5, ?6, ?7, 'reply', ?8, ?9)`,
851
1393
  [id, targetAlias, targetNodeId, priority, task, context ?? null, from_session, effectiveNetId ?? null, metaJson]
852
1394
  );
853
1395
  db.run(
@@ -880,6 +1422,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
880
1422
  if (target.state === "online") {
881
1423
  pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
882
1424
  }
1425
+ // #461 network observer summary — unconditional (task row exists
1426
+ // even when the target is offline/queued), metadata only.
1427
+ pushNetworkObserverEvent(effectiveNetId, { type: "new_task", task_id: id, from: from_session, to: targetAlias, status: target.state === "online" ? "delivered" : "queued", priority });
883
1428
 
884
1429
  if (target.state === "offline") {
885
1430
  return {
@@ -894,6 +1439,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
894
1439
  task_id: id,
895
1440
  message_id: id,
896
1441
  session_status: target.session.status ?? "offline",
1442
+ actual_to: resolvedActualTo,
897
1443
  ...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
898
1444
  }),
899
1445
  }],
@@ -907,6 +1453,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
907
1453
  text: JSON.stringify({
908
1454
  ok: true,
909
1455
  message_id: id,
1456
+ actual_to: resolvedActualTo,
910
1457
  ...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
911
1458
  session_status: target.session?.status ?? "unknown",
912
1459
  }),
@@ -923,14 +1470,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
923
1470
  alias: z.string().min(1).max(200).describe("Target session alias"),
924
1471
  message: z.string().min(1).max(10000).describe("Message content"),
925
1472
  from_session: z.string().max(200).optional(),
1473
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
926
1474
  },
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);
1475
+ async ({ alias, message, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1476
+ const effectiveNetId = getNetworkId(netId);
929
1477
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
930
1478
  const canonical = resolveCanonicalAlias(effectiveNetId, alias);
931
1479
  const targetAlias = canonical.alias;
932
1480
  const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
933
1481
  if (target.state === "not_found") return deliveryTargetReply(target)!;
1482
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 2/6).
1483
+ {
1484
+ const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
1485
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1486
+ }
934
1487
  console.log(`[${ts()}] ${from_session} → send_message → ${targetAlias}: ${message.slice(0, 60)}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
935
1488
  const id = uuidv4();
936
1489
  db.run(
@@ -969,96 +1522,225 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
969
1522
  }
970
1523
  );
971
1524
 
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
- {
1525
+ // ── V2/V3 reply primitives ──
1526
+ const replyToolSchema = {
977
1527
  alias: z.string().min(1).max(200).describe("Target session alias"),
978
1528
  text: z.string().min(1).max(10000).describe("Reply content"),
979
1529
  in_reply_to: z.string().max(200).optional().describe("Original task/message ID"),
980
1530
  status: z.enum(["replied", "failed", "cancelled"]).optional().default("replied").describe("Task outcome"),
981
1531
  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);
1532
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1533
+ // #507 — top-level attachments (parity with REST /api/task L506). MCP Zod
1534
+ // otherwise silently strips unknown fields, so a caller passing
1535
+ // `attachments` before this schema entry existed would see `ok:true`
1536
+ // and never learn the attachments were dropped. Validated by
1537
+ // validateAttachments (uploads.ts) — the same helper the REST path uses.
1538
+ 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."),
1539
+ // #507 — optional structured metadata (parity with send_task L837). If
1540
+ // both `attachments` (top-level) and `meta.attachments` are supplied,
1541
+ // top-level wins (same rule as REST /api/task L2101).
1542
+ meta: z.any().optional().describe("Optional structured reply metadata, e.g. { attachments: [...] }."),
1543
+ };
1544
+ const handleReply = async (args: any, peerCapabilityRequired: boolean) => {
1545
+ const { alias, text, in_reply_to, status: replyStatus = "replied", from_session: _fromIn, network_id: netId, attachments, meta } = args;
1546
+ const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1547
+ const effectiveNetId = getNetworkId(netId);
985
1548
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
986
- console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
1549
+ if (peerCapabilityRequired && !in_reply_to) {
1550
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "peer_reply_task_required" }) }] };
1551
+ }
1552
+
1553
+ // #507 — validate attachments BEFORE any DB write. Rejects malformed
1554
+ // input (bad file_id, >20 items, size > cap, non-object item, wrong
1555
+ // type field) with an explicit error the caller can act on. Empty /
1556
+ // absent attachments return { ok: true, attachments: [] } — the
1557
+ // reverse-(e) invariant (no attachments → behavior unchanged) is
1558
+ // pinned by tests that assert byte-identical response shape
1559
+ // before/after this validation runs.
1560
+ const attachmentsResult = validateAttachments(attachments ?? (meta && typeof meta === "object" ? (meta as any).attachments : undefined));
1561
+ if (!attachmentsResult.ok) {
1562
+ return {
1563
+ content: [{
1564
+ type: "text" as const,
1565
+ text: JSON.stringify({
1566
+ ok: false,
1567
+ error: "bad_attachments",
1568
+ message: attachmentsResult.error,
1569
+ }),
1570
+ }],
1571
+ };
1572
+ }
1573
+ const mergedMeta = attachmentsResult.attachments.length
1574
+ ? { ...(meta && typeof meta === "object" ? meta : {}), attachments: attachmentsResult.attachments }
1575
+ : meta;
1576
+ const metaJson = normalizeMetaJson(mergedMeta);
1577
+
1578
+ const canonicalReplyTarget = resolveCanonicalAlias(effectiveNetId, alias);
1579
+ const replyTargetAlias = canonicalReplyTarget.alias;
1580
+ console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${replyTargetAlias}: ${text.slice(0, 60)}${attachmentsResult.attachments.length ? ` [+${attachmentsResult.attachments.length} attachments]` : ""}${canonicalReplyTarget.renamed ? ` [renamed from ${alias}]` : ""}`);
987
1581
  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
- };
1007
- }
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
- };
1022
- }
1582
+ const replyTargetNodeId = resolveNodeIdForAlias(replyTargetAlias, effectiveNetId);
1583
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 3/6).
1584
+ {
1585
+ const lc = assertNodeActive(replyTargetAlias, effectiveNetId ?? null);
1586
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1023
1587
  }
1024
- const replyTargetNodeId = resolveNodeIdForAlias(alias, effectiveNetId);
1025
- const replyLogged = db.transaction(() => {
1588
+ const replyOutcome = db.transaction(() => {
1589
+ type ReplyTask = {
1590
+ status: string;
1591
+ from_node_id: string | null;
1592
+ from_name: string;
1593
+ to_name: string;
1594
+ to_node_id: string | null;
1595
+ };
1596
+ let taskBefore: ReplyTask | null = null;
1597
+ let callerNodeId: string | null = null;
1598
+ if (in_reply_to) {
1599
+ const taskParams: any[] = [in_reply_to];
1600
+ let taskSql = "SELECT status, from_node_id, from_name, to_name, to_node_id FROM tasks WHERE task_id = ?1";
1601
+ taskSql = addScope(taskSql, taskParams, effectiveNetId);
1602
+ taskBefore = db.get<ReplyTask>(taskSql, ...taskParams) ?? null;
1603
+ if (!taskBefore) return { ok: false as const, error: "reply_task_not_found" as const };
1604
+ if (!["created", "delivered", "acked", "running"].includes(taskBefore.status)) {
1605
+ return { ok: false as const, error: "reply_task_terminal" as const, taskStatus: taskBefore.status };
1606
+ }
1607
+
1608
+ // V3 atomic peer replies are capability-negotiated. Both identity
1609
+ // bindings and the recipient capability are re-read inside this
1610
+ // transaction before any inbox/task/run write. Legacy/unbound rows
1611
+ // fail toward compatibility send_reply, which terminalizes the
1612
+ // original task instead of creating a second response-requiring row.
1613
+ if (peerCapabilityRequired) {
1614
+ if (!taskBefore.from_node_id) {
1615
+ // Origin type is an intrinsic property of the task and must be
1616
+ // decided before caller capability. A Dashboard/human task still
1617
+ // needs the established send_reply terminalization path when the
1618
+ // replying node uses a legacy/unbound token.
1619
+ return { ok: false as const, error: "peer_reply_origin_not_node" as const };
1620
+ }
1621
+ if (!callerTokenIsNetwork || !callerTokenId || !enforceNetworkId) {
1622
+ return { ok: false as const, error: "peer_reply_node_token_required" as const };
1623
+ }
1624
+ const token = db.get<{ bound_node_id: string | null }>(
1625
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
1626
+ callerTokenId,
1627
+ enforceNetworkId,
1628
+ );
1629
+ if (!token?.bound_node_id || !taskBefore.to_node_id) {
1630
+ return { ok: false as const, error: "peer_reply_unsupported" as const };
1631
+ }
1632
+ if (token.bound_node_id !== taskBefore.to_node_id) {
1633
+ return { ok: false as const, error: "reply_task_not_owned" as const };
1634
+ }
1635
+ callerNodeId = token.bound_node_id;
1636
+ const canonicalTarget = replyTargetAlias;
1637
+ const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, taskBefore.from_name).alias;
1638
+ if (canonicalTarget !== canonicalOrigin) {
1639
+ return { ok: false as const, error: "reply_target_mismatch" as const };
1640
+ }
1641
+ const recipient = db.get<{ peer_reply_inbox_capable: number }>(
1642
+ `SELECT peer_reply_inbox_capable FROM sessions
1643
+ WHERE node_id = ?1 AND network_id = ?2 AND alias = ?3
1644
+ ORDER BY updated_at DESC LIMIT 1`,
1645
+ taskBefore.from_node_id,
1646
+ enforceNetworkId,
1647
+ canonicalOrigin,
1648
+ );
1649
+ if (recipient?.peer_reply_inbox_capable !== 1) {
1650
+ return { ok: false as const, error: "peer_reply_unsupported" as const };
1651
+ }
1652
+ } else if (callerTokenIsNetwork && enforceNetworkId) {
1653
+ // Compatibility send_reply is still an exact task transition for
1654
+ // node callers. Bind it to the token-authenticated assignee alias
1655
+ // and immutable original sender. Alias binding survives a
1656
+ // legitimate node-id rotation without letting another node close
1657
+ // the task.
1658
+ const canonicalCaller = resolveCanonicalAlias(enforceNetworkId, from_session).alias;
1659
+ const canonicalAssignee = resolveCanonicalAlias(enforceNetworkId, taskBefore.to_name).alias;
1660
+ if (canonicalCaller !== canonicalAssignee) {
1661
+ return { ok: false as const, error: "reply_task_not_owned" as const };
1662
+ }
1663
+ const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, taskBefore.from_name).alias;
1664
+ if (replyTargetAlias !== canonicalOrigin) {
1665
+ return { ok: false as const, error: "reply_target_mismatch" as const };
1666
+ }
1667
+ }
1668
+
1669
+ }
1670
+
1671
+ // #507 — write meta_json on inbox insert (parity with send_task L952).
1672
+ // Prior to this the attachments field on a send_reply call was
1673
+ // silently stripped by MCP Zod, leaving `ok:true` with no persisted
1674
+ // meta.
1026
1675
  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]
1676
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id, meta_json)
1677
+ VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7, ?8)`,
1678
+ [id, replyTargetAlias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null, metaJson]
1030
1679
  );
1031
1680
 
1032
1681
  // 更新 tasks 表
1033
1682
  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')`;
1683
+ // #507 — persist meta_json onto the tasks row too, so
1684
+ // dashboard's task view sees the reply's attachments alongside
1685
+ // the reply text (parity with send_task L957/959 which writes
1686
+ // meta_json into both inbox and tasks). The COALESCE guards
1687
+ // against clobbering pre-existing meta_json when this reply
1688
+ // brings no attachments (metaJson === null → keep the existing
1689
+ // task meta_json unchanged). Reverse-(e) invariant.
1690
+ const updateParams: any[] = [replyStatus, text, metaJson, in_reply_to];
1691
+ let updateSql = `UPDATE tasks
1692
+ SET status = ?1,
1693
+ result = ?2,
1694
+ completed_at = datetime('now'),
1695
+ meta_json = COALESCE(?3, meta_json)
1696
+ WHERE task_id = ?4 AND status IN ('created', 'delivered', 'acked', 'running')`;
1697
+ if (peerCapabilityRequired && callerTokenIsNetwork && taskBefore) {
1698
+ if (taskBefore.to_node_id) {
1699
+ updateParams.push(callerNodeId);
1700
+ updateSql += ` AND to_node_id = ?${updateParams.length}`;
1701
+ } else {
1702
+ updateParams.push(taskBefore.to_name);
1703
+ updateSql += ` AND to_node_id IS NULL AND to_name = ?${updateParams.length}`;
1704
+ }
1705
+ }
1037
1706
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1038
1707
  const result = db.run(updateSql, updateParams);
1039
1708
  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;
1709
+ throw new Error(`reply_atomic_cas_failed:${in_reply_to}`);
1042
1710
  }
1043
- return true;
1711
+ syncScheduledRunForTask(in_reply_to, effectiveNetId);
1712
+ return { ok: true as const, replyLogged: true };
1044
1713
  }
1045
- return false;
1714
+ return { ok: true as const, replyLogged: false };
1046
1715
  });
1047
1716
 
1048
- if (in_reply_to && !replyLogged) {
1717
+ if (!replyOutcome.ok) {
1718
+ const taskStatus = "taskStatus" in replyOutcome ? replyOutcome.taskStatus : undefined;
1719
+ const messages: Record<string, string> = {
1720
+ reply_task_not_found: `cannot apply reply: task not found (${in_reply_to})`,
1721
+ reply_task_terminal: `cannot apply reply: task is already terminal (${taskStatus})`,
1722
+ peer_reply_node_token_required: "atomic peer reply requires a node token",
1723
+ peer_reply_unsupported: "recipient or caller does not support atomic peer replies",
1724
+ peer_reply_origin_not_node: "original task sender is not a node; use send_reply",
1725
+ reply_node_identity_unbound: "cannot apply reply: node identity is not token-bound",
1726
+ reply_task_not_owned: "cannot apply reply: task is not owned by this node",
1727
+ reply_target_mismatch: "cannot apply reply: target is not the original task sender",
1728
+ };
1049
1729
  return {
1050
1730
  content: [{
1051
1731
  type: "text" as const,
1052
1732
  text: JSON.stringify({
1053
1733
  ok: false,
1054
- error: "reply_not_applied",
1055
- message: "reply was not applied to task",
1734
+ error: replyOutcome.error,
1735
+ message: messages[replyOutcome.error],
1056
1736
  in_reply_to,
1737
+ ...(taskStatus ? { task_status: taskStatus } : {}),
1057
1738
  reply_queued: false,
1058
1739
  }),
1059
1740
  }],
1060
1741
  };
1061
1742
  }
1743
+ const replyLogged = replyOutcome.replyLogged;
1062
1744
 
1063
1745
  // Log event after commit (outside transaction)
1064
1746
  if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
@@ -1095,16 +1777,71 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1095
1777
  }
1096
1778
  }
1097
1779
 
1098
- const session = scopedSessionStatus(alias, effectiveNetId);
1099
- pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
1780
+ const session = scopedSessionStatus(replyTargetAlias, effectiveNetId);
1781
+ pushEvent(replyTargetAlias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
1782
+ // #461 network observer summary — ids + routing only, no reply text.
1783
+ pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to ?? null, message_id: id, from: from_session, to: replyTargetAlias, status: replyStatus });
1784
+
1785
+ // #498 compatibility tripwire. Legacy send_reply is intentionally kept
1786
+ // for old agents and Dashboard callers during rollout, but it is not the
1787
+ // capability-negotiated atomic peer primitive. Warn only for an actual
1788
+ // agent target; Dashboard hub/api replies remain quiet. New agents call
1789
+ // send_peer_reply and never see this warning.
1790
+ const targetIsAgent = replyTargetNodeId !== null
1791
+ && replyTargetAlias !== "hub"
1792
+ && replyTargetAlias !== "api";
1793
+ const warning = !peerCapabilityRequired && targetIsAgent
1794
+ ? `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.)`
1795
+ : undefined;
1796
+
1797
+ // #507 — echo attachments READ BACK FROM DB (lead 2b5f6634): the point
1798
+ // of the echo is to prove attachments actually landed in storage, not
1799
+ // to reflect the in-memory variable we tried to write. Reading
1800
+ // `attachmentsResult.attachments` here would still show `ok:true` +
1801
+ // full echo if the UPDATE failed with `changes=0` (e.g. task moved to
1802
+ // terminal between the pre-check and the transaction). SELECT from
1803
+ // inbox.meta_json (always written, uses the id we just generated) so
1804
+ // the echo means "these attachments are on disk now". Absent field
1805
+ // when the caller sent no attachments — reverse-(e) invariant: a
1806
+ // no-attachment call returns the exact same response shape as before
1807
+ // this PR. Persisted-attachments field is only added when the caller
1808
+ // actually asked for attachments.
1809
+ let attachmentsSaved: unknown[] | null = null;
1810
+ if (attachmentsResult.attachments.length > 0) {
1811
+ const persistedRow = db.get<{ meta_json: string | null }>(
1812
+ "SELECT meta_json FROM inbox WHERE id = ?1", [id]
1813
+ );
1814
+ const persistedMeta = parseMetaJson(persistedRow?.meta_json ?? null);
1815
+ attachmentsSaved = persistedMeta && typeof persistedMeta === "object" && Array.isArray((persistedMeta as any).attachments)
1816
+ ? (persistedMeta as any).attachments
1817
+ : [];
1818
+ }
1100
1819
 
1101
1820
  return {
1102
1821
  content: [{
1103
1822
  type: "text" as const,
1104
- text: JSON.stringify({ ok: true, message_id: id, session_status: session?.status ?? "unknown" }),
1823
+ text: JSON.stringify({
1824
+ ok: true,
1825
+ message_id: id,
1826
+ session_status: session?.status ?? "unknown",
1827
+ ...(warning ? { warning } : {}),
1828
+ ...(attachmentsSaved !== null ? { attachments_saved: attachmentsSaved } : {}),
1829
+ }),
1105
1830
  }],
1106
1831
  };
1107
- }
1832
+ };
1833
+
1834
+ server.tool(
1835
+ "send_reply",
1836
+ "Reply to a Dashboard/UI-originated task. Also serves as the terminal legacy fallback when atomic peer reply capability is unavailable.",
1837
+ replyToolSchema,
1838
+ (args) => handleReply(args, false),
1839
+ );
1840
+ server.tool(
1841
+ "send_peer_reply",
1842
+ "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.",
1843
+ replyToolSchema,
1844
+ (args) => handleReply(args, true),
1108
1845
  );
1109
1846
 
1110
1847
  // ── V2: send_ack (不入 inbox,仅更新状态) ──
@@ -1114,9 +1851,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1114
1851
  {
1115
1852
  task_id: z.string().min(1).max(200).describe("Task ID to acknowledge"),
1116
1853
  from_session: z.string().max(200).optional(),
1854
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1117
1855
  },
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);
1856
+ async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1857
+ const effectiveNetId = getNetworkId(netId);
1120
1858
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1121
1859
  console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
1122
1860
  const updateParams: any[] = [task_id];
@@ -1140,9 +1878,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1140
1878
  {
1141
1879
  task_id: z.string().min(1).max(200).describe("Task ID to retry"),
1142
1880
  from_session: z.string().max(200).optional(),
1881
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1143
1882
  },
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);
1883
+ async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1884
+ const effectiveNetId = getNetworkId(netId);
1146
1885
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1147
1886
  console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
1148
1887
  // Find the original task
@@ -1156,6 +1895,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1156
1895
  if (!["failed", "expired", "cancelled"].includes(task.status)) {
1157
1896
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task status is ${task.status}, not retryable` }) }] };
1158
1897
  }
1898
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 4/6).
1899
+ {
1900
+ const lc = assertNodeActive(task.to_name, effectiveNetId ?? task.network_id ?? null);
1901
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1902
+ }
1159
1903
  db.transaction(() => {
1160
1904
  // Reset task status
1161
1905
  const updateParams: any[] = [task_id];
@@ -1163,17 +1907,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1163
1907
  WHERE task_id = ?1`;
1164
1908
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1165
1909
  db.run(updateSql, updateParams);
1910
+ syncScheduledRunForTask(task_id, effectiveNetId ?? task.network_id ?? null);
1166
1911
  // Re-queue in inbox with new ID (original ID may already exist)
1167
1912
  const retryInboxId = uuidv4();
1168
1913
  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]
1914
+ `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id)
1915
+ VALUES (?1, ?2, ?3, ?4, 'task', ?5, ?6, ?7, 'reply', ?8)`,
1916
+ [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
1917
  );
1173
1918
  });
1174
1919
  logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
1175
1920
  // SSE push (unconditional — channel is keyed by alias, not network)
1176
1921
  pushEvent(task.to_name, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session }, effectiveNetId ?? task.network_id ?? null);
1922
+ pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: task.to_name, status: "delivered", priority: task.priority });
1177
1923
  return {
1178
1924
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
1179
1925
  };
@@ -1213,19 +1959,64 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1213
1959
  from_name: z.string().max(200).optional().describe("Filter by sender"),
1214
1960
  from_node_id: z.string().max(200).optional().describe("Filter by immutable sender node_id"),
1215
1961
  network_id: z.string().max(200).optional().describe("Filter by network"),
1962
+ before_created_at: z.string().max(64).optional().describe("Pagination cursor timestamp"),
1963
+ before_task_id: z.string().max(200).optional().describe("Pagination cursor task id"),
1964
+ durable_cursor: z.boolean().optional().describe("Require immutable node-scoped cursor protocol"),
1965
+ durable_terminal_cursor: z.boolean().optional().describe("Read terminal outbound journal in monotonic order"),
1966
+ after_terminal_seq: z.number().int().min(0).optional().describe("Exclusive terminal journal watermark"),
1216
1967
  limit: z.number().min(1).max(100).optional().default(20),
1217
1968
  },
1218
- async ({ alias, status, from_name, from_node_id, network_id: netId, limit }) => {
1969
+ 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
1970
  const readScope = resolveReadScope(netId);
1220
1971
  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";
1972
+ if (durable_cursor) {
1973
+ if (!callerTokenIsNetwork) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_required" }) }] };
1974
+ const boundNodeId = callerTokenId && enforceNetworkId
1975
+ ? db.get<{ bound_node_id: string | null }>(
1976
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
1977
+ callerTokenId,
1978
+ enforceNetworkId,
1979
+ )?.bound_node_id ?? null
1980
+ : null;
1981
+ if (!boundNodeId || from_node_id !== boundNodeId) {
1982
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "from_node_id_identity_mismatch" }) }] };
1983
+ }
1984
+ }
1985
+ if (durable_terminal_cursor) {
1986
+ if (!durable_cursor) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "durable_cursor_required" }) }] };
1987
+ const params: any[] = [enforceNetworkId, from_node_id, after_terminal_seq ?? 0, limit];
1988
+ const tasks = db.all(
1989
+ `SELECT t.task_id, t.from_node_id, t.from_name, t.to_node_id, t.to_name, t.priority,
1990
+ t.status, t.content, t.result, t.created_at, t.runtime_submitted_at,
1991
+ t.consumed_at, t.completed_at, e.terminal_seq
1992
+ FROM task_terminal_events e
1993
+ JOIN tasks t ON t.task_id = e.task_id
1994
+ WHERE COALESCE(e.network_id, 'default') = COALESCE(?1, 'default')
1995
+ AND e.from_node_id = ?2 AND e.terminal_seq > ?3
1996
+ ORDER BY e.terminal_seq ASC LIMIT ?4`,
1997
+ ...params,
1998
+ );
1999
+ return { content: [{ type: "text" as const, text: JSON.stringify({
2000
+ ok: true,
2001
+ capability: "list_tasks.immutable-terminal-sequence.v2",
2002
+ tasks,
2003
+ count: tasks.length,
2004
+ next_terminal_seq: tasks.length ? tasks[tasks.length - 1]?.terminal_seq : (after_terminal_seq ?? 0),
2005
+ has_more: tasks.length === limit,
2006
+ }) }] };
2007
+ }
2008
+ 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, completed_at FROM tasks WHERE 1=1";
1222
2009
  const params: any[] = [];
1223
2010
  sql = addReadScope(sql, params, readScope);
1224
2011
  if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
1225
2012
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
1226
2013
  if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
1227
2014
  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}`;
2015
+ if (before_created_at && before_task_id) {
2016
+ sql += ` AND (created_at < ?${params.length + 1} OR (created_at = ?${params.length + 1} AND task_id < ?${params.length + 2}))`;
2017
+ params.push(before_created_at, before_task_id);
2018
+ }
2019
+ sql += ` ORDER BY created_at DESC, task_id DESC LIMIT ?${params.length + 1}`;
1229
2020
  params.push(limit);
1230
2021
  const tasks = db.all(sql, ...params);
1231
2022
 
@@ -1239,7 +2030,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1239
2030
  return {
1240
2031
  content: [{
1241
2032
  type: "text" as const,
1242
- text: JSON.stringify({ ok: true, tasks, count: tasks.length, stats }),
2033
+ text: JSON.stringify({
2034
+ ok: true,
2035
+ ...(durable_cursor ? { capability: "list_tasks.immutable-node-cursor.v1" } : {}),
2036
+ tasks,
2037
+ count: tasks.length,
2038
+ next_cursor: tasks.length === limit ? {
2039
+ before_created_at: tasks[tasks.length - 1]?.created_at,
2040
+ before_task_id: tasks[tasks.length - 1]?.task_id,
2041
+ } : null,
2042
+ stats,
2043
+ }),
1243
2044
  }],
1244
2045
  };
1245
2046
  }
@@ -1253,24 +2054,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1253
2054
  task_id: z.string().min(1).max(200).describe("Task ID to cancel"),
1254
2055
  reason: z.string().max(1000).optional().describe("Cancellation reason"),
1255
2056
  from_session: z.string().max(200).optional(),
2057
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1256
2058
  },
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);
2059
+ async ({ task_id, reason, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2060
+ const effectiveNetId = getNetworkId(netId);
1259
2061
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1260
2062
  console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
1261
2063
  const updateParams: any[] = [reason || "cancelled by " + from_session, task_id];
1262
2064
  let updateSql = `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
1263
2065
  WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`;
1264
2066
  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
- }
2067
+ const result = db.transaction(() => {
2068
+ const updated = db.run(updateSql, updateParams);
2069
+ // Also ack the inbox entry to prevent agent from picking it up.
2070
+ if (updated.changes > 0) {
2071
+ const inboxParams: any[] = [task_id];
2072
+ let inboxSql = "UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0";
2073
+ inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
2074
+ db.run(inboxSql, inboxParams);
2075
+ syncScheduledRunForTask(task_id, effectiveNetId);
2076
+ }
2077
+ return updated;
2078
+ });
2079
+ if (result.changes > 0) logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
1274
2080
  return {
1275
2081
  content: [{ type: "text" as const, text: JSON.stringify({ ok: result.changes > 0, task_id, cancelled: result.changes > 0 }) }],
1276
2082
  };
@@ -1285,9 +2091,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1285
2091
  task_id: z.string().min(1).max(200).describe("Task ID to reassign"),
1286
2092
  new_alias: z.string().min(1).max(200).describe("Target agent alias"),
1287
2093
  from_session: z.string().max(200).optional(),
2094
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
1288
2095
  },
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);
2096
+ async ({ task_id, new_alias, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
2097
+ const effectiveNetId = getNetworkId(netId);
1291
2098
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1292
2099
  console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
1293
2100
  const taskParams: any[] = [task_id];
@@ -1303,10 +2110,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1303
2110
  const reassignedAlias = canonical.alias;
1304
2111
  const target = resolveDeliveryTarget(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
1305
2112
  const newNodeId = target.state === "not_found" ? null : (target.session?.node_id ?? null);
2113
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 5/6).
2114
+ {
2115
+ const lc = assertNodeActive(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
2116
+ if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
2117
+ }
1306
2118
  db.transaction(() => {
1307
2119
  // Ack old inbox to prevent original agent from picking it up
1308
2120
  const inboxParams: any[] = [task_id];
1309
- let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
2121
+ let inboxSql = "UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0";
1310
2122
  inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
1311
2123
  db.run(inboxSql, inboxParams);
1312
2124
 
@@ -1316,11 +2128,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1316
2128
  db.run(updateSql, updateParams);
1317
2129
 
1318
2130
  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]);
2131
+ 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)",
2132
+ [newInboxId, task_id, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
1321
2133
  });
1322
2134
  logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${reassignedAlias}`);
1323
2135
  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);
2136
+ pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: reassignedAlias, status: "delivered", priority: task.priority });
1324
2137
  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
2138
  }
1326
2139
  );
@@ -1348,6 +2161,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1348
2161
  const ids: string[] = [];
1349
2162
 
1350
2163
  for (const t of targets) {
2164
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 6/6).
2165
+ // Broadcast skips non-active recipients silently rather than
2166
+ // failing the entire send — broadcast semantics are best-effort
2167
+ // per-recipient and a stopped node simply gets nothing.
2168
+ const lc = assertNodeActive(t.alias, effectiveNetId ?? t.network_id ?? null);
2169
+ if (!lc.ok) continue;
1351
2170
  const id = uuidv4();
1352
2171
  db.run(
1353
2172
  `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
@@ -1463,6 +2282,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1463
2282
  patch: z.object({
1464
2283
  model: z.string().max(200).optional(),
1465
2284
  flags: z.record(z.unknown()).optional(),
2285
+ // #260 P5 — channel enable/disable. Restart-tier field (agent-node
2286
+ // reads config.channels once at boot to fork per-channel workers,
2287
+ // so the swap takes effect via process restart). Not
2288
+ // SECURITY_SENSITIVE — this is a lifecycle op, gated by SEC-1
2289
+ // (network scope) only.
2290
+ //
2291
+ // Deliberately `z.array(z.unknown()).max(16)` (mirrors flags's
2292
+ // `z.record(z.unknown())`): trust nothing at the wire boundary,
2293
+ // narrow the same way `narrowChannelsPatch` narrows raw untrusted
2294
+ // JSON (typeof + allowlist + dedup + case-fold). A strict
2295
+ // `z.array(z.string())` would fail-fast on a single non-string
2296
+ // entry, but the wire contract wants junk silently dropped so a
2297
+ // dashboard fat-finger doesn't turn into a 400 the user has to
2298
+ // interpret. validatePatch then re-rejects if the caller bypassed
2299
+ // narrowing.
2300
+ channels: z.array(z.unknown()).max(16).optional(),
1466
2301
  }).describe("Fields to update. Empty patch → no-op (use restart_node for that)."),
1467
2302
  network_id: z.string().max(200).optional(),
1468
2303
  },
@@ -1480,6 +2315,50 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1480
2315
 
1481
2316
  const model = typeof patch.model === "string" ? patch.model : undefined;
1482
2317
  const flags = (patch.flags && typeof patch.flags === "object") ? patch.flags as Record<string, unknown> : {};
2318
+ // Narrow untrusted `channels` at the boundary (typeof + allowlist +
2319
+ // dedup + case-fold), and distinguish two very different cases
2320
+ // that both narrow to `[]`:
2321
+ // (a) `patch.channels === []` (explicit "disable all editable
2322
+ // channels"). Downstream must proceed and write channels=[]
2323
+ // so the node's next restart forks no workers.
2324
+ // (b) `patch.channels === ["commhub"]` or similar — the caller
2325
+ // sent items but every single one was invalid (dashboard PR
2326
+ // #31 still ships commhub, and a typo like "telegarm" would
2327
+ // hit the same path). Downstream MUST NOT treat this as
2328
+ // (a) — silently converting to disable-all would nuke the
2329
+ // user's existing telegram/feishu workers.
2330
+ //
2331
+ // The reject error is `channels_all_invalid` so the dashboard
2332
+ // can surface the mismatch instead of showing a false success.
2333
+ let channels: string[] | undefined = undefined;
2334
+ if (patch.channels !== undefined) {
2335
+ if (!Array.isArray(patch.channels)) {
2336
+ // Zod already permits arrays only; belt+braces if it drifts.
2337
+ return {
2338
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_patch", field: "channels", reason: "must be an array" }) }],
2339
+ };
2340
+ }
2341
+ const narrowed = narrowChannelsPatch(patch.channels) ?? [];
2342
+ if (patch.channels.length === 0) {
2343
+ channels = []; // (a) explicit disable-all
2344
+ } else if (narrowed.length === 0) {
2345
+ // (b) every entry was invalid — refuse to write. Distinct
2346
+ // error so the dashboard can surface the failure.
2347
+ return {
2348
+ content: [{
2349
+ type: "text" as const,
2350
+ text: JSON.stringify({
2351
+ ok: false,
2352
+ error: "channels_all_invalid",
2353
+ requested: patch.channels,
2354
+ message: "every requested channel is unknown or unsupported; use an explicit empty array to disable-all",
2355
+ }),
2356
+ }],
2357
+ };
2358
+ } else {
2359
+ channels = narrowed;
2360
+ }
2361
+ }
1483
2362
 
1484
2363
  // SEC-2 (final policy 2026-06-28) — security-sensitive flags
1485
2364
  // (permissionMode / dangerouslySkipPermissions / teammateMode)
@@ -1506,7 +2385,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1506
2385
  };
1507
2386
  }
1508
2387
 
1509
- const validationFail = validatePatch(model, flags);
2388
+ const validationFail = validatePatch(model, flags, channels);
1510
2389
  if (validationFail) {
1511
2390
  return {
1512
2391
  content: [{
@@ -1584,8 +2463,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1584
2463
 
1585
2464
  // Compute apply_mode + persist + push doorbell.
1586
2465
  const updateId = `cu_${uuidv4()}`;
1587
- const applyMode = computeApplyMode(model, flags);
1588
- const patchJson = JSON.stringify({ ...(model !== undefined ? { model } : {}), flags });
2466
+ const applyMode = computeApplyMode(model, flags, channels);
2467
+ const patchJson = JSON.stringify({
2468
+ ...(model !== undefined ? { model } : {}),
2469
+ flags,
2470
+ ...(channels !== undefined ? { channels } : {}),
2471
+ });
1589
2472
  const networkId = node.network_id || "default";
1590
2473
  db.run(
1591
2474
  `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)`,
@@ -1762,10 +2645,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1762
2645
  }
1763
2646
  const updateId = `cu_${uuidv4()}`;
1764
2647
  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
- );
2648
+ // RFC-027 PR1.2a latent fix (#346 ack): restart_node must reset
2649
+ // lifecycle_state to 'active'. Otherwise a node that was previously
2650
+ // stop_node'd → 'stopped' would, after restart, stay marked
2651
+ // 'stopped' in the nodes table → the 6 MCP + 2 REST inbox guards
2652
+ // would refuse every routing attempt → node silently unreachable
2653
+ // (no error to operator, just no traffic). Pair the schema flip
2654
+ // with the config_updates INSERT inside one tx so we don't
2655
+ // half-commit if the dispatch INSERT throws.
2656
+ db.transaction(() => {
2657
+ db.run(
2658
+ `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)`,
2659
+ [updateId, nodeId, networkId, node.config_revision || 0, Date.now(), callerTokenId || "unknown"],
2660
+ );
2661
+ db.run(
2662
+ `UPDATE nodes SET lifecycle_state = 'active' WHERE node_id = ?1`,
2663
+ [nodeId],
2664
+ );
2665
+ });
1769
2666
  pushEvent(node.alias, { type: "restart", update_id: updateId }, networkId);
1770
2667
  return {
1771
2668
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update_id: updateId, apply_mode: "restart_only" }) }],
@@ -1798,16 +2695,150 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1798
2695
  throw e;
1799
2696
  };
1800
2697
 
1801
- // §2.5 step 1+2 — dashboard-facing tool. Validates spec, mint
1802
- // child-ntok, stash env_blob in pendingEnvBlobs Map, write request
1803
- // row (metadata only — no env_blob in SQL), pushEvent doorbell.
2698
+ // RFC-026 §9.2.1 / #338 PR2 — list_host_supervisors.
2699
+ // Surfaces host_supervisor daemons in the caller's network with
2700
+ // online flag + declared capabilities. Replaces the prior `node_daemon_`
2701
+ // prefix heuristic (dashboard /api/anet/node-create) + the role-extract
2702
+ // path on /api/nodes (#337). Member脱敏 strips host_telemetry IP /
2703
+ // cpu / mem (per RFC-026 §6 #3 — daemons can leak internal topology
2704
+ // to non-admin readers). Revoked daemon ntoks filtered out via
2705
+ // join on api_tokens.revoked_at.
1804
2706
  server.tool(
1805
- "create_node",
1806
- "Create a node on a host-daemon and start it. Daemon forks `anet node create + start` on the target machine; child reports back to hub. RFC-026.",
2707
+ "list_host_supervisors",
2708
+ "List host_supervisor daemon nodes in the caller's network (online status + runtimes_supported + allowed_secret_keys + telemetry; member-脱敏). RFC-026 §9.2.1.",
1807
2709
  {
1808
- daemon_node_id: z.string().min(1).max(200),
1809
- node_spec: z.object({
1810
- name: z.string().min(1).max(64),
2710
+ network_id: z.string().max(200).optional(),
2711
+ },
2712
+ async ({ network_id: clientNetId }) => {
2713
+ // SEC-1 — use resolveReadScope, NOT getNetworkId. getNetworkId is
2714
+ // for write-tool helpers and pairs with `canWrite`; READ tools that
2715
+ // bypass canWrite and trust getNetworkId leak across tenants
2716
+ // (PR2 v1 BLOCKER per 通信龙 audit — utok_ caller with
2717
+ // enforceNetworkId=null could pass any network_id and read daemon
2718
+ // names + allowed_secret_keys for tenants they're not a member of).
2719
+ // resolveReadScope checks network_members for the user + denies
2720
+ // on non-membership, mirroring REST resolveRestNetworkScope.
2721
+ const readScope = resolveReadScope(clientNetId);
2722
+ if (readScope.denied) {
2723
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
2724
+ }
2725
+ // Caller role for 脱敏 decision (admin/owner = full, others = masked).
2726
+ // Use the resolved scope's networkId (the verified one), not the
2727
+ // unchecked clientNetId.
2728
+ const scopedNetId = readScope.networkId ?? null;
2729
+ const callerRole = enforceUserId && scopedNetId
2730
+ ? getUserNetworkRole(enforceUserId, scopedNetId)
2731
+ : null;
2732
+ // Network-bound tokens get full telemetry within their bound network
2733
+ // (already gate-locked by enforceNetworkId path of resolveReadScope).
2734
+ const isPrivileged = callerRole === "admin" || callerRole === "owner" || (!enforceUserId);
2735
+
2736
+ // Pull candidate daemons. Active-token EXISTS subquery handles BOTH
2737
+ // revoked daemon ntoks (revoked_at set) AND DELETEd token rows
2738
+ // (the standard revokeToken path deletes the row, not flagging
2739
+ // revoked_at — PR2 v1 SHOULD-FIX per 通信龙 audit). EXISTS naturally
2740
+ // dedupes if a daemon ever had multiple tokens (e.g. rotation; nit ⚪).
2741
+ let sql = `
2742
+ SELECT
2743
+ n.node_id, n.alias, n.hostname, n.network_id,
2744
+ n.runtimes_supported, n.allowed_secret_keys,
2745
+ n.created_at, n.updated_at,
2746
+ s.last_seen_at AS session_last_seen,
2747
+ s.status AS session_status,
2748
+ s.cpu_cores AS session_cpu_cores,
2749
+ s.mem_total_gb AS session_mem_total_gb,
2750
+ s.ip AS session_ip,
2751
+ n.config_snapshot
2752
+ FROM nodes n
2753
+ LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
2754
+ WHERE EXISTS (
2755
+ SELECT 1 FROM api_tokens t
2756
+ WHERE t.network_id = n.network_id
2757
+ AND t.name = 'node:' || n.alias
2758
+ AND t.revoked_at IS NULL
2759
+ )
2760
+ `;
2761
+ const sqlParams: any[] = [];
2762
+ sql = addReadScope(sql, sqlParams, readScope, "n.network_id");
2763
+ sql += ` ORDER BY n.updated_at DESC`;
2764
+ const rows = db.all<Record<string, any>>(sql, ...sqlParams);
2765
+
2766
+ // Filter to role=host_supervisor (read from config_snapshot;
2767
+ // schema-promoted columns runtimes_supported/allowed_secret_keys
2768
+ // are pre-extracted but role isn't a first-class column).
2769
+ const nowMs = Date.now();
2770
+ const ONLINE_MS = 60_000;
2771
+ const daemons = rows
2772
+ .map(r => {
2773
+ let snapRole: string | null = null;
2774
+ if (r.config_snapshot) {
2775
+ try {
2776
+ const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
2777
+ snapRole = typeof parsed?.role === "string" ? parsed.role : null;
2778
+ } catch { /* malformed snapshot — role stays null */ }
2779
+ }
2780
+ return { row: r, role: snapRole };
2781
+ })
2782
+ .filter(({ role }) => role === "host_supervisor")
2783
+ .map(({ row: r }) => {
2784
+ // online = sessions.last_seen_at within ONLINE_MS
2785
+ let online = false;
2786
+ let lastSeenAt: string | null = null;
2787
+ if (r.session_last_seen) {
2788
+ lastSeenAt = r.session_last_seen;
2789
+ const t = Date.parse(r.session_last_seen);
2790
+ if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
2791
+ }
2792
+ // Parse self-declare arrays (default to [] for pre-PR2 daemons)
2793
+ let runtimes: string[] = [];
2794
+ let secrets: string[] = [];
2795
+ try {
2796
+ if (r.runtimes_supported) {
2797
+ const parsed = JSON.parse(r.runtimes_supported);
2798
+ runtimes = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
2799
+ }
2800
+ } catch { /* malformed — empty */ }
2801
+ try {
2802
+ if (r.allowed_secret_keys) {
2803
+ const parsed = JSON.parse(r.allowed_secret_keys);
2804
+ secrets = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
2805
+ }
2806
+ } catch { /* malformed — empty */ }
2807
+ // host_telemetry — member脱敏 drops IP/cpu/mem
2808
+ const telemetry: Record<string, unknown> = {
2809
+ alert_level: online ? "green" : "gray",
2810
+ };
2811
+ if (isPrivileged) {
2812
+ telemetry.cpu_cores = r.session_cpu_cores ?? null;
2813
+ telemetry.mem_gb = r.session_mem_total_gb ?? null;
2814
+ telemetry.ip_internal = r.session_ip ?? null;
2815
+ }
2816
+ return {
2817
+ daemon_node_id: r.node_id,
2818
+ alias: r.alias,
2819
+ hostname: r.hostname,
2820
+ online,
2821
+ last_seen_at: lastSeenAt,
2822
+ runtimes_supported: runtimes,
2823
+ allowed_secret_keys: secrets,
2824
+ host_telemetry: telemetry,
2825
+ };
2826
+ });
2827
+
2828
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, daemons, count: daemons.length }) }] };
2829
+ },
2830
+ );
2831
+
2832
+ // §2.5 step 1+2 — dashboard-facing tool. Validates spec, mint
2833
+ // child-ntok, stash env_blob in pendingEnvBlobs Map, write request
2834
+ // row (metadata only — no env_blob in SQL), pushEvent doorbell.
2835
+ server.tool(
2836
+ "create_node",
2837
+ "Create a node on a host-daemon and start it. Daemon forks `anet node create + start` on the target machine; child reports back to hub. RFC-026.",
2838
+ {
2839
+ daemon_node_id: z.string().min(1).max(200),
2840
+ node_spec: z.object({
2841
+ name: z.string().min(1).max(64),
1811
2842
  runtime: z.string().min(1).max(64),
1812
2843
  model: z.string().min(1).max(100),
1813
2844
  flags: z.record(z.unknown()).optional(),
@@ -1840,21 +2871,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1840
2871
  }
1841
2872
 
1842
2873
  // 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.
2874
+ // last reported config_snapshot. PR3 (#338) canonical path is
2875
+ // `daemon_capabilities.runtimes_supported` (RFC-026 §9.3).
2876
+ // Pre-PR3 daemons (preview.10 and earlier) place these at the
2877
+ // TOP level of the snapshot rather than nested — those reads
2878
+ // return undefined here and `daemonAllowedRuntimes` stays null
2879
+ // (permissive — no allowlist enforcement); they fall back to
2880
+ // §4.2.2 structural validation only, identical to pre-PR3
2881
+ // behavior. No regression on in-flight daemons.
1847
2882
  let daemonAllowList = new Set<string>();
1848
2883
  let daemonAllowedRuntimes: string[] | null = null;
1849
2884
  try {
1850
2885
  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);
2886
+ const caps = snap?.daemon_capabilities;
2887
+ if (Array.isArray(caps?.allowed_secret_keys)) {
2888
+ daemonAllowList = new Set(caps.allowed_secret_keys);
1853
2889
  }
1854
- if (Array.isArray(snap?.daemon_capabilities?.allowed_runtimes)) {
1855
- daemonAllowedRuntimes = snap.daemon_capabilities.allowed_runtimes;
2890
+ if (Array.isArray(caps?.runtimes_supported)) {
2891
+ daemonAllowedRuntimes = caps.runtimes_supported;
1856
2892
  }
1857
- } catch { /* permissive P1 fallback */ }
2893
+ } catch { /* permissive fallback */ }
1858
2894
 
1859
2895
  // §4.2.2 — structural validation (catches name/runtime/model/
1860
2896
  // flag injection at the hub edge). Daemon repeats this; double
@@ -2084,14 +3120,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2084
3120
  // record fork-side info.
2085
3121
  server.tool(
2086
3122
  "ack_create_request",
2087
- "Daemon acks a create-node request (called after fork). status='started' or 'failed'. RFC-026.",
3123
+ "Daemon acks a create-node request (called after fork). status='started' | 'failed' | 'rejected' | 'runtime_capability_check_failed'. RFC-026 §9.3 D2.",
2088
3124
  {
2089
3125
  request_id: z.string().min(1).max(200),
2090
- status: z.enum(["started", "failed", "rejected"]),
3126
+ // RFC-026 §9.3 D2 — runtime_capability_check_failed signals the
3127
+ // daemon spawned the child OK but it died within FAIL_FAST_MS
3128
+ // (5s in agent-node v2.5.0-preview.11+), indicating a
3129
+ // declaration↔reality gap on this daemon's runtimes_supported.
3130
+ // Treated terminal like 'failed' but fires a distinct audit_log
3131
+ // action so dashboards can highlight "lying daemons" separately
3132
+ // from generic spawn failures.
3133
+ status: z.enum(["started", "failed", "rejected", "runtime_capability_check_failed"]),
2091
3134
  error: z.string().max(1000).optional(),
2092
3135
  child_pid: z.number().int().optional(),
3136
+ runtime: z.string().max(64).optional(), // populated by daemon when status=runtime_capability_check_failed
2093
3137
  },
2094
- async ({ request_id, status, error: ackError, child_pid: _pid }) => {
3138
+ async ({ request_id, status, error: ackError, child_pid: _pid, runtime: ackRuntime }) => {
2095
3139
  const callerDaemon = resolveCallerDaemonTokenBound();
2096
3140
  if (!callerDaemon.ok) {
2097
3141
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
@@ -2117,7 +3161,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2117
3161
  );
2118
3162
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "awaiting_register" }) }] };
2119
3163
  }
2120
- // failed / rejected — revoke child-ntok + mark request terminal
3164
+ // failed / rejected / runtime_capability_check_failed — revoke
3165
+ // child-ntok + mark request terminal.
2121
3166
  if (row.child_token_id) {
2122
3167
  db.run(`UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = ?1 AND revoked_at IS NULL`, [row.child_token_id]);
2123
3168
  }
@@ -2125,9 +3170,988 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2125
3170
  `UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3 WHERE request_id = ?4 AND status IN ('pending', 'delivered')`,
2126
3171
  [status, ackError || null, ackedAt, request_id],
2127
3172
  );
3173
+ // RFC-026 §9.3 D2 — surface declaration↔reality gap on a
3174
+ // distinct audit_log action so dashboards / operators can spot
3175
+ // chronically-lying daemons separate from generic spawn-failed.
3176
+ if (status === "runtime_capability_check_failed") {
3177
+ auditCreateNode({
3178
+ action: "daemon_capability_lied",
3179
+ user_id: null,
3180
+ network_id: row.network_id,
3181
+ target_id: request_id,
3182
+ detail: {
3183
+ daemon_node_id: row.daemon_node_id,
3184
+ runtime: ackRuntime || null,
3185
+ error: ackError ? ackError.slice(0, 500) : null,
3186
+ acked_at: ackedAt,
3187
+ },
3188
+ });
3189
+ }
3190
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
3191
+ },
3192
+ );
3193
+
3194
+ // ─── RFC-027 §2 — stop/delete node lifecycle ───────────────────────
3195
+ //
3196
+ // Two user-facing MCP tools (stop_node, delete_node) + two daemon-
3197
+ // facing tools (get_stop_request, ack_stop_request). State machine
3198
+ // per §2.3 — nodes.lifecycle_state ∈ {active, stopping, stopped,
3199
+ // deleting}. row-gone implies deleted. Security per §4:
3200
+ // §4.1 SEC-1: trust-root SQL join, not getNetworkId
3201
+ // §4.2 D6: delete_node refuses target.role==host_supervisor
3202
+ // §4.3 D4: in-flight inbox default-refuse + force+audit
3203
+ // §4.4 D7: daemon writes backup chmod 700, sweeper真删
3204
+ // §4.5 D8: audit_log in same SQLite tx as state UPDATE
3205
+ //
3206
+ // The dispatcher logic shared by stop_node + delete_node lives in
3207
+ // dispatchStopOrDelete; the two tool handlers thin-wrap it with
3208
+ // their respective action discriminator.
3209
+ type DispatchAction = "stop" | "delete";
3210
+ type DispatchArgs = {
3211
+ action: DispatchAction;
3212
+ child_node_id: string;
3213
+ daemon_node_id: string;
3214
+ force: boolean;
3215
+ grace_seconds: number;
3216
+ delete_config: boolean;
3217
+ confirm_alias?: string;
3218
+ };
3219
+ // RFC-027 PR2 prereq — auto-resolve daemon_node_id from child_node_id.
3220
+ // The dashboard (and most callers of stop_node / delete_node) shouldn't
3221
+ // need to track the daemon→child mapping themselves; the hub has it on
3222
+ // node_create_requests at child creation time. Returns null when no
3223
+ // creation record exists (orphan node row OR pre-RFC-026 node) — caller
3224
+ // must then pass daemon_node_id explicitly. node_id derivation:
3225
+ // `node_${request_id.replace(/^cr_/,"")}` (see create-node-daemon.ts
3226
+ // and PR1 BLOCKER-1 fix), so we reverse it: `cr_${node_id.slice(5)}`.
3227
+ const resolveDaemonForChild = (child_node_id: string): string | null => {
3228
+ if (!child_node_id.startsWith("node_")) return null;
3229
+ const requestId = `cr_${child_node_id.slice(5)}`;
3230
+ const row = db.get<{ daemon_node_id: string }>(
3231
+ `SELECT daemon_node_id FROM node_create_requests WHERE request_id = ?1`,
3232
+ requestId,
3233
+ );
3234
+ return row?.daemon_node_id ?? null;
3235
+ };
3236
+
3237
+ const dispatchStopOrDelete = (args: DispatchArgs, clientNetId?: string | null) => {
3238
+ // §4.1 — SEC-1 trust-root join: resolve target node row WITHIN the
3239
+ // caller's scope. resolveReadScope returns 'denied' if the caller
3240
+ // doesn't belong to clientNetId; for the row-existence test we
3241
+ // re-join with member's networks so an attacker can't probe by
3242
+ // node_id from a network they don't belong to.
3243
+ const scope = resolveReadScope(clientNetId);
3244
+ if (scope.denied) {
3245
+ return { ok: false, error: "forbidden_cross_tenant", message: scope.denied };
3246
+ }
3247
+ const nodeQ: any[] = [args.child_node_id];
3248
+ let nodeSql = `SELECT node_id, alias, network_id, lifecycle_state, config_snapshot, runtimes_supported
3249
+ FROM nodes WHERE node_id = ?1`;
3250
+ nodeSql = addReadScope(nodeSql, nodeQ, scope);
3251
+ const node = db.get<{
3252
+ node_id: string;
3253
+ alias: string;
3254
+ network_id: string;
3255
+ lifecycle_state: string | null;
3256
+ config_snapshot: string | null;
3257
+ }>(nodeSql, ...nodeQ);
3258
+ if (!node) return { ok: false, error: "forbidden_cross_tenant", message: "node not found in your networks" };
3259
+
3260
+ // Caller must hold a write-capable role on the resolved network
3261
+ // (admin/owner/member; viewer denied). canWrite walks getUserNetworkRole.
3262
+ if (!canWrite(node.network_id)) {
3263
+ return { ok: false, error: "permission_denied", message: "viewer role cannot stop/delete nodes" };
3264
+ }
3265
+
3266
+ // §4.2 D6 — delete refuses targets whose role is host_supervisor.
3267
+ // Stop is allowed against any node per RFC table (host_supervisor's
3268
+ // stop is functionally a no-op anyway: the daemon's children_map
3269
+ // only tracks its child nodes, never the daemon itself).
3270
+ //
3271
+ // PR1 SF-4 (#345 review) — fail-CLOSED on role read. The first cut
3272
+ // parsed config_snapshot and treated parse-fail / missing-field as
3273
+ // role=null which slipped through the gate. A daemon row with a
3274
+ // corrupt snapshot would then be deletable via delete_node. Fix:
3275
+ // - read role via two independent paths (snapshot.role + the
3276
+ // indexed `runtimes_supported` column whose presence ≈ daemon)
3277
+ // - if EITHER path indicates host_supervisor → refuse
3278
+ // - if snapshot parsed but role missing AND the node has any
3279
+ // daemon-shape evidence (non-null runtimes_supported column,
3280
+ // present in any node_create_requests as daemon_node_id) →
3281
+ // refuse defensively. The defense-in-depth daemon-side
3282
+ // children_map miss is the second layer; the hub gate must
3283
+ // itself fail-closed when role is ambiguous.
3284
+ if (args.action === "delete") {
3285
+ let role: string | null = null;
3286
+ let snapshotParseFailed = false;
3287
+ try {
3288
+ const snap = node.config_snapshot ? JSON.parse(node.config_snapshot) : null;
3289
+ role = typeof snap?.role === "string" ? snap.role : null;
3290
+ } catch { snapshotParseFailed = true; }
3291
+ const ambiguous = snapshotParseFailed || (role == null);
3292
+ // Independent corroborating check: nodes flagged as daemons by
3293
+ // create_node dispatchers will show up as daemon_node_id in
3294
+ // node_create_requests. Costs one indexed query.
3295
+ let looksLikeDaemon = false;
3296
+ if (ambiguous) {
3297
+ const m = db.get<{ n: number }>(
3298
+ `SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1`,
3299
+ node.node_id,
3300
+ );
3301
+ if ((m?.n ?? 0) > 0) looksLikeDaemon = true;
3302
+ }
3303
+ if (role === "host_supervisor" || (ambiguous && looksLikeDaemon)) {
3304
+ return {
3305
+ ok: false, error: "cannot_delete_daemon_via_delete_node",
3306
+ message: snapshotParseFailed
3307
+ ? "node config_snapshot unreadable; node is referenced as a daemon — refuse delete defensively (use delete_daemon path)"
3308
+ : "host_supervisor daemons must be removed via the dedicated delete_daemon path (RFC-027.5)",
3309
+ };
3310
+ }
3311
+ }
3312
+
3313
+ // delete_node confirm_alias gate: dashboard's二次确认 input must
3314
+ // match the actual alias byte-for-byte. Hub rejects mismatched
3315
+ // input even if dashboard UI claims it's disabled.
3316
+ if (args.action === "delete") {
3317
+ if (typeof args.confirm_alias !== "string" || args.confirm_alias !== node.alias) {
3318
+ return { ok: false, error: "confirm_alias_mismatch", message: "confirm_alias must equal the node's alias" };
3319
+ }
3320
+ }
3321
+
3322
+ // Daemon must exist + be in the same network. We do NOT enforce
3323
+ // daemon.role==host_supervisor here: a child whose creator daemon
3324
+ // got demoted should still be stoppable. The daemon will refuse
3325
+ // with noop_not_my_child if children_map doesn't know it.
3326
+ //
3327
+ // PR1.2 e2e catch (BLOCKER): pushEvent keys SSE by clientKey ≈
3328
+ // `${networkId}:${sessionName}`, and agent-node registers its SSE
3329
+ // connection under its ALIAS (not its node_id). The create_node
3330
+ // doorbell at line ~2197 correctly pushes via `daemon.alias`;
3331
+ // stop_node was pushing via `daemon_node_id` so every doorbell
3332
+ // missed every SSE client → daemon never woke up → request_id
3333
+ // stayed 'pending' forever → child never reaped. The 24 unit tests
3334
+ // in stop-delete-node.test.ts all mock pushEvent and inject the
3335
+ // post-doorbell handler call directly, so single-unit coverage
3336
+ // couldn't surface this — exactly the failure class 通信龙 said
3337
+ // the docker e2e gate exists to catch (BLOCKER-1 同源). Load the
3338
+ // alias along with node_id + network_id and dispatch to the alias.
3339
+ const daemon = db.get<{ node_id: string; alias: string; network_id: string }>(
3340
+ `SELECT node_id, alias, network_id FROM nodes WHERE node_id = ?1`, args.daemon_node_id,
3341
+ );
3342
+ if (!daemon) return { ok: false, error: "daemon_not_found" };
3343
+ if (daemon.network_id !== node.network_id) {
3344
+ // Cross-tenant child↔daemon mismatch shouldn't be possible in
3345
+ // normal flow but is a SEC-1 hardening — refuse loud.
3346
+ return { ok: false, error: "daemon_cross_tenant", message: "daemon and child are in different networks" };
3347
+ }
3348
+
3349
+ // State machine gate.
3350
+ const state = node.lifecycle_state ?? "active";
3351
+ if (args.action === "stop" && state !== "active") {
3352
+ return { ok: false, error: state === "stopping" ? "node_already_stopping" : "node_not_active", current_state: state };
3353
+ }
3354
+ if (args.action === "delete" && state === "deleting") {
3355
+ return { ok: false, error: "node_already_deleting", current_state: state };
3356
+ }
3357
+ if (args.action === "delete" && state === "stopping") {
3358
+ // Can't start delete while a stop is mid-flight — race-prone.
3359
+ return { ok: false, error: "node_stopping_in_progress", current_state: state };
3360
+ }
3361
+
3362
+ // §4.3 D4 — in-flight inbox check. Count unacked tasks routed to
3363
+ // this node's alias (inbox routing uses session_name == alias).
3364
+ // Default refuse with the count surfaced; force=true overrides
3365
+ // and triggers the forced_stop_with_in_flight audit row.
3366
+ // SF-5 (#345 review): legacy inbox rows may have network_id=NULL
3367
+ // and would silently miss a network_id=?2 strict equality match.
3368
+ // Use COALESCE so a NULL inbox row is counted against the same
3369
+ // network as the target node (which is the only safe default
3370
+ // — pre-multi-network rows existed in single-network mode).
3371
+ const inFlightRow = db.get<{ n: number }>(
3372
+ `SELECT COUNT(*) AS n FROM inbox WHERE session_name = ?1 AND acked = 0
3373
+ AND COALESCE(network_id, ?2) = ?2`,
3374
+ node.alias, node.network_id,
3375
+ );
3376
+ const inFlight = inFlightRow?.n ?? 0;
3377
+ if (inFlight > 0 && !args.force) {
3378
+ return { ok: false, error: "node_busy_in_flight", in_flight_count: inFlight, hint: "set force=true to override (audit logged)" };
3379
+ }
3380
+
3381
+ // ── all gates passed; create request + transition state + push doorbell, transactionally ──
3382
+ const requestId = generateId("sr");
3383
+ const now = Date.now();
3384
+ const newState = args.action === "stop" ? "stopping" : "deleting";
3385
+
3386
+ // §4.5 D8 — lifecycle UPDATE + audit INSERTs + request INSERT atomic.
3387
+ // PR1.1: use db.transaction() (SQLiteAdapter wraps better-sqlite3's
3388
+ // native transaction; PgAdapter ships a real BEGIN/COMMIT/ROLLBACK
3389
+ // shim). Lets us drop the open-coded BEGIN/COMMIT + makes the
3390
+ // SF-2 failure-injection test (mock db.run throw inside callback)
3391
+ // reliable across both backends.
3392
+ try {
3393
+ db.transaction(() => {
3394
+ db.run(
3395
+ `INSERT INTO node_stop_requests
3396
+ (request_id, network_id, daemon_node_id, child_node_id, child_alias, action,
3397
+ delete_config, grace_seconds, force, in_flight_at_dispatch, created_by_token,
3398
+ status, created_at)
3399
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'pending', ?12)`,
3400
+ [
3401
+ requestId, node.network_id, args.daemon_node_id, node.node_id, node.alias, args.action,
3402
+ args.delete_config ? 1 : 0, args.grace_seconds, args.force ? 1 : 0, inFlight,
3403
+ callerTokenId || "unknown", now,
3404
+ ],
3405
+ );
3406
+ db.run(
3407
+ `UPDATE nodes SET lifecycle_state = ?1 WHERE node_id = ?2`,
3408
+ [newState, node.node_id],
3409
+ );
3410
+ // §4.5 D8 — audit dispatch action. Uses the STRICT variant so a
3411
+ // failed audit INSERT propagates and triggers ROLLBACK (PR1 SF-2
3412
+ // review catch — auditCreateNode's swallow-and-warn would have
3413
+ // committed the lifecycle UPDATE without an audit row, leaving
3414
+ // exactly the "deleted but no audit" window §4.5 closes).
3415
+ auditCreateNodeStrict({
3416
+ action: args.action === "stop" ? "stop_node_dispatched" : "delete_node_dispatched",
3417
+ user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
3418
+ detail: {
3419
+ child_node_id: node.node_id, child_alias: node.alias,
3420
+ daemon_node_id: args.daemon_node_id, action: args.action,
3421
+ force: args.force, delete_config: args.delete_config,
3422
+ grace_seconds: args.grace_seconds, in_flight_at_dispatch: inFlight,
3423
+ lifecycle_state_before: state, lifecycle_state_after: newState,
3424
+ ts_request: now,
3425
+ },
3426
+ });
3427
+ if (args.force && inFlight > 0) {
3428
+ auditCreateNodeStrict({
3429
+ action: "forced_stop_with_in_flight",
3430
+ user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
3431
+ detail: { in_flight_count: inFlight, child_alias: node.alias },
3432
+ });
3433
+ }
3434
+ }); // end db.transaction
3435
+ } catch (e: any) {
3436
+ return { ok: false, error: "dispatch_tx_failed", message: e?.message || String(e) };
3437
+ }
3438
+
3439
+ // SSE doorbell — daemon will pull via get_stop_request.
3440
+ // PR1.2 e2e fix: route by daemon.alias (see SELECT above for the
3441
+ // BLOCKER explanation). Mirrors create_node's pushEvent target at
3442
+ // line ~2197.
3443
+ pushEvent(daemon.alias, { type: "stop_node", request_id: requestId }, node.network_id);
3444
+ return {
3445
+ ok: true, request_id: requestId, action: args.action,
3446
+ lifecycle_state: newState, in_flight_at_dispatch: inFlight,
3447
+ };
3448
+ };
3449
+
3450
+ server.tool(
3451
+ "stop_node",
3452
+ "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).",
3453
+ {
3454
+ child_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/),
3455
+ // PR2 prereq: dashboard rarely knows daemon_node_id directly.
3456
+ // When omitted, hub resolves from the original creation record
3457
+ // (node_create_requests.daemon_node_id keyed by this child).
3458
+ daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
3459
+ force: z.boolean().optional().default(false),
3460
+ grace_seconds: z.number().int().min(5).max(60).optional().default(10),
3461
+ network_id: z.string().max(200).optional(),
3462
+ },
3463
+ async ({ child_node_id, daemon_node_id, force, grace_seconds, network_id: clientNetId }) => {
3464
+ const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
3465
+ if (!resolved) {
3466
+ return { content: [{ type: "text" as const, text: JSON.stringify({
3467
+ ok: false, error: "daemon_not_resolvable",
3468
+ message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
3469
+ }) }] };
3470
+ }
3471
+ const r = dispatchStopOrDelete(
3472
+ { action: "stop", child_node_id, daemon_node_id: resolved, force: force ?? false,
3473
+ grace_seconds: grace_seconds ?? 10, delete_config: false },
3474
+ clientNetId,
3475
+ );
3476
+ return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
3477
+ },
3478
+ );
3479
+
3480
+ server.tool(
3481
+ "delete_node",
3482
+ "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.",
3483
+ {
3484
+ child_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/),
3485
+ daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
3486
+ confirm_alias: z.string().min(1).max(200),
3487
+ force: z.boolean().optional().default(false),
3488
+ grace_seconds: z.number().int().min(5).max(60).optional().default(10),
3489
+ delete_config: z.boolean().optional().default(true),
3490
+ network_id: z.string().max(200).optional(),
3491
+ },
3492
+ async ({ child_node_id, daemon_node_id, confirm_alias, force, grace_seconds, delete_config, network_id: clientNetId }) => {
3493
+ const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
3494
+ if (!resolved) {
3495
+ return { content: [{ type: "text" as const, text: JSON.stringify({
3496
+ ok: false, error: "daemon_not_resolvable",
3497
+ message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
3498
+ }) }] };
3499
+ }
3500
+ const r = dispatchStopOrDelete(
3501
+ { action: "delete", child_node_id, daemon_node_id: resolved, force: force ?? false,
3502
+ grace_seconds: grace_seconds ?? 10, delete_config: delete_config ?? true, confirm_alias },
3503
+ clientNetId,
3504
+ );
3505
+ return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
3506
+ },
3507
+ );
3508
+
3509
+ server.tool(
3510
+ "get_stop_request",
3511
+ "Daemon pulls a pending stop/delete request (called when SSE stop_node doorbell arrives). RFC-027 §2.4.",
3512
+ {
3513
+ request_id: z.string().min(1).max(200),
3514
+ },
3515
+ async ({ request_id }) => {
3516
+ const callerDaemon = resolveCallerDaemonTokenBound();
3517
+ if (!callerDaemon.ok) {
3518
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
3519
+ }
3520
+ const row = db.get<{
3521
+ daemon_node_id: string; status: string; network_id: string;
3522
+ child_node_id: string; child_alias: string; action: string;
3523
+ delete_config: number; grace_seconds: number; force: number;
3524
+ }>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, action,
3525
+ delete_config, grace_seconds, force
3526
+ FROM node_stop_requests WHERE request_id = ?1`, request_id);
3527
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
3528
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
3529
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
3530
+ }
3531
+ if (row.network_id !== callerDaemon.networkId) {
3532
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
3533
+ }
3534
+ // Stamp delivered_at on first pull (idempotent — only if still pending).
3535
+ db.run(
3536
+ `UPDATE node_stop_requests SET status = 'delivered', delivered_at = ?1
3537
+ WHERE request_id = ?2 AND status = 'pending'`,
3538
+ [Date.now(), request_id],
3539
+ );
3540
+ return { content: [{ type: "text" as const, text: JSON.stringify({
3541
+ ok: true,
3542
+ request_id,
3543
+ child_node_id: row.child_node_id,
3544
+ child_alias: row.child_alias,
3545
+ action: row.action,
3546
+ delete_config: row.delete_config === 1,
3547
+ grace_seconds: row.grace_seconds,
3548
+ force: row.force === 1,
3549
+ }) }] };
3550
+ },
3551
+ );
3552
+
3553
+ server.tool(
3554
+ "ack_stop_request",
3555
+ "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.",
3556
+ {
3557
+ request_id: z.string().min(1).max(200),
3558
+ status: z.enum(["stopped", "stop_failed", "noop_not_my_child"]),
3559
+ exit_signal: z.string().max(16).optional(),
3560
+ backup_path: z.string().max(500).optional(),
3561
+ error: z.string().max(1000).optional(),
3562
+ },
3563
+ async ({ request_id, status, exit_signal, backup_path, error: ackError }) => {
3564
+ const callerDaemon = resolveCallerDaemonTokenBound();
3565
+ if (!callerDaemon.ok) {
3566
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
3567
+ }
3568
+ const row = db.get<{
3569
+ daemon_node_id: string; status: string; network_id: string;
3570
+ child_node_id: string; child_alias: string; action: string;
3571
+ in_flight_at_dispatch: number; force: number;
3572
+ }>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, action,
3573
+ in_flight_at_dispatch, force
3574
+ FROM node_stop_requests WHERE request_id = ?1`, request_id);
3575
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
3576
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
3577
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
3578
+ }
3579
+ if (row.network_id !== callerDaemon.networkId) {
3580
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
3581
+ }
3582
+ const now = Date.now();
3583
+
3584
+ // §4.5 D8 — lifecycle UPDATE + request UPDATE + audit INSERT
3585
+ // (+ DELETE for delete action) atomic via db.transaction().
3586
+ // PR1.1: replaces hand-rolled BEGIN..COMMIT so PG adapter and
3587
+ // SQLite both get correct rollback semantics from one code path.
3588
+ try {
3589
+ db.transaction(() => {
3590
+ db.run(
3591
+ `UPDATE node_stop_requests SET status = ?1, error = ?2, exit_signal = ?3,
3592
+ backup_path = ?4, acked_at = ?5
3593
+ WHERE request_id = ?6`,
3594
+ [status, ackError || null, exit_signal || null, backup_path || null, now, request_id],
3595
+ );
3596
+ if (status === "stopped" && row.action === "stop") {
3597
+ db.run(`UPDATE nodes SET lifecycle_state = 'stopped' WHERE node_id = ?1`, [row.child_node_id]);
3598
+ auditCreateNodeStrict({
3599
+ action: "stop_node_completed",
3600
+ user_id: null, network_id: row.network_id, target_id: request_id,
3601
+ detail: {
3602
+ child_node_id: row.child_node_id, child_alias: row.child_alias,
3603
+ exit_signal: exit_signal || null, ts_daemon_ack: now,
3604
+ lifecycle_state_after: "stopped",
3605
+ },
3606
+ });
3607
+ } else if (status === "stopped" && row.action === "delete") {
3608
+ // Revoke the child's ntok (name='node:<alias>') in the daemon's
3609
+ // network, then DELETE the nodes row. Token revoke pattern mirrors
3610
+ // ack_create_request's terminal path.
3611
+ db.run(
3612
+ `UPDATE api_tokens SET revoked_at = datetime('now')
3613
+ WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
3614
+ [row.network_id, `node:${row.child_alias}`],
3615
+ );
3616
+ db.run(`DELETE FROM nodes WHERE node_id = ?1`, [row.child_node_id]);
3617
+ auditCreateNodeStrict({
3618
+ action: "delete_node_completed",
3619
+ user_id: null, network_id: row.network_id, target_id: request_id,
3620
+ detail: {
3621
+ child_node_id: row.child_node_id, child_alias: row.child_alias,
3622
+ exit_signal: exit_signal || null, backup_path: backup_path || null,
3623
+ ts_daemon_ack: now, lifecycle_state_after: "deleted",
3624
+ },
3625
+ });
3626
+ } else if (status === "stop_failed") {
3627
+ db.run(`UPDATE nodes SET lifecycle_state = 'stop_failed' WHERE node_id = ?1`, [row.child_node_id]);
3628
+ // No completion audit; the failure error is on the request row.
3629
+ }
3630
+ // noop_not_my_child: leave lifecycle_state as-is; the hub-side
3631
+ // sweeper / next reconciliation will pick it up. Don't transition.
3632
+ });
3633
+ } catch (e: any) {
3634
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "finalize_tx_failed", message: e?.message || String(e) }) }] };
3635
+ }
3636
+
2128
3637
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
2129
3638
  },
2130
3639
  );
3640
+
3641
+ // RFC-027 PR1.1 — list_my_children: daemon-only query. Returns the
3642
+ // {child_node_id, alias, lifecycle_state} tuples for nodes whose
3643
+ // active create-request has this daemon as daemon_node_id. Used at
3644
+ // daemon boot to rebuild the in-memory childrenMap (PR1 dropped
3645
+ // every entry on daemon restart → stop/delete silently no-op'd).
3646
+ //
3647
+ // Network-scope is the daemon's own (resolveCallerDaemonTokenBound
3648
+ // already binds tokenIsNetwork to a single network). No SEC-1 leak:
3649
+ // returns ONLY children whose request landed under this daemon's
3650
+ // token + network.
3651
+ server.tool(
3652
+ "list_my_children",
3653
+ "Daemon pulls the alias + node_id list of children it spawned (for childrenMap rebuild after restart). RFC-027 PR1.1.",
3654
+ {},
3655
+ async () => {
3656
+ const callerDaemon = resolveCallerDaemonTokenBound();
3657
+ if (!callerDaemon.ok) {
3658
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
3659
+ }
3660
+ // node_create_requests carries the canonical authoritative
3661
+ // child→daemon mapping (the row id IS the request, and the
3662
+ // child node_id derives from it deterministically via
3663
+ // `node_${request_id.replace(/^cr_/, "")}`). Filter to children
3664
+ // that completed registration (have a nodes row) so we don't
3665
+ // ask the daemon to pgrep for children that never came up.
3666
+ const rows = db.all<{ request_id: string; child_name: string; child_node_id: string; lifecycle_state: string | null }>(
3667
+ `SELECT ncr.request_id, ncr.child_name,
3668
+ ('node_' || substr(ncr.request_id, 4)) AS child_node_id,
3669
+ n.lifecycle_state
3670
+ FROM node_create_requests ncr
3671
+ LEFT JOIN nodes n ON n.node_id = ('node_' || substr(ncr.request_id, 4))
3672
+ WHERE ncr.daemon_node_id = ?1
3673
+ AND ncr.network_id = ?2
3674
+ AND ncr.status IN ('succeeded', 'delivered')
3675
+ AND n.node_id IS NOT NULL
3676
+ AND COALESCE(n.lifecycle_state, 'active') NOT IN ('stopped', 'stop_failed')`,
3677
+ callerDaemon.daemonNodeId, callerDaemon.networkId,
3678
+ );
3679
+ const children = rows.map(r => ({
3680
+ child_node_id: r.child_node_id,
3681
+ alias: r.child_name,
3682
+ lifecycle_state: r.lifecycle_state ?? "active",
3683
+ }));
3684
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: children.length, children }) }] };
3685
+ },
3686
+ );
3687
+
3688
+ // ── RFC-028 P1 — Provider & Model Registry + connectivity probe ──
3689
+ // Background timers (probe GC + sweeper) idempotent; safe to call
3690
+ // per registerTools.
3691
+ startPendingProbeGcTimer();
3692
+ startProbeSweeperTimer();
3693
+
3694
+ // Helper: zod to JSON-RPC reply for ProbeValidationError + VaultError
3695
+ const probeFailReply = (e: unknown) => {
3696
+ if (e instanceof ProbeValidationError) {
3697
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: e.code, ...(e.detail || {}) }) }] };
3698
+ }
3699
+ if (e instanceof VaultError) {
3700
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: e.code, message: e.message }) }] };
3701
+ }
3702
+ throw e;
3703
+ };
3704
+
3705
+ // §2.3.4 — upsert_network_secret (OWNER-ONLY; vault write).
3706
+ server.tool(
3707
+ "upsert_network_secret",
3708
+ "Write or replace a secret value in the network's vault (AES-GCM encrypted at rest). OWNER-only. RFC-028.",
3709
+ {
3710
+ key: z.string().min(1).max(64).regex(/^[A-Z][A-Z0-9_]{0,63}$/),
3711
+ value: z.string().min(1).max(16 * 1024),
3712
+ network_id: z.string().max(200).optional(),
3713
+ },
3714
+ async ({ key, value, network_id: clientNetId }) => {
3715
+ const effectiveNetId = getNetworkId(clientNetId);
3716
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
3717
+ const callerRole = enforceUserId && effectiveNetId
3718
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
3719
+ : null;
3720
+ if (callerRole !== "owner") {
3721
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "secret_owner_only", required_role: "owner", caller_role: callerRole }) }] };
3722
+ }
3723
+ try {
3724
+ vaultUpsert(effectiveNetId || "default", key, value);
3725
+ auditCreateNode({
3726
+ action: "create_node_dispatched", // reusing audit_log shape (provider follow-up logs)
3727
+ user_id: enforceUserId, network_id: effectiveNetId, target_id: null,
3728
+ detail: { op: "vault_upsert", key, network_id: effectiveNetId },
3729
+ });
3730
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, key }) }] };
3731
+ } catch (e) {
3732
+ return probeFailReply(e);
3733
+ }
3734
+ },
3735
+ );
3736
+
3737
+ // §2.3.4b — list_network_secrets (key names only; viewer+).
3738
+ server.tool(
3739
+ "list_network_secrets",
3740
+ "List vault key NAMES (NEVER values) for a network. RFC-028.",
3741
+ { network_id: z.string().max(200).optional() },
3742
+ async ({ network_id: clientNetId }) => {
3743
+ const effectiveNetId = getNetworkId(clientNetId);
3744
+ if (!canWrite(effectiveNetId) && enforceUserId) {
3745
+ // viewer-level: allow if caller has any role in network
3746
+ const role = getUserNetworkRole(enforceUserId, effectiveNetId || "default");
3747
+ if (!role) return writeDeniedReply(effectiveNetId, "read");
3748
+ }
3749
+ const keys = vaultListKeys(effectiveNetId || "default");
3750
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, keys }) }] };
3751
+ },
3752
+ );
3753
+
3754
+ // §2.3.1 — upsert_provider (admin+).
3755
+ server.tool(
3756
+ "upsert_provider",
3757
+ "Create or update a provider (vendor + base_url + secret_key_ref + initial models). Admin+. RFC-028.",
3758
+ {
3759
+ name: z.string().min(1).max(100),
3760
+ vendor: z.string().min(1).max(64),
3761
+ base_url: z.string().min(1).max(500),
3762
+ secret_key_ref: z.string().min(1).max(64),
3763
+ models: z.array(z.object({
3764
+ model_name: z.string().min(1).max(100),
3765
+ display_name: z.string().max(100).optional(),
3766
+ context_window: z.number().int().min(0).optional(),
3767
+ supports_vision: z.boolean().optional(),
3768
+ })).optional(),
3769
+ network_id: z.string().max(200).optional(),
3770
+ },
3771
+ async ({ name, vendor, base_url, secret_key_ref, models, network_id: clientNetId }) => {
3772
+ const effectiveNetId = getNetworkId(clientNetId);
3773
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
3774
+ const callerRole = enforceUserId && effectiveNetId
3775
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
3776
+ : null;
3777
+ if (callerRole !== "admin" && callerRole !== "owner") {
3778
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_provider", required_role: "admin", caller_role: callerRole }) }] };
3779
+ }
3780
+ try {
3781
+ _validateBaseUrl(vendor, base_url);
3782
+ } catch (e) {
3783
+ return probeFailReply(e);
3784
+ }
3785
+ // Verify vault key exists (best-effort; just lists names)
3786
+ const vaultKeys = vaultListKeys(effectiveNetId || "default");
3787
+ if (!vaultKeys.includes(secret_key_ref)) {
3788
+ 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" }) }] };
3789
+ }
3790
+ const providerId = `prov_${uuidv4()}`;
3791
+ try {
3792
+ db.run(
3793
+ `INSERT INTO providers (provider_id, network_id, name, vendor, base_url, secret_key_ref, created_at, created_by, enabled)
3794
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)`,
3795
+ [providerId, effectiveNetId || "default", name, vendor, base_url, secret_key_ref, Date.now(), enforceUserId || "unknown"],
3796
+ );
3797
+ } catch (e: any) {
3798
+ if (/UNIQUE constraint failed/.test(e?.message || "")) {
3799
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_name_conflict", name }) }] };
3800
+ }
3801
+ throw e;
3802
+ }
3803
+ const modelIds: string[] = [];
3804
+ if (models) {
3805
+ for (const m of models) {
3806
+ const mid = `pm_${uuidv4()}`;
3807
+ db.run(
3808
+ `INSERT INTO provider_models (model_id, provider_id, model_name, display_name, context_window, supports_vision, enabled, created_at)
3809
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)`,
3810
+ [mid, providerId, m.model_name, m.display_name ?? null, m.context_window ?? null, m.supports_vision ? 1 : 0, Date.now()],
3811
+ );
3812
+ modelIds.push(mid);
3813
+ }
3814
+ }
3815
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id: providerId, model_ids: modelIds }) }] };
3816
+ },
3817
+ );
3818
+
3819
+ // §2.3.2 — update_provider (admin+; patch semantics). RFC-028 P1.5.
3820
+ // Single tool for editing existing providers — patch model: name/base_url/
3821
+ // models/enabled all optional, at least one required. Vendor and
3822
+ // secret_key_ref are IMMUTABLE via this tool (vendor change = create+delete;
3823
+ // secret change goes through upsert_network_secret first). network_id is
3824
+ // immutable (cross-tenant move forbidden).
3825
+ //
3826
+ // base_url change re-runs validateBaseUrl with vendor read from DB row
3827
+ // (NOT from patch) — defends against trying to widen host allowlist by
3828
+ // smuggling a new vendor name. zod .strict() on the wrapping object
3829
+ // surfaces extras as -32602 at MCP boundary (R3 lock, same as
3830
+ // ack_probe_request per #308 fold-in).
3831
+ //
3832
+ // Audit: every successful update writes audit_log with before/after diff
3833
+ // of the changed fields ONLY (no secret values — secret isn't a patch
3834
+ // field, no leak path).
3835
+ server.registerTool(
3836
+ "update_provider",
3837
+ {
3838
+ 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.",
3839
+ inputSchema: z.object({
3840
+ provider_id: z.string().regex(/^prov_[a-zA-Z0-9_-]+$/).max(200),
3841
+ network_id: z.string().max(200).optional(),
3842
+ patch: z.object({
3843
+ name: z.string().min(1).max(100).optional(),
3844
+ base_url: z.string().min(1).max(500).optional(),
3845
+ models: z.array(z.object({
3846
+ model_name: z.string().min(1).max(100),
3847
+ display_name: z.string().max(100).optional(),
3848
+ context_window: z.number().int().min(0).optional(),
3849
+ supports_vision: z.boolean().optional(),
3850
+ })).optional(),
3851
+ enabled: z.boolean().optional(),
3852
+ }).strict(),
3853
+ }).strict() as any,
3854
+ },
3855
+ async (args: any) => {
3856
+ const { provider_id, network_id: clientNetId, patch } = args;
3857
+ const effectiveNetId = getNetworkId(clientNetId);
3858
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
3859
+ const callerRole = enforceUserId && effectiveNetId
3860
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
3861
+ : null;
3862
+ if (callerRole !== "admin" && callerRole !== "owner") {
3863
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_provider", required_role: "admin", caller_role: callerRole }) }] };
3864
+ }
3865
+
3866
+ // Empty patch — surface noop_no_changes (don't silently succeed)
3867
+ const patchKeys = Object.keys(patch || {});
3868
+ if (patchKeys.length === 0) {
3869
+ 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" }) }] };
3870
+ }
3871
+
3872
+ // SEC-1: row must exist within caller's network (SQL-level enforcement)
3873
+ const row = db.get<{ provider_id: string; vendor: string; name: string; base_url: string; enabled: number }>(
3874
+ `SELECT provider_id, vendor, name, base_url, enabled FROM providers WHERE provider_id = ?1 AND network_id = ?2`,
3875
+ provider_id, effectiveNetId || "default",
3876
+ );
3877
+ if (!row) {
3878
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_not_found", provider_id }) }] };
3879
+ }
3880
+
3881
+ // base_url change → re-run validateBaseUrl with vendor from DB (not patch)
3882
+ if (patch.base_url !== undefined && patch.base_url !== row.base_url) {
3883
+ try {
3884
+ _validateBaseUrl(row.vendor, patch.base_url);
3885
+ } catch (e) {
3886
+ return probeFailReply(e);
3887
+ }
3888
+ }
3889
+
3890
+ // Diff staging — collect before/after for audit (NO secret values; secret
3891
+ // isn't a patch field). Skip fields that didn't actually change.
3892
+ const diff: Record<string, { before: unknown; after: unknown }> = {};
3893
+ const sets: string[] = [];
3894
+ const params: unknown[] = [];
3895
+ let paramIdx = 1;
3896
+ if (patch.name !== undefined && patch.name !== row.name) {
3897
+ sets.push(`name = ?${paramIdx++}`); params.push(patch.name);
3898
+ diff.name = { before: row.name, after: patch.name };
3899
+ }
3900
+ if (patch.base_url !== undefined && patch.base_url !== row.base_url) {
3901
+ sets.push(`base_url = ?${paramIdx++}`); params.push(patch.base_url);
3902
+ diff.base_url = { before: row.base_url, after: patch.base_url };
3903
+ }
3904
+ if (patch.enabled !== undefined) {
3905
+ const newEnabled = patch.enabled ? 1 : 0;
3906
+ if (newEnabled !== row.enabled) {
3907
+ sets.push(`enabled = ?${paramIdx++}`); params.push(newEnabled);
3908
+ diff.enabled = { before: row.enabled === 1, after: patch.enabled };
3909
+ }
3910
+ }
3911
+
3912
+ // Replace models list (if supplied) atomically with the providers UPDATE
3913
+ const willReplaceModels = patch.models !== undefined;
3914
+ const newModelIds: string[] = [];
3915
+
3916
+ // No-op early return (通信牛 nit): all patch fields matched the
3917
+ // existing row values + no models supplied → no real change to
3918
+ // persist. Returning here means we don't write an empty-diff
3919
+ // audit row (audit noise). Audit_log INSERT must only fire when
3920
+ // something actually changed.
3921
+ if (sets.length === 0 && !willReplaceModels) {
3922
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id, no_changes: true, hint: "patch fields matched existing row, no-op" }) }] };
3923
+ }
3924
+
3925
+ try {
3926
+ db.exec("BEGIN");
3927
+ if (sets.length > 0) {
3928
+ params.push(provider_id);
3929
+ try {
3930
+ db.run(`UPDATE providers SET ${sets.join(", ")} WHERE provider_id = ?${paramIdx}`, params);
3931
+ } catch (e: any) {
3932
+ if (/UNIQUE constraint failed/.test(e?.message || "")) {
3933
+ db.exec("ROLLBACK");
3934
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_name_conflict", name: patch.name }) }] };
3935
+ }
3936
+ throw e;
3937
+ }
3938
+ }
3939
+ if (willReplaceModels) {
3940
+ db.run(`DELETE FROM provider_models WHERE provider_id = ?1`, [provider_id]);
3941
+ for (const m of patch.models) {
3942
+ const mid = `pm_${uuidv4()}`;
3943
+ db.run(
3944
+ `INSERT INTO provider_models (model_id, provider_id, model_name, display_name, context_window, supports_vision, enabled, created_at)
3945
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)`,
3946
+ [mid, provider_id, m.model_name, m.display_name ?? null, m.context_window ?? null, m.supports_vision ? 1 : 0, Date.now()],
3947
+ );
3948
+ newModelIds.push(mid);
3949
+ }
3950
+ diff.models = { before: "(prior list)", after: `${patch.models.length} models replaced` };
3951
+ }
3952
+ db.run(
3953
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
3954
+ VALUES (?1, ?2, 'update_provider', 'provider', ?3, ?4, ?5)`,
3955
+ [enforceUserId || null, callerAlias || null, provider_id, JSON.stringify({ diff, fields_changed: Object.keys(diff) }), effectiveNetId || null],
3956
+ );
3957
+ db.exec("COMMIT");
3958
+ } catch (e: any) {
3959
+ try { db.exec("ROLLBACK"); } catch { /* ok */ }
3960
+ throw e;
3961
+ }
3962
+
3963
+ return { content: [{ type: "text" as const, text: JSON.stringify({
3964
+ ok: true, provider_id,
3965
+ fields_changed: Object.keys(diff),
3966
+ models_replaced: willReplaceModels ? newModelIds.length : null,
3967
+ }) }] };
3968
+ },
3969
+ );
3970
+
3971
+ // §2.3.3 — list_providers (viewer+; never returns secret VALUES).
3972
+ server.tool(
3973
+ "list_providers",
3974
+ "List providers + models in the caller's network. Never returns secret VALUES. RFC-028.",
3975
+ { network_id: z.string().max(200).optional() },
3976
+ async ({ network_id: clientNetId }) => {
3977
+ const effectiveNetId = getNetworkId(clientNetId);
3978
+ // viewer access: caller must be member of network (read access)
3979
+ if (enforceUserId) {
3980
+ const role = getUserNetworkRole(enforceUserId, effectiveNetId || "default");
3981
+ if (!role) return writeDeniedReply(effectiveNetId, "read");
3982
+ }
3983
+ const providers = db.all<any>(
3984
+ `SELECT provider_id, name, vendor, base_url, secret_key_ref, enabled FROM providers WHERE network_id = ?1 AND enabled = 1 ORDER BY name`,
3985
+ effectiveNetId || "default",
3986
+ );
3987
+ const out = providers.map(p => {
3988
+ const models = db.all<any>(
3989
+ `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`,
3990
+ p.provider_id,
3991
+ );
3992
+ return { ...p, in_vault: true, models }; // in_vault: true since we required it on upsert
3993
+ });
3994
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, providers: out }) }] };
3995
+ },
3996
+ );
3997
+
3998
+ // §2.3.5 — probe_provider_model (admin+; dispatches to daemon).
3999
+ server.tool(
4000
+ "probe_provider_model",
4001
+ "Dispatch a connectivity probe to a daemon. Mints ephemeral secret blob; daemon pulls via get_probe_request. Admin+. RFC-028.",
4002
+ {
4003
+ provider_id: z.string().min(1).max(200),
4004
+ model_name: z.string().min(1).max(100),
4005
+ daemon_node_id: z.string().min(1).max(200),
4006
+ network_id: z.string().max(200).optional(),
4007
+ },
4008
+ async ({ provider_id, model_name, daemon_node_id, network_id: clientNetId }) => {
4009
+ const effectiveNetId = getNetworkId(clientNetId);
4010
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
4011
+ const callerRole = enforceUserId && effectiveNetId
4012
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
4013
+ : null;
4014
+ if (callerRole !== "admin" && callerRole !== "owner") {
4015
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_probe", required_role: "admin", caller_role: callerRole }) }] };
4016
+ }
4017
+ // Resolve provider + model (network-scoped). Distinguish "not found"
4018
+ // from "disabled" so the dashboard can render the right error
4019
+ // (P1.5 dashboard编辑/停用 needs this to explain why probe rejected).
4020
+ const provider = db.get<any>(
4021
+ `SELECT provider_id, vendor, base_url, secret_key_ref, enabled FROM providers WHERE provider_id = ?1 AND network_id = ?2`,
4022
+ provider_id, effectiveNetId || "default",
4023
+ );
4024
+ if (!provider) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_not_found", provider_id }) }] };
4025
+ 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" }) }] };
4026
+ const model = db.get<any>(
4027
+ `SELECT model_id, model_name FROM provider_models WHERE provider_id = ?1 AND model_name = ?2 AND enabled = 1`,
4028
+ provider_id, model_name,
4029
+ );
4030
+ if (!model) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "model_not_found", model_name }) }] };
4031
+ // Resolve daemon (must be in caller network)
4032
+ const { row: daemon, sec1Ok } = resolveTargetNode(daemon_node_id, effectiveNetId);
4033
+ if (!daemon) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found" }) }] };
4034
+ if (!sec1Ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
4035
+ // Vault decrypt the API key (may throw VaultError)
4036
+ let apiKey: string;
4037
+ try {
4038
+ const v = vaultGet(effectiveNetId || "default", provider.secret_key_ref);
4039
+ if (!v) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "secret_not_in_vault", key: provider.secret_key_ref }) }] };
4040
+ apiKey = v;
4041
+ } catch (e) { return probeFailReply(e); }
4042
+ // Mint probe row + stash ephemeral blob
4043
+ const probeId = newProbeId();
4044
+ db.run(
4045
+ `INSERT INTO probe_results (probe_id, provider_id, model_name, daemon_node_id, network_id, status, probed_at, probed_by_user)
4046
+ VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)`,
4047
+ [probeId, provider_id, model_name, daemon_node_id, effectiveNetId || "default", Date.now(), enforceUserId || null],
4048
+ );
4049
+ putPendingProbeSecret({
4050
+ probe_id: probeId,
4051
+ daemon_node_id,
4052
+ provider_id,
4053
+ vendor: provider.vendor,
4054
+ base_url: provider.base_url,
4055
+ model_name,
4056
+ api_key: apiKey,
4057
+ network_id: effectiveNetId || "default",
4058
+ });
4059
+ pushEvent(daemon.alias, { type: "probe_provider", probe_id: probeId }, daemon.network_id || effectiveNetId || "default");
4060
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, probe_id: probeId }) }] };
4061
+ },
4062
+ );
4063
+
4064
+ // §2.3.6 — get_probe_results (viewer+; matrix renderer source).
4065
+ server.tool(
4066
+ "get_probe_results",
4067
+ "Query probe history (optionally filtered by provider/model/daemon). Used by dashboard reachability matrix. RFC-028.",
4068
+ {
4069
+ provider_id: z.string().max(200).optional(),
4070
+ model_name: z.string().max(100).optional(),
4071
+ daemon_node_id: z.string().max(200).optional(),
4072
+ limit: z.number().int().min(1).max(500).optional(),
4073
+ network_id: z.string().max(200).optional(),
4074
+ },
4075
+ async ({ provider_id, model_name, daemon_node_id, limit, network_id: clientNetId }) => {
4076
+ const effectiveNetId = getNetworkId(clientNetId);
4077
+ if (enforceUserId) {
4078
+ const role = getUserNetworkRole(enforceUserId, effectiveNetId || "default");
4079
+ if (!role) return writeDeniedReply(effectiveNetId, "read");
4080
+ }
4081
+ const where: string[] = ["network_id = ?1"];
4082
+ const params: any[] = [effectiveNetId || "default"];
4083
+ if (provider_id) { where.push(`provider_id = ?${params.length + 1}`); params.push(provider_id); }
4084
+ if (model_name) { where.push(`model_name = ?${params.length + 1}`); params.push(model_name); }
4085
+ if (daemon_node_id) { where.push(`daemon_node_id = ?${params.length + 1}`); params.push(daemon_node_id); }
4086
+ 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}`;
4087
+ const rows = db.all<any>(sql, ...params);
4088
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, results: rows }) }] };
4089
+ },
4090
+ );
4091
+
4092
+ // §2.3.7 daemon-facing — get_probe_request.
4093
+ server.tool(
4094
+ "get_probe_request",
4095
+ "Daemon pulls a pending probe request (called when SSE probe_provider doorbell arrives). RFC-028.",
4096
+ { probe_id: z.string().min(1).max(200) },
4097
+ async ({ probe_id }) => {
4098
+ const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
4099
+ if (!callerDaemon.ok) {
4100
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4101
+ }
4102
+ // takePendingProbeSecret enforces daemon binding + evicts
4103
+ const blob = (await import("./probe.js")).takePendingProbeSecret(probe_id, callerDaemon.daemonNodeId);
4104
+ if (!blob) {
4105
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "probe_request_unavailable", reason: "not_found_or_wrong_daemon_or_expired" }) }] };
4106
+ }
4107
+ return { content: [{ type: "text" as const, text: JSON.stringify({
4108
+ ok: true,
4109
+ probe_id: blob.probe_id,
4110
+ vendor: blob.vendor,
4111
+ base_url: blob.base_url,
4112
+ model_name: blob.model_name,
4113
+ api_key: blob.api_key, // ephemeral; daemon writes to .env.local or in-memory only
4114
+ }) }] };
4115
+ },
4116
+ );
4117
+
4118
+ // §2.3.8 daemon-facing — ack_probe_request (STRICT whitelist via zod).
4119
+ // R3 LOCK (RFC-028 v3): ack payload has EXACTLY 4 keys. We pass a
4120
+ // ZodObject with .strict() so the MCP SDK's validateToolInput surfaces
4121
+ // any extra field (e.g. an attacker smuggling `error_message` to leak a
4122
+ // secret) as a -32602 Invalid params error at the protocol boundary
4123
+ // BEFORE our handler runs. The SDK invokes z.object(shape) by default
4124
+ // which is strip-mode; passing a fully-constructed strict object
4125
+ // bypasses that and enforces unknownKeys='strict'.
4126
+ server.registerTool(
4127
+ "ack_probe_request",
4128
+ {
4129
+ description: "Daemon acks a probe. Schema is STRICT whitelist (no error_message; v3 R3 LOCK). RFC-028.",
4130
+ // Strict-mode ZodObject so the MCP SDK's validateToolInput surfaces
4131
+ // any extra field as a -32602 Invalid params error BEFORE the
4132
+ // handler runs. server.tool() accepts only ZodRawShape (default
4133
+ // strip-mode); registerTool() accepts a constructed ZodObject and
4134
+ // honors its unknownKeys policy.
4135
+ inputSchema: z.object({
4136
+ probe_id: z.string().min(1).max(200),
4137
+ 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"]),
4138
+ raw_status_code: z.number().int().min(100).max(599).optional(),
4139
+ latency_ms: z.number().int().min(0).max(60_000),
4140
+ }).strict() as any,
4141
+ },
4142
+ async (args: any) => {
4143
+ const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
4144
+ if (!callerDaemon.ok) {
4145
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
4146
+ }
4147
+ try {
4148
+ const r = finalizeProbeAck(args, { network_id: callerDaemon.networkId, daemon_node_id: callerDaemon.daemonNodeId });
4149
+ return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
4150
+ } catch (e) {
4151
+ return probeFailReply(e);
4152
+ }
4153
+ },
4154
+ );
2131
4155
  }
2132
4156
 
2133
4157
  // ────────────────────────────────────────────────────────────────────
@@ -2145,6 +4169,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2145
4169
  export interface UpsertNodeWithSec1GuardInput {
2146
4170
  node_id: string;
2147
4171
  callerNetworkId: string | null;
4172
+ callerUserId?: string | null;
4173
+ callerTokenId?: string | null;
2148
4174
  node_name?: string | null;
2149
4175
  alias?: string | null;
2150
4176
  runtime?: string | null;
@@ -2157,19 +4183,60 @@ export interface UpsertNodeWithSec1GuardInput {
2157
4183
  }
2158
4184
  export type UpsertNodeOutcome =
2159
4185
  | { result: "inserted" | "updated"; node_id: string }
2160
- | { result: "refused"; reason: "cross_network"; existingNet: string | null; callerNet: string | null }
4186
+ | { result: "refused"; reason: "cross_network" | "token_node_mismatch" | "owner_mismatch"; existingNet: string | null; callerNet: string | null }
2161
4187
  | { result: "skipped"; reason: "missing_node_id" };
2162
4188
 
2163
4189
  const _norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x);
2164
4190
 
4191
+ /**
4192
+ * A capability that changes peer delivery semantics is trusted only when the
4193
+ * authenticating ntok is immutably bound to the exact reported node. Legacy
4194
+ * alias-only tokens may still heartbeat, but cannot opt a node into #698.
4195
+ */
4196
+ export function trustedConfigSnapshotForNode(
4197
+ snapshot: unknown | null | undefined,
4198
+ callerTokenId: string | null | undefined,
4199
+ nodeId: string,
4200
+ ): unknown | null | undefined {
4201
+ if (!snapshot || typeof snapshot !== "object") return snapshot;
4202
+ const copy = { ...(snapshot as Record<string, unknown>) };
4203
+ const token = callerTokenId
4204
+ ? db.get<{ bound_node_id: string | null }>(
4205
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1",
4206
+ callerTokenId,
4207
+ )
4208
+ : null;
4209
+ if (token?.bound_node_id !== nodeId) delete copy.peer_reply_inbox_capable;
4210
+ return copy;
4211
+ }
4212
+
2165
4213
  export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): UpsertNodeOutcome {
2166
4214
  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",
4215
+ const existing = db.get<{ network_id: string | null; owner_user_id: string | null }>(
4216
+ "SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1",
2169
4217
  input.node_id,
2170
4218
  );
2171
4219
  const callerNet = input.callerNetworkId;
2172
4220
 
4221
+ // RFC-036 — when a token was minted with a node_id binding, a heartbeat may
4222
+ // report only that exact node. Legacy unbound ntok rows remain compatible,
4223
+ // but they can never consume owner-gated schedule intents.
4224
+ if (input.callerTokenId) {
4225
+ const token = db.get<{ bound_node_id: string | null; user_id: string; network_id: string | null }>(
4226
+ "SELECT bound_node_id, user_id, network_id FROM api_tokens WHERE token_id = ?1",
4227
+ input.callerTokenId,
4228
+ );
4229
+ if (token?.bound_node_id && token.bound_node_id !== input.node_id) {
4230
+ return { result: "refused", reason: "token_node_mismatch", existingNet: existing?.network_id ?? null, callerNet };
4231
+ }
4232
+ if (token?.network_id && _norm(token.network_id) !== _norm(callerNet)) {
4233
+ return { result: "refused", reason: "cross_network", existingNet: existing?.network_id ?? null, callerNet };
4234
+ }
4235
+ }
4236
+ if (existing?.owner_user_id && input.callerUserId !== existing.owner_user_id) {
4237
+ return { result: "refused", reason: "owner_mismatch", existingNet: existing.network_id, callerNet };
4238
+ }
4239
+
2173
4240
  // Legacy / first-write paths: row missing OR network_id NULL → claim.
2174
4241
  const isLegacy = !existing
2175
4242
  || existing.network_id === null
@@ -2215,10 +4282,38 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
2215
4282
  callerNet ?? null,
2216
4283
  ],
2217
4284
  );
2218
- if (input.config_snapshot) {
4285
+ const trustedSnapshot = trustedConfigSnapshotForNode(
4286
+ input.config_snapshot,
4287
+ input.callerTokenId,
4288
+ input.node_id,
4289
+ );
4290
+ if (trustedSnapshot) {
4291
+ // RFC-026 §9.3 / #338 PR2+PR3 — promote daemon self-declare fields
4292
+ // to first-class indexable columns alongside the snapshot blob.
4293
+ // The snapshot stays the source of truth for non-list reads; the
4294
+ // columns exist so `list_host_supervisors` doesn't JSON.parse on
4295
+ // every call. typeof-narrow per
4296
+ // per team rule (typeof-narrow extracted JSON fields at the boundary) — zod narrowed but
4297
+ // input.config_snapshot is typed `unknown` here.
4298
+ //
4299
+ // PR3 nit ①: read from nested `daemon_capabilities.*` (canonical
4300
+ // per RFC §9.3 + matches existing hub create_node reads at
4301
+ // tools.ts:2010/2075). PR2 ate from top-level keys, which the
4302
+ // hub create_node path never read → max_concurrent_children
4303
+ // backpressure was dead config + allowlist enforcement bypassed.
4304
+ const snap = trustedSnapshot as Record<string, unknown> | null;
4305
+ const caps = (snap?.daemon_capabilities ?? null) as Record<string, unknown> | null;
4306
+ const runtimesRaw = caps?.runtimes_supported;
4307
+ const allowedRaw = caps?.allowed_secret_keys;
4308
+ const runtimesJson = Array.isArray(runtimesRaw) && runtimesRaw.every(s => typeof s === "string")
4309
+ ? JSON.stringify(runtimesRaw)
4310
+ : null;
4311
+ const allowedJson = Array.isArray(allowedRaw) && allowedRaw.every(s => typeof s === "string")
4312
+ ? JSON.stringify(allowedRaw)
4313
+ : null;
2219
4314
  db.run(
2220
- `UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
2221
- [JSON.stringify(input.config_snapshot), input.node_id],
4315
+ `UPDATE nodes SET config_snapshot = ?1, runtimes_supported = ?2, allowed_secret_keys = ?3 WHERE node_id = ?4`,
4316
+ [JSON.stringify(trustedSnapshot), runtimesJson, allowedJson, input.node_id],
2222
4317
  );
2223
4318
  // RFC-024 — finalize any pending/restarting update whose target
2224
4319
  // patch is now reflected in the snapshot. Closes the
@@ -2228,7 +4323,7 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
2228
4323
  // update_id to call ack_config_update(applied) itself. Hub does
2229
4324
  // it on the new child's behalf by content-matching the patch
2230
4325
  // against the reported snapshot.
2231
- finalizePendingMatchingUpdates(input.node_id, input.config_snapshot);
4326
+ finalizePendingMatchingUpdates(input.node_id, trustedSnapshot);
2232
4327
  }
2233
4328
  // RFC-026 §2.5 step 4 — on every register/report_status, opportunistically
2234
4329
  // close out any pending create_node request whose child_name matches
@@ -2288,6 +4383,15 @@ export function finalizePendingMatchingUpdates(
2288
4383
 
2289
4384
  const snapModel: string | null | undefined = snapshot?.model;
2290
4385
  const snapFlags: Record<string, unknown> = (snapshot?.flags && typeof snapshot.flags === "object") ? snapshot.flags : {};
4386
+ // #260 P5 — snapshot.channels is the (sorted, bare-type) channel set
4387
+ // the node currently has forked. agent-node's buildConfigSnapshot
4388
+ // always emits it (even as []), so the "absent field" case here means
4389
+ // an older pre-#260-P5 agent-node — for those, don't finalize a
4390
+ // channels-carrying patch (would false-positive on the OTHER fields).
4391
+ const snapChannelsPresent = Array.isArray(snapshot?.channels);
4392
+ const snapChannels: string[] = snapChannelsPresent
4393
+ ? (snapshot.channels as unknown[]).filter((c): c is string => typeof c === "string")
4394
+ : [];
2291
4395
 
2292
4396
  const finalizedIds: string[] = [];
2293
4397
 
@@ -2312,6 +4416,35 @@ export function finalizePendingMatchingUpdates(
2312
4416
  if (snapFlags[k] !== v) { matches = false; break; }
2313
4417
  }
2314
4418
  }
4419
+ // #260 P5 — channels field content-match. Without this the
4420
+ // node's very first startup report_status matches (patch.flags
4421
+ // is `{}` for a channels-only update, so the flags loop is
4422
+ // trivially satisfied) and hub prematurely marks the update
4423
+ // applied, deleting the pending row + bumping config_revision
4424
+ // BEFORE the doorbell reaches the child. Codex catch on PR #411.
4425
+ //
4426
+ // Set-equality is enough because the patch side comes from
4427
+ // narrowChannelsPatch → already deduped + case-folded + in
4428
+ // EDITABLE_CHANNELS iteration order; the snapshot side comes
4429
+ // from buildConfigSnapshot which mirrors the same shape.
4430
+ if (matches && Array.isArray(patch.channels)) {
4431
+ if (!snapChannelsPresent) {
4432
+ // Pre-#260-P5 agent-node — no channels field in snapshot,
4433
+ // so we can't prove the apply landed. Skip finalize; hub's
4434
+ // F-B reaper still supersedes this eventually.
4435
+ matches = false;
4436
+ } else {
4437
+ const wanted = new Set<string>(patch.channels);
4438
+ const have = new Set<string>(snapChannels);
4439
+ if (wanted.size !== have.size) {
4440
+ matches = false;
4441
+ } else {
4442
+ for (const w of wanted) {
4443
+ if (!have.has(w)) { matches = false; break; }
4444
+ }
4445
+ }
4446
+ }
4447
+ }
2315
4448
  }
2316
4449
 
2317
4450
  if (!matches) continue;