@sleep2agi/commhub-server 0.9.0-preview.28 → 0.9.0-preview.29

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 (56) hide show
  1. package/bin/commhub.ts +4 -0
  2. package/package.json +4 -4
  3. package/src/ack-create-request-transport.test.ts +159 -0
  4. package/src/ack-create-request.test.ts +5 -5
  5. package/src/admin-networks-http.test.ts +84 -0
  6. package/src/api-host-supervisors-fallback.test.ts +1 -6
  7. package/src/auth.ts +88 -14
  8. package/src/dashboard-slash-routing-http.test.ts +103 -0
  9. package/src/db-adapter-guard.test.ts +85 -0
  10. package/src/db-adapter.ts +64 -23
  11. package/src/db.ts +279 -32
  12. package/src/ext-token-shared.test.ts +60 -0
  13. package/src/ext-validation-regression.test.ts +10 -17
  14. package/src/external-schedule-edits-http.test.ts +316 -0
  15. package/src/external-schedule-edits.ts +314 -0
  16. package/src/file-download-authz-dev-open.test.ts +1 -1
  17. package/src/file-download-authz.test.ts +21 -1
  18. package/src/file-download-date-bucket.test.ts +1 -1
  19. package/src/file-network-scope-dev-open.test.ts +1 -1
  20. package/src/file-network-scope.test.ts +1 -1
  21. package/src/health-redaction.test.ts +12 -0
  22. package/src/index.ts +6 -2
  23. package/src/network-name-http.test.ts +71 -0
  24. package/src/network-scope.ts +20 -0
  25. package/src/node-attrs.test.ts +1 -1
  26. package/src/peer-reply-atomic.test.ts +455 -0
  27. package/src/peer-reply-capability.test.ts +70 -0
  28. package/src/require-explicit-test-db.ts +9 -0
  29. package/src/rest-explicit-columns-http.test.ts +149 -0
  30. package/src/rest-identity.test.ts +120 -0
  31. package/src/rest-identity.ts +93 -0
  32. package/src/rest-projections.ts +67 -0
  33. package/src/scheduled-run-terminal.test.ts +268 -0
  34. package/src/scheduled-tasks-http.test.ts +460 -0
  35. package/src/scheduled-tasks.ts +538 -0
  36. package/src/send-reply-agent-warning.test.ts +38 -38
  37. package/src/server.ts +315 -60
  38. package/src/shared/external-schedule-contract.ts +60 -0
  39. package/src/skillhub-http.test.ts +88 -0
  40. package/src/task-auth-origin-http.test.ts +87 -0
  41. package/src/task-auth-origin.test.ts +24 -0
  42. package/src/task-auth-origin.ts +7 -0
  43. package/src/task-consumption.test.ts +329 -0
  44. package/src/task-diagnostic-http.test.ts +145 -0
  45. package/src/task-diagnostic.test.ts +82 -0
  46. package/src/task-diagnostic.ts +89 -0
  47. package/src/task-idempotency-mcp.test.ts +137 -0
  48. package/src/task-idempotency.test.ts +60 -0
  49. package/src/task-idempotency.ts +75 -0
  50. package/src/task-lifecycle-watcher.test.ts +160 -0
  51. package/src/task-lifecycle-watcher.ts +49 -0
  52. package/src/task-network-resolution-http.test.ts +137 -0
  53. package/src/tasks-pagination-http.test.ts +137 -0
  54. package/src/tools.ts +635 -107
  55. package/src/uploads-http.test.ts +1 -3
  56. package/src/uploads.ts +14 -3
package/src/tools.ts CHANGED
@@ -1,7 +1,8 @@
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, pushNetworkObserverEvent } from "./push.js";
3
+ import { createHash } from "node:crypto";
4
+ import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
5
+ import { getSSEStats, pushEvent, pushNetworkObserverEvent } from "./push.js";
5
6
  import { assertNodeActive } from "./lifecycle-guard.js";
6
7
  import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
7
8
  import { canRestWriteNetwork, getUserNetworkIds, singleNetworkId } from "./network-scope.js";
@@ -55,6 +56,8 @@ import {
55
56
  isAllowedToChangeFlag,
56
57
  } from "./config-apply-validate.js";
57
58
  import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
59
+ import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
60
+ import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
58
61
 
59
62
  function ts(): string {
60
63
  return new Date().toTimeString().slice(0, 8);
@@ -148,6 +151,163 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
148
151
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error, message }) }] };
149
152
  };
150
153
 
