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

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