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