154
+ const skillHubReply = (value: Record<string, unknown>) => ({
155
+ content: [{ type: "text" as const, text: JSON.stringify(value) }],
156
+ });
157
+
158
+ // SkillHub is a network registry, not a public filesystem. Content is an
159
+ // immutable SKILL.md snapshot; identity comes only from the authenticated
160
+ // MCP principal (never from request fields).
161
+ server.tool(
162
+ "submit_skill",
163
+ "Submit an immutable SKILL.md version to the caller's network SkillHub. Node identity is token-bound. New submissions require review.",
164
+ {
165
+ slug: z.string().min(2).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
166
+ name: z.string().min(1).max(120),
167
+ description: z.string().max(1000).optional(),
168
+ version: z.string().min(1).max(40).regex(/^[0-9A-Za-z]+(?:[._-][0-9A-Za-z]+)*$/),
169
+ content: z.string().min(1).max(128 * 1024).refine(value => !value.includes("\0"), "content must not contain NUL bytes"),
170
+ network_id: z.string().max(200).optional(),
171
+ },
172
+ async ({ slug, name, description, version, content, network_id: clientNetId }) => {
173
+ const effectiveNetId = getNetworkId(clientNetId);
174
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
175
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
176
+ const sourceType = callerTokenIsNetwork ? "node" : "user";
177
+ if (sourceType === "node" && !callerAlias) return skillHubReply({ ok: false, error: "node_identity_unbound" });
178
+ const contentHash = createHash("sha256").update(content, "utf8").digest("hex");
179
+ const existing = db.get<any>(
180
+ `SELECT skill_id, content_hash, status FROM skillhub_skills
181
+ WHERE network_id = ?1 AND slug = ?2 AND version = ?3`,
182
+ effectiveNetId, slug, version,
183
+ );
184
+ if (existing) {
185
+ if (existing.content_hash === contentHash) {
186
+ return skillHubReply({ ok: true, idempotent: true, skill_id: existing.skill_id, status: existing.status });
187
+ }
188
+ return skillHubReply({ ok: false, error: "skill_version_conflict", hint: "publish changed content under a new version" });
189
+ }
190
+ const skillId = `skill_${uuidv4()}`;
191
+ try {
192
+ db.run(
193
+ `INSERT INTO skillhub_skills
194
+ (skill_id, network_id, slug, name, description, version, content, content_hash, status, source_type, source_alias, created_by_user)
195
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
196
+ [skillId, effectiveNetId, slug, name.trim(), description?.trim() || "", version, content, contentHash, sourceType, callerAlias, enforceUserId || null],
197
+ );
198
+ } catch (error: any) {
199
+ // Two submitters may pass the preflight SELECT together. Resolve the
200
+ // UNIQUE race into the same deterministic idempotent/conflict contract
201
+ // instead of leaking a storage exception as HTTP/MCP 500.
202
+ if (!/unique|duplicate key/i.test(error?.message || "")) throw error;
203
+ const winner = db.get<any>(
204
+ `SELECT skill_id, content_hash, status FROM skillhub_skills
205
+ WHERE network_id = ?1 AND slug = ?2 AND version = ?3`,
206
+ effectiveNetId, slug, version,
207
+ );
208
+ if (!winner) throw error;
209
+ if (winner.content_hash === contentHash) {
210
+ return skillHubReply({ ok: true, idempotent: true, skill_id: winner.skill_id, status: winner.status });
211
+ }
212
+ return skillHubReply({ ok: false, error: "skill_version_conflict", hint: "publish changed content under a new version" });
213
+ }
214
+ db.run(
215
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
216
+ VALUES (?1, ?2, 'skill_submit', 'skill', ?3, ?4, ?5)`,
217
+ [enforceUserId || null, callerAlias || null, skillId, JSON.stringify({ slug, version, source_type: sourceType }), effectiveNetId],
218
+ );
219
+ return skillHubReply({ ok: true, skill_id: skillId, status: "pending", source_type: sourceType, source_alias: callerAlias });
220
+ },
221
+ );
222
+
223
+ server.tool(
224
+ "list_skills",
225
+ "List published skills in a network. Owners/admins may include pending review items.",
226
+ {
227
+ network_id: z.string().max(200).optional(),
228
+ include_pending: z.boolean().optional(),
229
+ query: z.string().max(120).optional(),
230
+ limit: z.number().int().min(1).max(200).optional(),
231
+ },
232
+ async ({ network_id: clientNetId, include_pending, query, limit }) => {
233
+ const effectiveNetId = getNetworkId(clientNetId);
234
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "read");
235
+ const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
236
+ if (enforceUserId && !role) return writeDeniedReply(effectiveNetId, "read");
237
+ // An ntok_ belongs to a node even when it was minted by the network
238
+ // owner. Never inherit the owner's review power through that token.
239
+ const reviewer = !callerTokenIsNetwork && (role === "owner" || role === "admin");
240
+ const showPending = !!include_pending && reviewer;
241
+ const params: unknown[] = [effectiveNetId];
242
+ let sql = `SELECT skill_id, slug, name, description, version, status, source_type, source_alias,
243
+ created_at, updated_at, reviewed_at, review_note
244
+ FROM skillhub_skills WHERE network_id = ?1`;
245
+ if (!showPending) sql += ` AND status = 'published'`;
246
+ if (query?.trim()) {
247
+ params.push(`%${query.trim()}%`);
248
+ sql += ` AND (slug LIKE ?${params.length} OR name LIKE ?${params.length} OR description LIKE ?${params.length})`;
249
+ }
250
+ sql += ` ORDER BY updated_at DESC LIMIT ${limit ?? 100}`;
251
+ return skillHubReply({ ok: true, reviewer, skills: db.all(sql, ...params) });
252
+ },
253
+ );
254
+
255
+ server.tool(
256
+ "get_skill",
257
+ "Read one SkillHub SKILL.md. Pending content is visible only to owners/admins.",
258
+ { skill_id: z.string().regex(/^skill_[A-Za-z0-9_-]+$/).max(200), network_id: z.string().max(200).optional() },
259
+ async ({ skill_id, network_id: clientNetId }) => {
260
+ const effectiveNetId = getNetworkId(clientNetId);
261
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "read");
262
+ const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
263
+ if (enforceUserId && !role) return writeDeniedReply(effectiveNetId, "read");
264
+ const row = db.get<any>(
265
+ `SELECT skill_id, slug, name, description, version, content, status,
266
+ source_type, source_alias, created_at, updated_at, reviewed_at, review_note
267
+ FROM skillhub_skills WHERE skill_id = ?1 AND network_id = ?2`,
268
+ skill_id, effectiveNetId,
269
+ );
270
+ if (!row) return skillHubReply({ ok: false, error: "skill_not_found" });
271
+ const reviewer = !callerTokenIsNetwork && (role === "owner" || role === "admin");
272
+ if (row.status !== "published" && !reviewer) {
273
+ return skillHubReply({ ok: false, error: "skill_not_found" });
274
+ }
275
+ return skillHubReply({ ok: true, skill: row });
276
+ },
277
+ );
278
+
279
+ server.tool(
280
+ "review_skill",
281
+ "Publish or reject a pending SkillHub submission. Network owner/admin only.",
282
+ {
283
+ skill_id: z.string().regex(/^skill_[A-Za-z0-9_-]+$/).max(200),
284
+ decision: z.enum(["published", "rejected"]),
285
+ note: z.string().max(1000).optional(),
286
+ network_id: z.string().max(200).optional(),
287
+ },
288
+ async ({ skill_id, decision, note, network_id: clientNetId }) => {
289
+ const effectiveNetId = getNetworkId(clientNetId);
290
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
291
+ const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
292
+ if (callerTokenIsNetwork || (role !== "owner" && role !== "admin")) return skillHubReply({ ok: false, error: "skill_review_admin_required" });
293
+ const row = db.get<any>(`SELECT status FROM skillhub_skills WHERE skill_id = ?1 AND network_id = ?2`, skill_id, effectiveNetId);
294
+ if (!row) return skillHubReply({ ok: false, error: "skill_not_found" });
295
+ if (row.status !== "pending") return skillHubReply({ ok: false, error: "skill_not_pending", status: row.status });
296
+ const updated = db.run(
297
+ `UPDATE skillhub_skills SET status = ?1, review_note = ?2, reviewed_by_user = ?3,
298
+ reviewed_at = datetime('now'), updated_at = datetime('now') WHERE skill_id = ?4 AND network_id = ?5 AND status = 'pending'`,
299
+ [decision, note?.trim() || null, enforceUserId || null, skill_id, effectiveNetId],
300
+ );
301
+ if (updated.changes !== 1) return skillHubReply({ ok: false, error: "skill_not_pending" });
302
+ db.run(
303
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
304
+ VALUES (?1, ?2, 'skill_review', 'skill', ?3, ?4, ?5)`,
305
+ [enforceUserId || null, callerAlias || null, skill_id, JSON.stringify({ decision }), effectiveNetId],
306
+ );
307
+ return skillHubReply({ ok: true, skill_id, status: decision });
308
+ },
309
+ );
310
+
151
311
  const addScope = (sql: string, params: any[], networkId?: string | null, column = "network_id"): string => {
152
312
  if (!networkId) return sql;
153
313
  sql += ` AND ${column} = ?${params.length + 1}`;
@@ -323,6 +483,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
323
483
  uptime_seconds: z.number().nullable().optional(),
324
484
  in_flight_count: z.number().nullable().optional(),
325
485
  }).optional().describe("Per-agent process telemetry reported by agent-node"),
486
+ external_schedules: z.object({
487
+ observed_at: z.string().datetime({ offset: true }).max(64),
488
+ schedules: z.array(z.object({
489
+ id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/),
490
+ name: z.string().min(1).max(200),
491
+ kind: z.enum(["cron", "systemd", "tmux", "playwright", "custom"]),
492
+ frequency: z.string().min(1).max(120),
493
+ last_run_at: z.string().datetime({ offset: true }).max(64).nullable(),
494
+ last_status: z.enum(["success", "failed", "running", "unknown"]),
495
+ last_error: z.string().max(500).nullable(),
496
+ next_run_at: z.string().datetime({ offset: true }).max(64).nullable(),
497
+ log_ref: z.string().min(1).max(255).nullable(),
498
+ enabled: z.boolean(),
499
+ // RFC-036 — only agent-node managed cron markers advertise write
500
+ // capability. Legacy/other kinds omit these fields and stay read-only.
501
+ editable: z.boolean().optional(),
502
+ revision: z.number().int().min(0).optional(),
503
+ }).strict()).max(64),
504
+ error: z.enum(["invalid_manifest", "unsafe_manifest", "read_failed"]).optional(),
505
+ }).strict().optional().describe("Bounded node-reported external schedule snapshot; never includes host paths or commands"),
326
506
  // RFC-024 B6 — masked snapshot of the node's effective config
327
507
  // (model + 6 dashboard-editable flags). Secrets ARE NOT in this
328
508
  // shape (env._envRef stays on host); the dashboard reads this
@@ -336,6 +516,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
336
516
  flags: z.record(z.unknown()).optional(),
337
517
  config_revision: z.number().int().min(0).optional(),
338
518
  config_update_capable: z.boolean().optional(),
519
+ peer_reply_inbox_capable: z.literal(true).optional(),
339
520
  // RFC-026 P2 / #338 — daemon role surfaced for hub /api/nodes
340
521
  // discovery (#337 extracts this field). "host_supervisor" =
341
522
  // anet daemon. Default-stripping zod would drop this otherwise.
@@ -353,7 +534,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
353
534
  }).optional(),
354
535
  }).optional().describe("RFC-024 — masked node config snapshot"),
355
536
  },
356
- 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 }) => {
537
+ 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 }) => {
357
538
  const effectiveNetId = getNetworkId(netId);
358
539
  const sessionNetId = effectiveNetId ?? "default";
359
540
  if (!callerTokenIsNetwork || !enforceNetworkId) {
@@ -438,6 +619,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
438
619
  const processCpuPct = typeof proc?.cpu_pct === "number" ? proc.cpu_pct : null;
439
620
  const processUptimeSeconds = typeof proc?.uptime_seconds === "number" ? proc.uptime_seconds : null;
440
621
  const processInFlightCount = typeof proc?.in_flight_count === "number" ? proc.in_flight_count : null;
622
+ const externalSchedulesJson = externalSchedules === undefined ? null : JSON.stringify(externalSchedules);
623
+ const trustedCurrentSnapshot = node_id
624
+ ? trustedConfigSnapshotForNode(cfgSnap ?? null, callerTokenId ?? null, node_id)
625
+ : null;
626
+ const peerReplyInboxCapable = !!(
627
+ trustedCurrentSnapshot
628
+ && typeof trustedCurrentSnapshot === "object"
629
+ && (trustedCurrentSnapshot as Record<string, unknown>).peer_reply_inbox_capable === true
630
+ );
441
631
  const statusHostTelemetry = host ? {
442
632
  hostname: hostHostname,
443
633
  ip: hostIp,
@@ -462,8 +652,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
462
652
  // Only delete same-alias sessions within the same network
463
653
  db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [effectiveAlias, resume_id, sessionNetId]);
464
654
  db.run(
465
- `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)
466
- 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'))
655
+ `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)
656
+ 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'))
467
657
  ON CONFLICT(resume_id) DO UPDATE SET
468
658
  alias = COALESCE(?2, sessions.alias), tmux_name = COALESCE(?3, sessions.tmux_name),
469
659
  server = COALESCE(?4, sessions.server), ip = COALESCE(?5, sessions.ip),
@@ -488,8 +678,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
488
678
  process_cpu_pct = COALESCE(?31, sessions.process_cpu_pct),
489
679
  process_uptime_seconds = COALESCE(?32, sessions.process_uptime_seconds),
490
680
  process_in_flight_count = COALESCE(?33, sessions.process_in_flight_count),
681
+ external_schedules = COALESCE(?34, sessions.external_schedules),
682
+ peer_reply_inbox_capable = ?35,
491
683
  last_seen_at = datetime('now'), updated_at = datetime('now')`,
492
- [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]
684
+ [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]
493
685
  );
494
686
  if (host || proc) {
495
687
  db.run(
@@ -539,6 +731,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
539
731
  upsertNodeWithSec1Guard({
540
732
  node_id,
541
733
  callerNetworkId: effectiveNetId ?? null,
734
+ callerUserId: enforceUserId ?? null,
735
+ callerTokenId: callerTokenId ?? null,
542
736
  node_name: nn || effectiveAlias,
543
737
  alias: effectiveAlias,
544
738
  runtime: nodeRuntime,
@@ -630,6 +824,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
630
824
  } else {
631
825
  updatedTaskId = task;
632
826
  }
827
+ if (updatedTaskId) syncScheduledRunForTask(updatedTaskId, effectiveNetId);
633
828
  });
634
829
  // Log event after transaction
635
830
  if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
@@ -690,7 +885,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
690
885
  const rows0 = db.get<{ cnt: number }>(countSql, ...countParams);
691
886
  console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
692
887
  const rowsParams: any[] = [alias];
693
- let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json
888
+ let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json,
889
+ CASE WHEN type = 'task' THEN COALESCE(task_id, id) ELSE task_id END AS task_id
694
890
  FROM inbox WHERE session_name = ?1 AND acked = 0`;
695
891
  rowsSql = addReadScope(rowsSql, rowsParams, readScope);
696
892
  rowsSql += ` ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
@@ -720,8 +916,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
720
916
  const effectiveNetId = getNetworkId(netId);
721
917
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
722
918
  console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
723
- const ackParams: any[] = [message_id, alias];
724
- let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2";
919
+ // New task consumers ACK by the logical tasks.task_id exposed by
920
+ // get_inbox. Keep exact inbox.id support for legacy consumers and for
921
+ // non-task messages. Restrict the lookup to pending rows: after a retry
922
+ // the original row can have id == task_id but is already ACKed, while
923
+ // the current row has a fresh transport id and the same logical task_id.
924
+ const inboxTaskParams: any[] = [message_id, alias];
925
+ let inboxTaskSql = `SELECT id, type, COALESCE(task_id, id) AS task_id
926
+ FROM inbox
927
+ WHERE session_name = ?2 AND acked = 0
928
+ AND (id = ?1 OR (type = 'task' AND task_id = ?1))`;
929
+ inboxTaskSql = addScope(inboxTaskSql, inboxTaskParams, effectiveNetId);
930
+ inboxTaskSql += " ORDER BY created_at DESC LIMIT 1";
931
+ const inboxTask = db.get<{ id: string; type: string; task_id: string }>(inboxTaskSql, ...inboxTaskParams);
932
+ if (!inboxTask) {
933
+ return {
934
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "message not found or already acknowledged" }) }],
935
+ };
936
+ }
937
+ const ackParams: any[] = [inboxTask.id, alias];
938
+ let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2 AND acked = 0";
725
939
  ackSql = addScope(ackSql, ackParams, effectiveNetId);
