@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.
- package/bin/commhub.ts +4 -0
- package/package.json +4 -4
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +5 -5
- package/src/admin-networks-http.test.ts +84 -0
- package/src/api-host-supervisors-fallback.test.ts +1 -6
- package/src/auth.ts +88 -14
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db.ts +279 -32
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +10 -17
- package/src/external-schedule-edits-http.test.ts +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +1 -1
- package/src/file-download-authz.test.ts +21 -1
- package/src/file-download-date-bucket.test.ts +1 -1
- package/src/file-network-scope-dev-open.test.ts +1 -1
- package/src/file-network-scope.test.ts +1 -1
- package/src/health-redaction.test.ts +12 -0
- package/src/index.ts +6 -2
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +20 -0
- package/src/node-attrs.test.ts +1 -1
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/rest-explicit-columns-http.test.ts +149 -0
- package/src/rest-identity.test.ts +120 -0
- package/src/rest-identity.ts +93 -0
- package/src/rest-projections.ts +67 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +460 -0
- package/src/scheduled-tasks.ts +538 -0
- package/src/send-reply-agent-warning.test.ts +38 -38
- package/src/server.ts +315 -60
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +329 -0
- package/src/task-diagnostic-http.test.ts +145 -0
- package/src/task-diagnostic.test.ts +82 -0
- package/src/task-diagnostic.ts +89 -0
- package/src/task-idempotency-mcp.test.ts +137 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +160 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +635 -107
- package/src/uploads-http.test.ts +1 -3
- 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 {
|
|
4
|
-
import {
|
|
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
|
-
|
|
724
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
1085
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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,
|
|
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
|
-
|
|
1210
|
-
return false;
|
|
1658
|
+
throw new Error(`reply_atomic_cas_failed:${in_reply_to}`);
|
|
1211
1659
|
}
|
|
1212
|
-
|
|
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 (
|
|
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:
|
|
1224
|
-
message:
|
|
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(
|
|
1268
|
-
pushEvent(
|
|
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:
|
|
1271
|
-
|
|
1272
|
-
// #498
|
|
1273
|
-
//
|
|
1274
|
-
//
|
|
1275
|
-
//
|
|
1276
|
-
//
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
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', ?
|
|
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.
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
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', ?
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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,
|
|
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
|