726
940
  const result = db.run(ackSql, ackParams);
727
941
  if (result.changes === 0) {
@@ -731,11 +945,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
731
945
  }
732
946
  // V2: sync tasks table — ack_inbox means delivered→acked
733
947
  try {
734
- const taskParams: any[] = [message_id];
948
+ if (inboxTask?.type !== "task") {
949
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }] };
950
+ }
951
+ const taskParams: any[] = [inboxTask.task_id];
735
952
  let taskSql = "UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'";
736
953
  taskSql = addScope(taskSql, taskParams, effectiveNetId);
737
954
  const ackResult = db.run(taskSql, taskParams);
738
- if (ackResult.changes > 0) logTaskEvent(message_id, "delivered", "acked", alias);
955
+ if (ackResult.changes > 0) logTaskEvent(inboxTask.task_id, "delivered", "acked", alias);
739
956
  } catch {}
740
957
  return {
741
958
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }],
@@ -743,6 +960,144 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
743
960
  }
744
961
  );
745
962
 
963
+ // #520 — two monotonic runtime-evidence levels for exact tasks.
964
+ //
965
+ // runtime_submitted_at means agent-node handed the body to the vendor
966
+ // runtime. consumed_at is stronger: an attributable turn-start or first
967
+ // activity event came back. Merely fetching/acking an inbox row sets neither.
968
+ // Node identity comes exclusively from the ntok; callers cannot self-report
969
+ // an alias or node_id. A consumed mark also fills runtime_submitted_at because
970
+ // that stronger fact logically implies submission.
971
+ const markTaskRuntimeEvidence = (
972
+ taskIds: string[],
973
+ level: "submitted" | "consumed",
974
+ ) => {
975
+ const task_ids = taskIds;
976
+ if (!callerTokenIsNetwork || !callerAlias || !enforceNetworkId) {
977
+ return {
978
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_required" }) }],
979
+ };
980
+ }
981
+ // PgAdapter.transaction currently opens a fresh subprocess/connection
982
+ // per statement. The all-or-nothing batch promise cannot be made there;
983
+ // refuse evidence rather than publish a partially stamped batch.
984
+ if (db.dialect !== "sqlite") {
985
+ return {
986
+ content: [{ type: "text" as const, text: JSON.stringify({
987
+ ok: false,
988
+ error: "task_runtime_evidence_backend_unsupported",
989
+ }) }],
990
+ };
991
+ }
992
+
993
+ const uniqueTaskIds = [...new Set(task_ids)];
994
+ if (uniqueTaskIds.length !== task_ids.length) {
995
+ return {
996
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_id" }) }],
997
+ };
998
+ }
999
+
1000
+ const placeholders = uniqueTaskIds.map((_, i) => `?${i + 2}`).join(", ");
1001
+
1002
+ // Keep ownership preflight and every stamp in one SQLite transaction.
1003
+ // This closes the cross-process preflight→UPDATE race: no other writer
1004
+ // can reassign a task between the identity decision and the write.
1005
+ const outcome = db.transaction(() => {
1006
+ const canonicalCaller = resolveCanonicalAlias(enforceNetworkId, callerAlias).alias;
1007
+ const callerSession = db.get<{ node_id: string | null }>(
1008
+ `SELECT node_id FROM sessions
1009
+ WHERE network_id = ?1 AND alias = ?2
1010
+ ORDER BY updated_at DESC LIMIT 1`,
1011
+ enforceNetworkId,
1012
+ canonicalCaller,
1013
+ );
1014
+ const rows = db.all<{ task_id: string; to_node_id: string | null; to_name: string }>(
1015
+ `SELECT task_id, to_node_id, to_name FROM tasks
1016
+ WHERE network_id = ?1 AND task_id IN (${placeholders})`,
1017
+ enforceNetworkId,
1018
+ ...uniqueTaskIds,
1019
+ );
1020
+ const owned = new Map(rows.map((row) => [row.task_id, row]));
1021
+ const rejectedTaskId = uniqueTaskIds.find((taskId) => {
1022
+ const row = owned.get(taskId);
1023
+ if (!row) return true;
1024
+ // Prefer immutable node identity whenever the task has one. Direct
1025
+ // and legacy token-bound sessions legitimately have NULL node_id;
1026
+ // only that shape may fall back to the canonical token alias.
1027
+ if (row.to_node_id) return row.to_node_id !== callerSession?.node_id;
1028
+ return resolveCanonicalAlias(enforceNetworkId, row.to_name).alias !== canonicalCaller;
1029
+ });
1030
+ if (rejectedTaskId) {
1031
+ return { ok: false as const, error: "task_not_owned", task_id: rejectedTaskId };
1032
+ }
1033
+
1034
+ for (const taskId of uniqueTaskIds) {
1035
+ const row = owned.get(taskId)!;
1036
+ const ownershipSql = row.to_node_id
1037
+ ? "to_node_id = ?3"
1038
+ : "to_node_id IS NULL AND to_name = ?3";
1039
+ const ownerValue = row.to_node_id ?? row.to_name;
1040
+ let updateResult;
1041
+ if (level === "consumed") {
1042
+ updateResult = db.run(
1043
+ `UPDATE tasks SET
1044
+ runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now')),
1045
+ consumed_at = COALESCE(consumed_at, datetime('now'))
1046
+ WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
1047
+ [enforceNetworkId, taskId, ownerValue],
1048
+ );
1049
+ } else {
1050
+ updateResult = db.run(
1051
+ `UPDATE tasks SET runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now'))
1052
+ WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
1053
+ [enforceNetworkId, taskId, ownerValue],
1054
+ );
1055
+ }
1056
+ if (updateResult.changes !== 1) {
1057
+ // A zero-row write after a successful preflight is an invariant
1058
+ // failure, never a successful evidence report.
1059
+ throw new Error(`task_runtime_evidence_write_race:${taskId}`);
1060
+ }
1061
+ }
1062
+ const evidenceRows = db.all<{
1063
+ task_id: string;
1064
+ runtime_submitted_at: string;
1065
+ consumed_at: string | null;
1066
+ }>(
1067
+ `SELECT task_id, runtime_submitted_at, consumed_at FROM tasks
1068
+ WHERE network_id = ?1 AND task_id IN (${placeholders})`,
1069
+ enforceNetworkId,
1070
+ ...uniqueTaskIds,
1071
+ );
1072
+ return { ok: true as const, tasks: evidenceRows };
1073
+ });
1074
+
1075
+ return {
1076
+ content: [{
1077
+ type: "text" as const,
1078
+ text: JSON.stringify(outcome),
1079
+ }],
1080
+ };
1081
+ };
1082
+
1083
+ const taskRuntimeEvidenceSchema = {
1084
+ task_ids: z.array(z.string().min(1).max(200)).min(1).max(100),
1085
+ };
1086
+
1087
+ server.tool(
1088
+ "mark_tasks_runtime_submitted",
1089
+ "Internal agent-node signal: exact task bodies were submitted to this token-bound node's vendor runtime.",
1090
+ taskRuntimeEvidenceSchema,
1091
+ async ({ task_ids }) => markTaskRuntimeEvidence(task_ids, "submitted"),
1092
+ );
1093
+
1094
+ server.tool(
1095
+ "mark_tasks_consumed",
1096
+ "Internal agent-node signal: exact tasks produced attributable turn-start/activity evidence in this token-bound node's runtime.",
1097
+ taskRuntimeEvidenceSchema,
1098
+ async ({ task_ids }) => markTaskRuntimeEvidence(task_ids, "consumed"),
1099
+ );
1100
+
746
1101
  // ═══════════════════════════════════════════
747
1102
  // Hub Tools (5)
748
1103
  // ═══════════════════════════════════════════
@@ -838,7 +1193,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
838
1193
  },
839
1194
  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);
840
1195
  const effectiveNetId = getNetworkId(netId);
841
- const metaJson = normalizeMetaJson(meta);
1196
+ const authOrigin: TaskAuthOrigin = callerTokenIsNetwork
1197
+ ? "node"
1198
+ : enforceUserId
1199
+ ? "user"
1200
+ : "legacy";
1201
+ const metaJson = normalizeMetaJson(stampTaskAuthOrigin(meta, authOrigin));
842
1202
 
843
1203
  // Role check FIRST — round5 follow-up (通信牛 oracle catch):
844
1204
  // the explicit-parent verification below distinguishes
@@ -913,6 +1273,37 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
913
1273
  const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
914
1274
  if (target.state === "not_found") return deliveryTargetReply(target)!;
915
1275
 
1276
+ // Dashboard sends carry a random client_request_id inside meta. Derive
1277
+ // the task primary key from authenticated scope + that request id so a
1278
+ // lost HTTP response can be retried safely, even after a hub restart.
1279
+ // The existing row must match the full request; key reuse with changed
1280
+ // content/target fails closed instead of silently returning another task.
1281
+ const clientRequestId = clientRequestIdFromMeta(meta);
1282
+ const id = clientRequestId
1283
+ ? idempotentTaskId(effectiveNetId ?? null, from_session, clientRequestId)
1284
+ : uuidv4();
1285
+ if (clientRequestId) {
1286
+ const existing = db.get<StoredIdempotentTask>(
1287
+ "SELECT task_id, from_name, to_name, priority, content, network_id, meta_json, status FROM tasks WHERE task_id = ?1",
1288
+ [id],
1289
+ );
1290
+ if (existing) {
1291
+ if (!idempotentTaskMatches(existing, {
1292
+ fromName: from_session, toName: targetAlias, priority, content: task,
1293
+ networkId: effectiveNetId ?? null, metaJson,
1294
+ })) {
1295
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1296
+ ok: false, error: "idempotency_conflict",
1297
+ message: "client_request_id was already used with a different task payload",
1298
+ }) }] };
1299
+ }
1300
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1301
+ ok: true, message_id: existing.task_id, task_id: existing.task_id,
1302
+ task_status: existing.status, idempotent_replay: true,
1303
+ }) }] };
1304
+ }
1305
+ }
1306
+
916
1307
  // #212 dedup guardrail. If this exact (from, to, content) has already
917
1308
  // been delivered within COMMHUB_SEND_DEDUP_WINDOW_MS (default 5 min)
918
1309
  // we refuse the call and surface a structured `duplicate_send`
@@ -940,7 +1331,6 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
940
1331
  }
941
1332
 
942
1333
  console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
943
- const id = uuidv4();
944
1334
  const fromNodeId = resolveNodeIdForAlias(from_session, effectiveNetId);
945
1335
  const targetNodeId = target.session?.node_id ?? null;
946
1336
  // 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
@@ -949,8 +1339,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
949
1339
  // 报告冲突)。
950
1340
  db.transaction(() => {
951
1341
  db.run(
952
- `INSERT INTO inbox (id, session_name, node_id, type, priority, content, context, from_session, requires_response, network_id, meta_json)
953
- VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, ?7, 'reply', ?8, ?9)`,
1342
+ `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, context, from_session, requires_response, network_id, meta_json)
1343
+ VALUES (?1, ?1, ?2, ?3, 'task', ?4, ?5, ?6, ?7, 'reply', ?8, ?9)`,
954
1344
  [id, targetAlias, targetNodeId, priority, task, context ?? null, from_session, effectiveNetId ?? null, metaJson]
955
1345
  );
956
1346
  db.run(
@@ -1081,11 +1471,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1081
1471
  }
1082
1472
  );
1083
1473
 
1084
- // ── V2: send_reply (关联 task_id,不触发 think) ──
1085
- server.tool(
1086
- "send_reply",
1087
- "Send a reply to a Dashboard/UI-originated task (updates task row, emits new_reply SSE — Dashboard reads it). ⚠ NOT for agent-to-agent replies: agent nodes only log new_reply and do not processInbox on it, so the peer will not see the reply in real time. Use send_task for peer-to-peer replies (RFC-030, Vincent 2026-07-28 全网规则). If the target alias resolves to a live agent node, the response includes a `warning` field pointing to send_task.",
1088
- {
1474
+ // ── V2/V3 reply primitives ──
1475
+ const replyToolSchema = {
1089
1476
  alias: z.string().min(1).max(200).describe("Target session alias"),
1090
1477
  text: z.string().min(1).max(10000).describe("Reply content"),
1091
1478
  in_reply_to: z.string().max(200).optional().describe("Original task/message ID"),
@@ -1102,10 +1489,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1102
1489
  // both `attachments` (top-level) and `meta.attachments` are supplied,
1103
1490
  // top-level wins (same rule as REST /api/task L2101).
1104
1491
  meta: z.any().optional().describe("Optional structured reply metadata, e.g. { attachments: [...] }."),
1105
- },
1106
- async ({ alias, text, in_reply_to, status: replyStatus, from_session: _fromIn, network_id: netId, attachments, meta }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1492
+ };
1493
+ const handleReply = async (args: any, peerCapabilityRequired: boolean) => {
1494
+ const { alias, text, in_reply_to, status: replyStatus = "replied", from_session: _fromIn, network_id: netId, attachments, meta } = args;
1495
+ const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1107
1496
  const effectiveNetId = getNetworkId(netId);
1108
1497
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1498
+ if (peerCapabilityRequired && !in_reply_to) {
1499
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "peer_reply_task_required" }) }] };
1500
+ }
1109
1501
 
1110
1502
  // #507 — validate attachments BEFORE any DB write. Rejects malformed
1111
1503
  // input (bad file_id, >20 items, size > cap, non-object item, wrong
@@ -1132,51 +1524,99 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1132
1524
  : meta;
1133
1525
  const metaJson = normalizeMetaJson(mergedMeta);
1134
1526
 
1135
- console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}${attachmentsResult.attachments.length ? ` [+${attachmentsResult.attachments.length} attachments]` : ""}`);
1527
+ const canonicalReplyTarget = resolveCanonicalAlias(effectiveNetId, alias);
1528
+ const replyTargetAlias = canonicalReplyTarget.alias;
1529
+ 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}]` : ""}`);
1136
1530
  const id = uuidv4();
1137
- let taskBefore: { status: string } | null = null;
1138
- if (in_reply_to) {
1139
- const taskParams: any[] = [in_reply_to];
1140
- let taskSql = "SELECT status FROM tasks WHERE task_id = ?1";
1141
- taskSql = addScope(taskSql, taskParams, effectiveNetId);
1142
- taskBefore = db.get<{ status: string }>(taskSql, ...taskParams) ?? null;
1143
- if (!taskBefore) {
1144
- return {
1145
- content: [{
1146
- type: "text" as const,
1147
- text: JSON.stringify({
1148
- ok: false,
1149
- error: "reply_task_not_found",
1150
- message: `cannot apply reply: task not found (${in_reply_to})`,
1151
- in_reply_to,
1152
- reply_queued: false,
1153
- }),
1154
- }],
1155
- };
1156
- }
1157
- if (!["created", "delivered", "acked", "running"].includes(taskBefore.status)) {
1158
- return {
1159
- content: [{
1160
- type: "text" as const,
1161
- text: JSON.stringify({
1162
- ok: false,
1163
- error: "reply_task_terminal",
1164
- message: `cannot apply reply: task is already terminal (${taskBefore.status})`,
1165
- in_reply_to,
1166
- task_status: taskBefore.status,
1167
- reply_queued: false,
1168
- }),
1169
- }],
1170
- };
1171
- }
1172
- }
1173
- const replyTargetNodeId = resolveNodeIdForAlias(alias, effectiveNetId);
1531
+ const replyTargetNodeId = resolveNodeIdForAlias(replyTargetAlias, effectiveNetId);
1174
1532
  // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 3/6).
1175
1533
  {
1176
- const lc = assertNodeActive(alias, effectiveNetId ?? null);
1534
+ const lc = assertNodeActive(replyTargetAlias, effectiveNetId ?? null);
1177
1535
  if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1178
1536
  }
1179
- const replyLogged = db.transaction(() => {
1537
+ const replyOutcome = db.transaction(() => {
1538
+ type ReplyTask = {
1539
+ status: string;
1540
+ from_node_id: string | null;
1541
+ from_name: string;
1542
+ to_name: string;
1543
+ to_node_id: string | null;
1544
+ };
1545
+ let taskBefore: ReplyTask | null = null;
1546
+ let callerNodeId: string | null = null;
1547
+ if (in_reply_to) {
1548
+ const taskParams: any[] = [in_reply_to];
1549
+ let taskSql = "SELECT status, from_node_id, from_name, to_name, to_node_id FROM tasks WHERE task_id = ?1";
1550
+ taskSql = addScope(taskSql, taskParams, effectiveNetId);
1551
+ taskBefore = db.get<ReplyTask>(taskSql, ...taskParams) ?? null;
1552
+ if (!taskBefore) return { ok: false as const, error: "reply_task_not_found" as const };
1553
+ if (!["created", "delivered", "acked", "running"].includes(taskBefore.status)) {
1554
+ return { ok: false as const, error: "reply_task_terminal" as const, taskStatus: taskBefore.status };
1555
+ }
1556
+
1557
+ // V3 atomic peer replies are capability-negotiated. Both identity
1558
+ // bindings and the recipient capability are re-read inside this
1559
+ // transaction before any inbox/task/run write. Legacy/unbound rows
1560
+ // fail toward compatibility send_reply, which terminalizes the
1561
+ // original task instead of creating a second response-requiring row.
1562
+ if (peerCapabilityRequired) {
1563
+ if (!taskBefore.from_node_id) {
1564
+ // Origin type is an intrinsic property of the task and must be
1565
+ // decided before caller capability. A Dashboard/human task still
1566
+ // needs the established send_reply terminalization path when the
1567
+ // replying node uses a legacy/unbound token.
1568
+ return { ok: false as const, error: "peer_reply_origin_not_node" as const };
1569
+ }
1570
+ if (!callerTokenIsNetwork || !callerTokenId || !enforceNetworkId) {
1571
+ return { ok: false as const, error: "peer_reply_node_token_required" as const };
1572
+ }
1573
+ const token = db.get<{ bound_node_id: string | null }>(
1574
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
1575
+ callerTokenId,
1576
+ enforceNetworkId,
1577
+ );
1578
+ if (!token?.bound_node_id || !taskBefore.to_node_id) {
1579
+ return { ok: false as const, error: "peer_reply_unsupported" as const };
1580
+ }
1581
+ if (token.bound_node_id !== taskBefore.to_node_id) {
1582
+ return { ok: false as const, error: "reply_task_not_owned" as const };
1583
+ }
1584
+ callerNodeId = token.bound_node_id;
1585
+ const canonicalTarget = replyTargetAlias;
1586
+ const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, taskBefore.from_name).alias;
1587
+ if (canonicalTarget !== canonicalOrigin) {
1588
+ return { ok: false as const, error: "reply_target_mismatch" as const };
1589
+ }
1590
+ const recipient = db.get<{ peer_reply_inbox_capable: number }>(
1591
+ `SELECT peer_reply_inbox_capable FROM sessions
1592
+ WHERE node_id = ?1 AND network_id = ?2 AND alias = ?3
1593
+ ORDER BY updated_at DESC LIMIT 1`,
1594
+ taskBefore.from_node_id,
1595
+ enforceNetworkId,
1596
+ canonicalOrigin,
1597
+ );
1598
+ if (recipient?.peer_reply_inbox_capable !== 1) {
1599
+ return { ok: false as const, error: "peer_reply_unsupported" as const };
1600
+ }
1601
+ } else if (callerTokenIsNetwork && enforceNetworkId) {
1602
+ // Compatibility send_reply is still an exact task transition for
1603
+ // node callers. Bind it to the token-authenticated assignee alias
1604
+ // and immutable original sender. Alias binding survives a
1605
+ // legitimate node-id rotation without letting another node close
1606
+ // the task.
1607
+ const canonicalCaller = resolveCanonicalAlias(enforceNetworkId, from_session).alias;
1608
+ const canonicalAssignee = resolveCanonicalAlias(enforceNetworkId, taskBefore.to_name).alias;
1609
+ if (canonicalCaller !== canonicalAssignee) {
1610
+ return { ok: false as const, error: "reply_task_not_owned" as const };
1611
+ }
1612
+ const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, taskBefore.from_name).alias;
1613
+ if (replyTargetAlias !== canonicalOrigin) {
1614
+ return { ok: false as const, error: "reply_target_mismatch" as const };
1615
+ }
1616
+ }
1617
+
1618
+ }
1619
+
1180
1620
  // #507 — write meta_json on inbox insert (parity with send_task L952).
1181
1621
  // Prior to this the attachments field on a send_reply call was
1182
1622
  // silently stripped by MCP Zod, leaving `ok:true` with no persisted
@@ -1184,7 +1624,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1184
1624
  db.run(
1185
1625
  `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id, meta_json)
1186
1626
  VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7, ?8)`,
1187
- [id, alias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null, metaJson]
1627
+ [id, replyTargetAlias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null, metaJson]
1188
1628
  );
1189
1629
 
1190
1630
  // 更新 tasks 表
@@ -1203,31 +1643,53 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1203
1643
  completed_at = datetime('now'),
1204
1644
  meta_json = COALESCE(?3, meta_json)
1205
1645
  WHERE task_id = ?4 AND status IN ('created', 'delivered', 'acked', 'running')`;
1646
+ if (peerCapabilityRequired && callerTokenIsNetwork && taskBefore) {
1647
+ if (taskBefore.to_node_id) {
1648
+ updateParams.push(callerNodeId);
1649
+ updateSql += ` AND to_node_id = ?${updateParams.length}`;
1650
+ } else {
1651
+ updateParams.push(taskBefore.to_name);
1652
+ updateSql += ` AND to_node_id IS NULL AND to_name = ?${updateParams.length}`;
1653
+ }
1654
+ }
1206
1655
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1207
1656
  const result = db.run(updateSql, updateParams);
1208
1657
  if (result.changes === 0) {
1209
- console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
1210
- return false;
1658
+ throw new Error(`reply_atomic_cas_failed:${in_reply_to}`);
1211
1659
  }
1212
- return true;
1660
+ syncScheduledRunForTask(in_reply_to, effectiveNetId);
1661
+ return { ok: true as const, replyLogged: true };
1213
1662
  }
1214
- return false;
1663
+ return { ok: true as const, replyLogged: false };
1215
1664
  });
1216
1665
 
1217
- if (in_reply_to && !replyLogged) {
1666
+ if (!replyOutcome.ok) {
1667
+ const taskStatus = "taskStatus" in replyOutcome ? replyOutcome.taskStatus : undefined;
1668
+ const messages: Record<string, string> = {
1669
+ reply_task_not_found: `cannot apply reply: task not found (${in_reply_to})`,
1670
+ reply_task_terminal: `cannot apply reply: task is already terminal (${taskStatus})`,
1671
+ peer_reply_node_token_required: "atomic peer reply requires a node token",
1672
+ peer_reply_unsupported: "recipient or caller does not support atomic peer replies",
1673
+ peer_reply_origin_not_node: "original task sender is not a node; use send_reply",
1674
+ reply_node_identity_unbound: "cannot apply reply: node identity is not token-bound",
1675
+ reply_task_not_owned: "cannot apply reply: task is not owned by this node",
1676
+ reply_target_mismatch: "cannot apply reply: target is not the original task sender",
1677
+ };
1218
1678
  return {
1219
1679
  content: [{
1220
1680
  type: "text" as const,
1221
1681
  text: JSON.stringify({
1222
1682
  ok: false,
1223
- error: "reply_not_applied",
1224
- message: "reply was not applied to task",
1683
+ error: replyOutcome.error,
1684
+ message: messages[replyOutcome.error],
1225
1685
  in_reply_to,
1686
+ ...(taskStatus ? { task_status: taskStatus } : {}),
1226
1687
  reply_queued: false,
1227
1688
  }),
1228
1689
  }],
1229
1690
  };
1230
1691
  }
1692
+ const replyLogged = replyOutcome.replyLogged;
1231
1693
 
1232
1694
  // Log event after commit (outside transaction)
1233
1695
  if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
@@ -1264,20 +1726,21 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1264
1726
  }
1265
1727
  }
1266
1728
 
1267
- const session = scopedSessionStatus(alias, effectiveNetId);
1268
- pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
1729
+ const session = scopedSessionStatus(replyTargetAlias, effectiveNetId);
1730
+ pushEvent(replyTargetAlias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
1269
1731
  // #461 network observer summary — ids + routing only, no reply text.
1270
- pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to ?? null, message_id: id, from: from_session, to: alias, status: replyStatus });
1271
-
1272
- // #498 — When the reply target is a live agent node (not Dashboard/UI),
1273
- // warn the caller that agent peers only log new_reply and do not
1274
- // processInbox on it. Points at the correct next action (send_task)
1275
- // so a follow-up turn corrects course rather than "why didn't they
1276
- // respond" spelunking. Absent field when target is Dashboard/hub/api
1277
- // (the correct path for commhub_reply) — silence is affirmation.
1278
- const targetIsAgent = replyTargetNodeId !== null && alias !== "hub" && alias !== "api";
1279
- const warning = targetIsAgent
1280
- ? `Target "${alias}" is an agent node. commhub_reply/send_reply does NOT wake agent peers (they only log new_reply, they do not processInbox on it) — the peer will not see this reply in real time. For agent-to-agent replies, use commhub_send_task(alias="${alias}", task="<your reply>") so the peer wakes via new_task SSE and processes it. (RFC-030, Vincent 2026-07-28 全网规则.)`
1732
+ pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to ?? null, message_id: id, from: from_session, to: replyTargetAlias, status: replyStatus });
1733
+
1734
+ // #498 compatibility tripwire. Legacy send_reply is intentionally kept
1735
+ // for old agents and Dashboard callers during rollout, but it is not the
1736
+ // capability-negotiated atomic peer primitive. Warn only for an actual
1737
+ // agent target; Dashboard hub/api replies remain quiet. New agents call
1738
+ // send_peer_reply and never see this warning.
1739
+ const targetIsAgent = replyTargetNodeId !== null
1740
+ && replyTargetAlias !== "hub"
1741
+ && replyTargetAlias !== "api";
1742
+ const warning = !peerCapabilityRequired && targetIsAgent
1743
+ ? `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.)`
1281
1744
  : undefined;
1282
1745
 
1283
1746
  // #507 — echo attachments READ BACK FROM DB (lead 2b5f6634): the point
@@ -1315,7 +1778,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1315
1778
  }),
1316
1779
  }],
1317
1780
  };
1318
- }
1781
+ };
1782
+
1783
+ server.tool(
1784
+ "send_reply",
1785
+ "Reply to a Dashboard/UI-originated task. Also serves as the terminal legacy fallback when atomic peer reply capability is unavailable.",
1786
+ replyToolSchema,
1787
+ (args) => handleReply(args, false),
1788
+ );
1789
+ server.tool(
1790
+ "send_peer_reply",
1791
+ "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.",
1792
+ replyToolSchema,
1793
+ (args) => handleReply(args, true),
1319
1794
  );
1320
1795
 
1321
1796
  // ── V2: send_ack (不入 inbox,仅更新状态) ──
@@ -1381,12 +1856,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1381
1856
  WHERE task_id = ?1`;
1382
1857
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1383
1858
  db.run(updateSql, updateParams);
1859
+ syncScheduledRunForTask(task_id, effectiveNetId ?? task.network_id ?? null);
1384
1860
  // Re-queue in inbox with new ID (original ID may already exist)
1385
1861
  const retryInboxId = uuidv4();
1386
1862
  db.run(
1387
- `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id)
1388
- VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7)`,
1389
- [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]
1863
+ `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id)
1864
+ VALUES (?1, ?2, ?3, ?4, 'task', ?5, ?6, ?7, 'reply', ?8)`,
1865
+ [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]
1390
1866
  );
1391
1867
  });
1392
1868
  logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
@@ -1437,7 +1913,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1437
1913
  async ({ alias, status, from_name, from_node_id, network_id: netId, limit }) => {
1438
1914
  const readScope = resolveReadScope(netId);
1439
1915
  if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
1440
- 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";
1916
+ 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";
1441
1917
  const params: any[] = [];
1442
1918
  sql = addReadScope(sql, params, readScope);
1443
1919
  if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
@@ -1482,15 +1958,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1482
1958
  let updateSql = `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
1483
1959
  WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`;
1484
1960
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1485
- const result = db.run(updateSql, updateParams);
1486
- // Also ack the inbox entry to prevent agent from picking it up
1487
- if (result.changes > 0) {
1488
- const inboxParams: any[] = [task_id];
1489
- let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
1490
- inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
1491
- db.run(inboxSql, inboxParams);
1492
- logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
1493
- }
1961
+ const result = db.transaction(() => {
1962
+ const updated = db.run(updateSql, updateParams);
1963
+ // Also ack the inbox entry to prevent agent from picking it up.
1964
+ if (updated.changes > 0) {
1965
+ const inboxParams: any[] = [task_id];
1966
+ let inboxSql = "UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0";
1967
+ inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
1968
+ db.run(inboxSql, inboxParams);
1969
+ syncScheduledRunForTask(task_id, effectiveNetId);
1970
+ }
1971
+ return updated;
1972
+ });
1973
+ if (result.changes > 0) logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
1494
1974
  return {
1495
1975
  content: [{ type: "text" as const, text: JSON.stringify({ ok: result.changes > 0, task_id, cancelled: result.changes > 0 }) }],
1496
1976
  };
@@ -1532,7 +2012,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1532
2012
  db.transaction(() => {
1533
2013
  // Ack old inbox to prevent original agent from picking it up
1534
2014
  const inboxParams: any[] = [task_id];
1535
- let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
2015
+ let inboxSql = "UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0";
1536
2016
  inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
1537
2017
  db.run(inboxSql, inboxParams);
1538
2018
 
@@ -1542,8 +2022,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1542
2022
  db.run(updateSql, updateParams);
1543
2023
 
1544
2024
  const newInboxId = uuidv4();
1545
- 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)",
1546
- [newInboxId, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
2025
+ 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)",
2026
+ [newInboxId, task_id, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
1547
2027
  });
1548
2028
  logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${reassignedAlias}`);
1549
2029
  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);
@@ -3583,6 +4063,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3583
4063
  export interface UpsertNodeWithSec1GuardInput {
3584
4064
  node_id: string;
3585
4065
  callerNetworkId: string | null;
4066
+ callerUserId?: string | null;
4067
+ callerTokenId?: string | null;
3586
4068
  node_name?: string | null;
3587
4069
  alias?: string | null;
3588
4070
  runtime?: string | null;
@@ -3595,19 +4077,60 @@ export interface UpsertNodeWithSec1GuardInput {
3595
4077
  }
3596
4078
  export type UpsertNodeOutcome =
3597
4079
  | { result: "inserted" | "updated"; node_id: string }
3598
- | { result: "refused"; reason: "cross_network"; existingNet: string | null; callerNet: string | null }
4080
+ | { result: "refused"; reason: "cross_network" | "token_node_mismatch" | "owner_mismatch"; existingNet: string | null; callerNet: string | null }
3599
4081
  | { result: "skipped"; reason: "missing_node_id" };
3600
4082
 
3601
4083
  const _norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x);
3602
4084
 
4085
+ /**
4086
+ * A capability that changes peer delivery semantics is trusted only when the
4087
+ * authenticating ntok is immutably bound to the exact reported node. Legacy
4088
+ * alias-only tokens may still heartbeat, but cannot opt a node into #698.
4089
+ */
4090
+ export function trustedConfigSnapshotForNode(
4091
+ snapshot: unknown | null | undefined,
4092
+ callerTokenId: string | null | undefined,
4093
+ nodeId: string,
4094
+ ): unknown | null | undefined {
4095
+ if (!snapshot || typeof snapshot !== "object") return snapshot;
4096
+ const copy = { ...(snapshot as Record<string, unknown>) };
4097
+ const token = callerTokenId
4098
+ ? db.get<{ bound_node_id: string | null }>(
4099
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1",
4100
+ callerTokenId,
4101
+ )
4102
+ : null;
4103
+ if (token?.bound_node_id !== nodeId) delete copy.peer_reply_inbox_capable;
4104
+ return copy;
4105
+ }
4106
+
3603
4107
  export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): UpsertNodeOutcome {
3604
4108
  if (!input.node_id) return { result: "skipped", reason: "missing_node_id" };
3605
- const existing = db.get<{ network_id: string | null }>(
3606
- "SELECT network_id FROM nodes WHERE node_id = ?1",
4109
+ const existing = db.get<{ network_id: string | null; owner_user_id: string | null }>(
4110
+ "SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1",
3607
4111
  input.node_id,
3608
4112
  );
3609
4113
  const callerNet = input.callerNetworkId;
3610
4114
 
4115
+ // RFC-036 — when a token was minted with a node_id binding, a heartbeat may
4116
+ // report only that exact node. Legacy unbound ntok rows remain compatible,
4117
+ // but they can never consume owner-gated schedule intents.
4118
+ if (input.callerTokenId) {
4119
+ const token = db.get<{ bound_node_id: string | null; user_id: string; network_id: string | null }>(
4120
+ "SELECT bound_node_id, user_id, network_id FROM api_tokens WHERE token_id = ?1",
4121
+ input.callerTokenId,
4122
+ );
4123
+ if (token?.bound_node_id && token.bound_node_id !== input.node_id) {
4124
+ return { result: "refused", reason: "token_node_mismatch", existingNet: existing?.network_id ?? null, callerNet };
4125
+ }
4126
+ if (token?.network_id && _norm(token.network_id) !== _norm(callerNet)) {
4127
+ return { result: "refused", reason: "cross_network", existingNet: existing?.network_id ?? null, callerNet };
4128
+ }
4129
+ }
4130
+ if (existing?.owner_user_id && input.callerUserId !== existing.owner_user_id) {
4131
+ return { result: "refused", reason: "owner_mismatch", existingNet: existing.network_id, callerNet };
4132
+ }
4133
+
3611
4134
  // Legacy / first-write paths: row missing OR network_id NULL → claim.
3612
4135
  const isLegacy = !existing
3613
4136
  || existing.network_id === null
@@ -3653,7 +4176,12 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
3653
4176
  callerNet ?? null,
3654
4177
  ],
3655
4178
  );
3656
- if (input.config_snapshot) {
4179
+ const trustedSnapshot = trustedConfigSnapshotForNode(
4180
+ input.config_snapshot,
4181
+ input.callerTokenId,
4182
+ input.node_id,
4183
+ );
4184
+ if (trustedSnapshot) {
3657
4185
  // RFC-026 §9.3 / #338 PR2+PR3 — promote daemon self-declare fields
3658
4186
  // to first-class indexable columns alongside the snapshot blob.
3659
4187
  // The snapshot stays the source of truth for non-list reads; the
@@ -3667,7 +4195,7 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
3667
4195
  // tools.ts:2010/2075). PR2 ate from top-level keys, which the
3668
4196
  // hub create_node path never read → max_concurrent_children
3669
4197
  // backpressure was dead config + allowlist enforcement bypassed.
3670
- const snap = input.config_snapshot as Record<string, unknown> | null;
4198
+ const snap = trustedSnapshot as Record<string, unknown> | null;
3671
4199
  const caps = (snap?.daemon_capabilities ?? null) as Record<string, unknown> | null;
3672
4200
  const runtimesRaw = caps?.runtimes_supported;
3673
4201
  const allowedRaw = caps?.allowed_secret_keys;
@@ -3679,7 +4207,7 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
3679
4207
  : null;
3680
4208
  db.run(
3681
4209
  `UPDATE nodes SET config_snapshot = ?1, runtimes_supported = ?2, allowed_secret_keys = ?3 WHERE node_id = ?4`,
3682
- [JSON.stringify(input.config_snapshot), runtimesJson, allowedJson, input.node_id],
4210
+ [JSON.stringify(trustedSnapshot), runtimesJson, allowedJson, input.node_id],
3683
4211
  );
3684
4212
  // RFC-024 — finalize any pending/restarting update whose target
3685
4213
  // patch is now reflected in the snapshot. Closes the
@@ -3689,7 +4217,7 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
3689
4217
  // update_id to call ack_config_update(applied) itself. Hub does
3690
4218
  // it on the new child's behalf by content-matching the patch
3691
4219
  // against the reported snapshot.
3692
- finalizePendingMatchingUpdates(input.node_id, input.config_snapshot);
4220
+ finalizePendingMatchingUpdates(input.node_id, trustedSnapshot);
3693
4221
  }
3694
4222
  // RFC-026 §2.5 step 4 — on every register/report_status, opportunistically
3695
4223
  // close out any pending create_node request whose child_name matches