@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.40
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 +107 -13
- 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-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +80 -1
- package/src/create-node-validate.ts +49 -5
- package/src/create-node.ts +51 -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-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +622 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message.test.ts +286 -0
- 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/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2187
- 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/node-id-alias.test.ts +147 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/observer-avatar-http.test.ts +424 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -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 +163 -7
- package/src/reply-target-derivation.test.ts +96 -0
- 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 +212 -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-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +3591 -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 +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +229 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/start-node.test.ts +109 -0
- package/src/stop-delete-node.test.ts +952 -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 +471 -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 +2952 -191
- 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,14 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
2
3
|
import { z } from "zod/v4";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { parseAliasFilter } from "./alias-filter.js";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
|
|
7
|
+
import { getSSEStats, hasSubscribers, hasUserSubscribers, pushEvent, pushNetworkObserverEvent, pushUserEvent } from "./push.js";
|
|
8
|
+
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
9
|
+
import { pendingInboxCount } from "./inbox-count.js";
|
|
5
10
|
import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
|
|
11
|
+
import { canRestWriteNetwork, getUserNetworkIds, singleNetworkId } from "./network-scope.js";
|
|
6
12
|
import {
|
|
7
13
|
buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
|
|
8
14
|
validateName as validateChildName,
|
|
@@ -22,17 +28,39 @@ import {
|
|
|
22
28
|
startPendingEnvGcTimer,
|
|
23
29
|
startSweeperTimer,
|
|
24
30
|
auditCreateNode,
|
|
31
|
+
auditCreateNodeStrict,
|
|
25
32
|
resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
|
|
26
33
|
} from "./create-node.js";
|
|
34
|
+
import {
|
|
35
|
+
vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
|
|
36
|
+
VaultError,
|
|
37
|
+
} from "./vault.js";
|
|
38
|
+
import { stripHostLocalPathsForCrossHostSafe, validateAttachments } from "./uploads.js";
|
|
39
|
+
import {
|
|
40
|
+
validateBaseUrl as _validateBaseUrl,
|
|
41
|
+
SUPPORTED_VENDORS,
|
|
42
|
+
ProbeValidationError,
|
|
43
|
+
} from "./probe-validate.js";
|
|
44
|
+
import {
|
|
45
|
+
putPendingProbeSecret,
|
|
46
|
+
newProbeId,
|
|
47
|
+
finalizeProbeAck,
|
|
48
|
+
startPendingProbeGcTimer,
|
|
49
|
+
startProbeSweeperTimer,
|
|
50
|
+
} from "./probe.js";
|
|
27
51
|
import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias } from "./rename.js";
|
|
28
52
|
import {
|
|
29
53
|
ALLOWED_FLAGS,
|
|
30
54
|
SECURITY_SENSITIVE_FLAGS,
|
|
55
|
+
EDITABLE_CHANNELS,
|
|
31
56
|
computeApplyMode,
|
|
32
57
|
validatePatch,
|
|
58
|
+
narrowChannelsPatch,
|
|
33
59
|
isAllowedToChangeFlag,
|
|
34
60
|
} from "./config-apply-validate.js";
|
|
35
61
|
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
62
|
+
import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
|
|
63
|
+
import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
|
|
36
64
|
|
|
37
65
|
function ts(): string {
|
|
38
66
|
return new Date().toTimeString().slice(0, 8);
|
|
@@ -45,7 +73,55 @@ function parseMetaJson(value: unknown): unknown | null {
|
|
|
45
73
|
|
|
46
74
|
function normalizeMetaJson(meta: unknown): string | null {
|
|
47
75
|
if (!meta || typeof meta !== "object") return null;
|
|
48
|
-
|
|
76
|
+
// #222 cross-host safety — strip `path` from meta.attachments[] when
|
|
77
|
+
// `file_id` is also present. Shared helper lives in uploads.ts so the
|
|
78
|
+
// REST handler in index.ts can apply the identical sanitization
|
|
79
|
+
// (otherwise REST and MCP transports would have different meta_json
|
|
80
|
+
// shapes for the same send).
|
|
81
|
+
try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── #1281 — 子节点生命周期工具的参数名统一 ────────────────────────────
|
|
85
|
+
//
|
|
86
|
+
// stop_node / start_node / delete_node 历史上用 `child_node_id`(RFC-027),
|
|
87
|
+
// restart_node / update_node_config 用 `node_id`。同一个对象(子节点)两个名字,
|
|
88
|
+
// 调用方按一个工具的习惯给另一个传就吃 -32602。两侧都已是**已发布契约**
|
|
89
|
+
// (start_node 随 #1273 合入),且 hub API 在 RFC-030 混版滚动期老客户端还在
|
|
90
|
+
// 发老名——所以不能硬改名,改为**两个名字都接受**:
|
|
91
|
+
// · canonical = `node_id`(= DB 列 nodes.node_id、= create_node 的输出、
|
|
92
|
+
// restart/update 历史用名;「建完节点→再操作它」用同一个名才是傻瓜式)。
|
|
93
|
+
// · `child_node_id` 作为**兼容 alias 保留** + deprecation 注释;不加运行时
|
|
94
|
+
// warning(老客户端是设计内兼容,不是错误,别刷屏)。
|
|
95
|
+
// create_node 的 `daemon_node_id` 指宿主 daemon,是另一层语义,不在本次统一内。
|
|
96
|
+
//
|
|
97
|
+
// server.tool 收的是 ZodRawShape(裸对象),挂不了 .refine(),所以「至少一个
|
|
98
|
+
// 必填」+「两个都传须相等」在 handler 侧用 resolveNodeIdArg 兜——返回结构化
|
|
99
|
+
// ok:false 反而比 schema 层的 -32602 更友好。两个名字在 schema 层都做成
|
|
100
|
+
// optional + 宽松 .min(1).max(200)(不带 regex),使命名与校验强度同时统一:
|
|
101
|
+
// 对 restart/update 不变(本就宽松),对 stop/start/delete 是放宽(原 regex 会
|
|
102
|
+
// 在 schema 层挡掉 alias/杂串,放宽后落到查表返回 node_not_found,对合法调用
|
|
103
|
+
// 方无影响,只是错误形态更一致)。
|
|
104
|
+
const NODE_ID_ALIAS_FIELDS = {
|
|
105
|
+
node_id: z.string().min(1).max(200).optional()
|
|
106
|
+
.describe("Target child node ID (canonical, #1281). The value create_node returns. Must be in caller's network."),
|
|
107
|
+
child_node_id: z.string().min(1).max(200).optional()
|
|
108
|
+
.describe("DEPRECATED alias for node_id (#1281): kept for back-compat with pre-unification clients; prefer node_id."),
|
|
109
|
+
} as const;
|
|
110
|
+
|
|
111
|
+
/** #1281 — 把 node_id / child_node_id 收敛成单一内部 id。两个都传且不等 ⇒
|
|
112
|
+
* node_id_conflict(挡「node_id 填 A、child_node_id 填 B」的手滑,比单纯二选一强);
|
|
113
|
+
* 都不传 ⇒ node_id_required。纯函数,模块级导出以便单测直接验解析逻辑。 */
|
|
114
|
+
export function resolveNodeIdArg(a: { node_id?: string; child_node_id?: string }):
|
|
115
|
+
{ ok: true; node_id: string } | { ok: false; error: string; message: string } {
|
|
116
|
+
const n = a.node_id, c = a.child_node_id;
|
|
117
|
+
if (n && c && n !== c) {
|
|
118
|
+
return { ok: false, error: "node_id_conflict", message: "node_id and child_node_id both provided but differ; send only one (they are aliases for the same child node)" };
|
|
119
|
+
}
|
|
120
|
+
const id = n ?? c;
|
|
121
|
+
if (!id) {
|
|
122
|
+
return { ok: false, error: "node_id_required", message: "one of node_id / child_node_id is required (child_node_id is a deprecated alias; prefer node_id)" };
|
|
123
|
+
}
|
|
124
|
+
return { ok: true, node_id: id };
|
|
49
125
|
}
|
|
50
126
|
|
|
51
127
|
export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null) {
|
|
@@ -71,30 +147,213 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
71
147
|
}],
|
|
72
148
|
};
|
|
73
149
|
};
|
|
74
|
-
//
|
|
75
|
-
|
|
150
|
+
// MCP-side auth context in the shape network-scope.ts helpers take.
|
|
151
|
+
// null = legacy global-token / open-dev mode (unscoped, allow-all).
|
|
152
|
+
const mcpAuthCtx = enforceUserId ? { userId: enforceUserId, networkId: enforceNetworkId ?? null } : null;
|
|
153
|
+
|
|
154
|
+
// If enforceNetworkId is set (ntok_), override any client-supplied
|
|
155
|
+
// network_id. #517: for utok_ with no explicit network_id, fall back to
|
|
156
|
+
// the user's single membership via the SAME singleNetworkId used by REST
|
|
157
|
+
// POST /api/task (network-scope.ts) — utok_ rows carry network_id=null by
|
|
158
|
+
// design, so without this fallback single-network nodes could read
|
|
159
|
+
// everything but write nothing. Multi-network stays null (ambiguous) and
|
|
160
|
+
// canWrite rejects with network_id_required.
|
|
161
|
+
const getNetworkId = (clientNetId?: string | null) => {
|
|
162
|
+
const explicit = enforceNetworkId ?? clientNetId ?? null;
|
|
163
|
+
if (explicit !== null || !enforceUserId) return explicit;
|
|
164
|
+
return singleNetworkId({ networkId: null, networkIds: getUserNetworkIds(enforceUserId) });
|
|
165
|
+
};
|
|
76
166
|
|
|
77
|
-
// Check write access
|
|
78
|
-
//
|
|
79
|
-
//
|
|
167
|
+
// Check write access — delegates to the shared canRestWriteNetwork so MCP
|
|
168
|
+
// and REST cannot drift again (#517). isAdmin=false: MCP has no admin
|
|
169
|
+
// bypass today; keep behavior identical to before the extraction.
|
|
80
170
|
const canWrite = (effectiveNetworkId?: string | null): boolean => {
|
|
81
|
-
if (!enforceUserId) return true; // legacy global token mode, allow
|
|
82
171
|
const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
|
|
83
|
-
|
|
84
|
-
const role = getUserNetworkRole(enforceUserId, netId);
|
|
85
|
-
return !!role && role !== "viewer"; // owner/admin/member can write
|
|
172
|
+
return canRestWriteNetwork(mcpAuthCtx, netId, false);
|
|
86
173
|
};
|
|
87
174
|
|
|
175
|
+
// #517: name the REAL cause. The old catch-all blamed permissions for
|
|
176
|
+
// what was actually an unresolvable network, sending operators down the
|
|
177
|
+
// wrong debugging path (roles/membership) for hours.
|
|
88
178
|
const writeDeniedReply = (effectiveNetworkId?: string | null, action = "write") => {
|
|
89
179
|
const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
180
|
+
let error: string;
|
|
181
|
+
let message: string;
|
|
182
|
+
if (!netId) {
|
|
183
|
+
const memberships = enforceUserId ? getUserNetworkIds(enforceUserId) : [];
|
|
184
|
+
error = "network_id_required";
|
|
185
|
+
message = memberships.length === 0
|
|
186
|
+
? "user token has no network memberships; join or create a network first"
|
|
187
|
+
: `user token spans ${memberships.length} networks; pass network_id explicitly (see /api/auth/me networks[].network_id)`;
|
|
188
|
+
} else if (enforceUserId && !getUserNetworkRole(enforceUserId, netId)) {
|
|
189
|
+
error = "access_denied";
|
|
190
|
+
message = "access denied to requested network (not a member)";
|
|
191
|
+
} else {
|
|
192
|
+
error = "permission_denied";
|
|
193
|
+
message = action === "send_task"
|
|
93
194
|
? "Viewer role cannot send tasks"
|
|
94
195
|
: "Viewer role cannot write to this network";
|
|
95
|
-
|
|
196
|
+
}
|
|
197
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error, message }) }] };
|
|
96
198
|
};
|
|
97
199
|
|
|
200
|
+
const skillHubReply = (value: Record<string, unknown>) => ({
|
|
201
|
+
content: [{ type: "text" as const, text: JSON.stringify(value) }],
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// SkillHub is a network registry, not a public filesystem. Content is an
|
|
205
|
+
// immutable SKILL.md snapshot; identity comes only from the authenticated
|
|
206
|
+
// MCP principal (never from request fields).
|
|
207
|
+
server.tool(
|
|
208
|
+
"submit_skill",
|
|
209
|
+
"Submit an immutable SKILL.md version to the caller's network SkillHub. Node identity is token-bound. New submissions require review.",
|
|
210
|
+
{
|
|
211
|
+
slug: z.string().min(2).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
212
|
+
name: z.string().min(1).max(120),
|
|
213
|
+
description: z.string().max(1000).optional(),
|
|
214
|
+
version: z.string().min(1).max(40).regex(/^[0-9A-Za-z]+(?:[._-][0-9A-Za-z]+)*$/),
|
|
215
|
+
content: z.string().min(1).max(128 * 1024).refine(value => !value.includes("\0"), "content must not contain NUL bytes"),
|
|
216
|
+
network_id: z.string().max(200).optional(),
|
|
217
|
+
},
|
|
218
|
+
async ({ slug, name, description, version, content, network_id: clientNetId }) => {
|
|
219
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
220
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
221
|
+
if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
|
|
222
|
+
const sourceType = callerTokenIsNetwork ? "node" : "user";
|
|
223
|
+
if (sourceType === "node" && !callerAlias) return skillHubReply({ ok: false, error: "node_identity_unbound" });
|
|
224
|
+
const contentHash = createHash("sha256").update(content, "utf8").digest("hex");
|
|
225
|
+
const existing = db.get<any>(
|
|
226
|
+
`SELECT skill_id, content_hash, status FROM skillhub_skills
|
|
227
|
+
WHERE network_id = ?1 AND slug = ?2 AND version = ?3`,
|
|
228
|
+
effectiveNetId, slug, version,
|
|
229
|
+
);
|
|
230
|
+
if (existing) {
|
|
231
|
+
if (existing.content_hash === contentHash) {
|
|
232
|
+
return skillHubReply({ ok: true, idempotent: true, skill_id: existing.skill_id, status: existing.status });
|
|
233
|
+
}
|
|
234
|
+
return skillHubReply({ ok: false, error: "skill_version_conflict", hint: "publish changed content under a new version" });
|
|
235
|
+
}
|
|
236
|
+
const skillId = `skill_${uuidv4()}`;
|
|
237
|
+
try {
|
|
238
|
+
db.run(
|
|
239
|
+
`INSERT INTO skillhub_skills
|
|
240
|
+
(skill_id, network_id, slug, name, description, version, content, content_hash, status, source_type, source_alias, created_by_user)
|
|
241
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
|
|
242
|
+
[skillId, effectiveNetId, slug, name.trim(), description?.trim() || "", version, content, contentHash, sourceType, callerAlias, enforceUserId || null],
|
|
243
|
+
);
|
|
244
|
+
} catch (error: any) {
|
|
245
|
+
// Two submitters may pass the preflight SELECT together. Resolve the
|
|
246
|
+
// UNIQUE race into the same deterministic idempotent/conflict contract
|
|
247
|
+
// instead of leaking a storage exception as HTTP/MCP 500.
|
|
248
|
+
if (!/unique|duplicate key/i.test(error?.message || "")) throw error;
|
|
249
|
+
const winner = db.get<any>(
|
|
250
|
+
`SELECT skill_id, content_hash, status FROM skillhub_skills
|
|
251
|
+
WHERE network_id = ?1 AND slug = ?2 AND version = ?3`,
|
|
252
|
+
effectiveNetId, slug, version,
|
|
253
|
+
);
|
|
254
|
+
if (!winner) throw error;
|
|
255
|
+
if (winner.content_hash === contentHash) {
|
|
256
|
+
return skillHubReply({ ok: true, idempotent: true, skill_id: winner.skill_id, status: winner.status });
|
|
257
|
+
}
|
|
258
|
+
return skillHubReply({ ok: false, error: "skill_version_conflict", hint: "publish changed content under a new version" });
|
|
259
|
+
}
|
|
260
|
+
db.run(
|
|
261
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
262
|
+
VALUES (?1, ?2, 'skill_submit', 'skill', ?3, ?4, ?5)`,
|
|
263
|
+
[enforceUserId || null, callerAlias || null, skillId, JSON.stringify({ slug, version, source_type: sourceType }), effectiveNetId],
|
|
264
|
+
);
|
|
265
|
+
return skillHubReply({ ok: true, skill_id: skillId, status: "pending", source_type: sourceType, source_alias: callerAlias });
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
server.tool(
|
|
270
|
+
"list_skills",
|
|
271
|
+
"List published skills in a network. Owners/admins may include pending review items.",
|
|
272
|
+
{
|
|
273
|
+
network_id: z.string().max(200).optional(),
|
|
274
|
+
include_pending: z.boolean().optional(),
|
|
275
|
+
query: z.string().max(120).optional(),
|
|
276
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
277
|
+
},
|
|
278
|
+
async ({ network_id: clientNetId, include_pending, query, limit }) => {
|
|
279
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
280
|
+
if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "read");
|
|
281
|
+
const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
|
|
282
|
+
if (enforceUserId && !role) return writeDeniedReply(effectiveNetId, "read");
|
|
283
|
+
// An ntok_ belongs to a node even when it was minted by the network
|
|
284
|
+
// owner. Never inherit the owner's review power through that token.
|
|
285
|
+
const reviewer = !callerTokenIsNetwork && (role === "owner" || role === "admin");
|
|
286
|
+
const showPending = !!include_pending && reviewer;
|
|
287
|
+
const params: unknown[] = [effectiveNetId];
|
|
288
|
+
let sql = `SELECT skill_id, slug, name, description, version, status, source_type, source_alias,
|
|
289
|
+
created_at, updated_at, reviewed_at, review_note
|
|
290
|
+
FROM skillhub_skills WHERE network_id = ?1`;
|
|
291
|
+
if (!showPending) sql += ` AND status = 'published'`;
|
|
292
|
+
if (query?.trim()) {
|
|
293
|
+
params.push(`%${query.trim()}%`);
|
|
294
|
+
sql += ` AND (slug LIKE ?${params.length} OR name LIKE ?${params.length} OR description LIKE ?${params.length})`;
|
|
295
|
+
}
|
|
296
|
+
sql += ` ORDER BY updated_at DESC LIMIT ${limit ?? 100}`;
|
|
297
|
+
return skillHubReply({ ok: true, reviewer, skills: db.all(sql, ...params) });
|
|
298
|
+
},
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
server.tool(
|
|
302
|
+
"get_skill",
|
|
303
|
+
"Read one SkillHub SKILL.md. Pending content is visible only to owners/admins.",
|
|
304
|
+
{ skill_id: z.string().regex(/^skill_[A-Za-z0-9_-]+$/).max(200), network_id: z.string().max(200).optional() },
|
|
305
|
+
async ({ skill_id, network_id: clientNetId }) => {
|
|
306
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
307
|
+
if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "read");
|
|
308
|
+
const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
|
|
309
|
+
if (enforceUserId && !role) return writeDeniedReply(effectiveNetId, "read");
|
|
310
|
+
const row = db.get<any>(
|
|
311
|
+
`SELECT skill_id, slug, name, description, version, content, status,
|
|
312
|
+
source_type, source_alias, created_at, updated_at, reviewed_at, review_note
|
|
313
|
+
FROM skillhub_skills WHERE skill_id = ?1 AND network_id = ?2`,
|
|
314
|
+
skill_id, effectiveNetId,
|
|
315
|
+
);
|
|
316
|
+
if (!row) return skillHubReply({ ok: false, error: "skill_not_found" });
|
|
317
|
+
const reviewer = !callerTokenIsNetwork && (role === "owner" || role === "admin");
|
|
318
|
+
if (row.status !== "published" && !reviewer) {
|
|
319
|
+
return skillHubReply({ ok: false, error: "skill_not_found" });
|
|
320
|
+
}
|
|
321
|
+
return skillHubReply({ ok: true, skill: row });
|
|
322
|
+
},
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
server.tool(
|
|
326
|
+
"review_skill",
|
|
327
|
+
"Publish or reject a pending SkillHub submission. Network owner/admin only.",
|
|
328
|
+
{
|
|
329
|
+
skill_id: z.string().regex(/^skill_[A-Za-z0-9_-]+$/).max(200),
|
|
330
|
+
decision: z.enum(["published", "rejected"]),
|
|
331
|
+
note: z.string().max(1000).optional(),
|
|
332
|
+
network_id: z.string().max(200).optional(),
|
|
333
|
+
},
|
|
334
|
+
async ({ skill_id, decision, note, network_id: clientNetId }) => {
|
|
335
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
336
|
+
if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
|
|
337
|
+
const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
|
|
338
|
+
if (callerTokenIsNetwork || (role !== "owner" && role !== "admin")) return skillHubReply({ ok: false, error: "skill_review_admin_required" });
|
|
339
|
+
const row = db.get<any>(`SELECT status FROM skillhub_skills WHERE skill_id = ?1 AND network_id = ?2`, skill_id, effectiveNetId);
|
|
340
|
+
if (!row) return skillHubReply({ ok: false, error: "skill_not_found" });
|
|
341
|
+
if (row.status !== "pending") return skillHubReply({ ok: false, error: "skill_not_pending", status: row.status });
|
|
342
|
+
const updated = db.run(
|
|
343
|
+
`UPDATE skillhub_skills SET status = ?1, review_note = ?2, reviewed_by_user = ?3,
|
|
344
|
+
reviewed_at = datetime('now'), updated_at = datetime('now') WHERE skill_id = ?4 AND network_id = ?5 AND status = 'pending'`,
|
|
345
|
+
[decision, note?.trim() || null, enforceUserId || null, skill_id, effectiveNetId],
|
|
346
|
+
);
|
|
347
|
+
if (updated.changes !== 1) return skillHubReply({ ok: false, error: "skill_not_pending" });
|
|
348
|
+
db.run(
|
|
349
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
350
|
+
VALUES (?1, ?2, 'skill_review', 'skill', ?3, ?4, ?5)`,
|
|
351
|
+
[enforceUserId || null, callerAlias || null, skill_id, JSON.stringify({ decision }), effectiveNetId],
|
|
352
|
+
);
|
|
353
|
+
return skillHubReply({ ok: true, skill_id, status: decision });
|
|
354
|
+
},
|
|
355
|
+
);
|
|
356
|
+
|
|
98
357
|
const addScope = (sql: string, params: any[], networkId?: string | null, column = "network_id"): string => {
|
|
99
358
|
if (!networkId) return sql;
|
|
100
359
|
sql += ` AND ${column} = ?${params.length + 1}`;
|
|
@@ -104,13 +363,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
104
363
|
|
|
105
364
|
type ReadScope = { networkId?: string | null; networkIds?: string[] | null; denied?: string };
|
|
106
365
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
enforceUserId
|
|
112
|
-
).map((row) => row.network_id);
|
|
113
|
-
};
|
|
366
|
+
// Delegates to the shared membership query (network-scope.ts) — was a
|
|
367
|
+
// byte-for-byte duplicate of getUserNetworkIds before #517.
|
|
368
|
+
const getReadableNetworkIds = (): string[] =>
|
|
369
|
+
enforceUserId ? getUserNetworkIds(enforceUserId) : [];
|
|
114
370
|
|
|
115
371
|
const resolveReadScope = (clientNetId?: string | null): ReadScope => {
|
|
116
372
|
if (!enforceUserId) return { networkId: clientNetId ?? null, networkIds: null };
|
|
@@ -125,6 +381,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
125
381
|
return { networkId: null, networkIds: getReadableNetworkIds() };
|
|
126
382
|
};
|
|
127
383
|
|
|
384
|
+
// RFC-027 §2.3 race-free invariant — assertNodeActive lives in
|
|
385
|
+
// server/src/lifecycle-guard.ts so REST handlers in server/src/index.ts
|
|
386
|
+
// can use the SAME code path. PR1.1 had it inline here; PR1.2a
|
|
387
|
+
// (#346 review catch) extracted because the closure scope made it
|
|
388
|
+
// unreachable from REST and left the §2.3 race open on dashboard
|
|
389
|
+
// Dispatch (POST /api/task + /api/broadcast). Per
|
|
390
|
+
// 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.
|
|
391
|
+
|
|
128
392
|
const addReadScope = (sql: string, params: any[], scope: ReadScope, column = "network_id"): string => {
|
|
129
393
|
if (scope.networkId) {
|
|
130
394
|
sql += ` AND ${column} = ?${params.length + 1}`;
|
|
@@ -145,6 +409,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
145
409
|
| { state: "offline"; alias: string; session: any; message: string }
|
|
146
410
|
| { state: "not_found"; alias: string; message: string };
|
|
147
411
|
|
|
412
|
+
// Only call after the authenticated, network-scoped lookup resolved a
|
|
413
|
+
// concrete target. Error paths intentionally omit this identity object.
|
|
414
|
+
const actualTo = (target: Exclude<DeliveryTarget, { state: "not_found" }>, networkId?: string | null) => ({
|
|
415
|
+
alias: target.alias,
|
|
416
|
+
to_node_id: target.session?.node_id ?? null,
|
|
417
|
+
network_id: networkId ?? null,
|
|
418
|
+
});
|
|
419
|
+
|
|
148
420
|
const scopedSessionStatus = (alias: string, networkId?: string | null) => {
|
|
149
421
|
const params: any[] = [alias];
|
|
150
422
|
let sql = "SELECT status, updated_at, last_seen_at, node_id FROM sessions WHERE alias = ?1";
|
|
@@ -265,6 +537,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
265
537
|
uptime_seconds: z.number().nullable().optional(),
|
|
266
538
|
in_flight_count: z.number().nullable().optional(),
|
|
267
539
|
}).optional().describe("Per-agent process telemetry reported by agent-node"),
|
|
540
|
+
external_schedules: z.object({
|
|
541
|
+
observed_at: z.string().datetime({ offset: true }).max(64),
|
|
542
|
+
schedules: z.array(z.object({
|
|
543
|
+
id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/),
|
|
544
|
+
name: z.string().min(1).max(200),
|
|
545
|
+
kind: z.enum(["cron", "systemd", "tmux", "playwright", "custom"]),
|
|
546
|
+
frequency: z.string().min(1).max(120),
|
|
547
|
+
last_run_at: z.string().datetime({ offset: true }).max(64).nullable(),
|
|
548
|
+
last_status: z.enum(["success", "failed", "running", "unknown"]),
|
|
549
|
+
last_error: z.string().max(500).nullable(),
|
|
550
|
+
next_run_at: z.string().datetime({ offset: true }).max(64).nullable(),
|
|
551
|
+
log_ref: z.string().min(1).max(255).nullable(),
|
|
552
|
+
enabled: z.boolean(),
|
|
553
|
+
// RFC-036 — only agent-node managed cron markers advertise write
|
|
554
|
+
// capability. Legacy/other kinds omit these fields and stay read-only.
|
|
555
|
+
editable: z.boolean().optional(),
|
|
556
|
+
revision: z.number().int().min(0).optional(),
|
|
557
|
+
}).strict()).max(64),
|
|
558
|
+
error: z.enum(["invalid_manifest", "unsafe_manifest", "read_failed"]).optional(),
|
|
559
|
+
}).strict().optional().describe("Bounded node-reported external schedule snapshot; never includes host paths or commands"),
|
|
268
560
|
// RFC-024 B6 — masked snapshot of the node's effective config
|
|
269
561
|
// (model + 6 dashboard-editable flags). Secrets ARE NOT in this
|
|
270
562
|
// shape (env._envRef stays on host); the dashboard reads this
|
|
@@ -278,9 +570,68 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
278
570
|
flags: z.record(z.unknown()).optional(),
|
|
279
571
|
config_revision: z.number().int().min(0).optional(),
|
|
280
572
|
config_update_capable: z.boolean().optional(),
|
|
573
|
+
peer_reply_inbox_capable: z.literal(true).optional(),
|
|
574
|
+
side_thread_capability: z.object({
|
|
575
|
+
supported: z.boolean(),
|
|
576
|
+
runtime: z.string().max(64),
|
|
577
|
+
runtimeVersion: z.string().max(64),
|
|
578
|
+
topology: z.string().max(64),
|
|
579
|
+
evidenceRevision: z.string().max(100),
|
|
580
|
+
mode: z.literal("native-exact-fork").optional(),
|
|
581
|
+
exactBoundary: z.object({ through: z.boolean(), before: z.boolean() }).strict().optional(),
|
|
582
|
+
reason: z.enum(["runtime", "version", "topology", "experimental-api", "exact-boundary"]).optional(),
|
|
583
|
+
}).strict().optional(),
|
|
584
|
+
// RFC-026 P2 / #338 — daemon role surfaced for hub /api/nodes
|
|
585
|
+
// discovery (#337 extracts this field). "host_supervisor" =
|
|
586
|
+
// anet daemon. Default-stripping zod would drop this otherwise.
|
|
587
|
+
role: z.string().max(64).optional().nullable(),
|
|
588
|
+
// RFC-026 §9.3 / #338 PR3 — daemon self-declare nested under
|
|
589
|
+
// `daemon_capabilities` (canonical shape per existing hub reads
|
|
590
|
+
// at tools.ts:2010/2075 — PR1/PR2 placed these top-level, hub
|
|
591
|
+
// never saw them, max_concurrent_children stayed default + the
|
|
592
|
+
// allowlists stayed unenforced. PR3 nit ① per 通信龙).
|
|
593
|
+
// Soft caps avoid abuse via attacker daemon.
|
|
594
|
+
daemon_capabilities: z.object({
|
|
595
|
+
runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
|
|
596
|
+
allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
|
|
597
|
+
max_concurrent_children: z.number().int().min(1).max(1000).optional(),
|
|
598
|
+
// #1353 —— daemon **当下**能不能创建节点。与 runtimes_supported 不是一回事:
|
|
599
|
+
// 后者是声明(我支持哪些 runtime),这个是实际能力。一个 daemon 可以声明支持
|
|
600
|
+
// 三种 runtime,同时因为 ANET_BIN pin 解析不出来而一种也创建不了。
|
|
601
|
+
//
|
|
602
|
+
// 🔴 为什么 hub 需要看见它:pin 只存在于 /etc/anet-daemon/path.conf 或
|
|
603
|
+
// 显式开启的环境变量里,**重启不带环境就会丢**。丢了之后 daemon 照常注册、
|
|
604
|
+
// 在线、收 doorbell,hub 返回 ok:true + request_id —— 而节点永远不出现。
|
|
605
|
+
// 在此之前 hub 完全看不出区别,Dashboard 的「选服务器」还会把它列为可选。
|
|
606
|
+
can_create_nodes: z.boolean().optional(),
|
|
607
|
+
// 只收**代码**,不收原始报错文本 —— 上游 unsafePathHelp() 的消息里带完整机器路径,
|
|
608
|
+
// 而这个字段会走到 Dashboard。enum 而不是 string:一个自由文本字段等于给
|
|
609
|
+
// 「把路径塞进来」留了口子。
|
|
610
|
+
// #1353 —— 四类失败,修法完全不同:
|
|
611
|
+
// identity 重装 anet / unset ANET_BIN_ABS
|
|
612
|
+
// source 写 /etc/anet-daemon/path.conf(**要 sudo**)
|
|
613
|
+
// shape 换成 realpath
|
|
614
|
+
// permission **一行 chmod go-w**
|
|
615
|
+
// 混成一条会让人修错方向 —— 2026-08-28 我一天里撞到其中两类,看到的是同一条错误。
|
|
616
|
+
//
|
|
617
|
+
// 🔴 `anet_bin_pin_unresolved` **必须留着**:已经在跑的 agent-node@2.5.0-preview.40
|
|
618
|
+
// 发的就是这个值。zod 对象里任何一个字段验证失败会让**整个 report_status 被拒**,
|
|
619
|
+
// 不只是丢掉这一格 —— 那会让所有 .40 daemon 在 hub 上变成失联。
|
|
620
|
+
// 删一个 enum 值的代价,远大于多留一个。
|
|
621
|
+
// 🔴 仍然是 enum 不是 string:自由文本字段就是给机器路径留的口子,
|
|
622
|
+
// 而上游 e.message 里带的正是完整路径。
|
|
623
|
+
create_nodes_blocked_reason: z.enum([
|
|
624
|
+
"anet_bin_pin_unresolved", // 兼容:.40 及更早
|
|
625
|
+
"anet_bin_identity",
|
|
626
|
+
"anet_bin_source",
|
|
627
|
+
"anet_bin_shape",
|
|
628
|
+
"anet_bin_permission",
|
|
629
|
+
"anet_bin_unknown",
|
|
630
|
+
]).optional(),
|
|
631
|
+
}).optional(),
|
|
281
632
|
}).optional().describe("RFC-024 — masked node config snapshot"),
|
|
282
633
|
},
|
|
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 }) => {
|
|
634
|
+
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
635
|
const effectiveNetId = getNetworkId(netId);
|
|
285
636
|
const sessionNetId = effectiveNetId ?? "default";
|
|
286
637
|
if (!callerTokenIsNetwork || !enforceNetworkId) {
|
|
@@ -318,6 +669,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
318
669
|
};
|
|
319
670
|
}
|
|
320
671
|
}
|
|
672
|
+
// #203 identity guard — network tokens must not silently rebind their
|
|
673
|
+
// own name via report_status. Without this, a runtime whose ALIAS
|
|
674
|
+
// drifted (env leak / wrong --alias / CurrentAliasResolver seeded from
|
|
675
|
+
// the wrong node_id) could rewrite api_tokens.name and cause every
|
|
676
|
+
// subsequent send_task from this token to be attributed to the drifted
|
|
677
|
+
// alias — the observed #203 symptom (grokB's send arriving as
|
|
678
|
+
// from=grokA). Only the legit rename path (rename.ts) may cross the
|
|
679
|
+
// token→alias binding. Symmetric to fromIdentityMismatchReply on the
|
|
680
|
+
// send side (test198).
|
|
681
|
+
if (callerTokenIsNetwork && callerAlias && !canonical.renamed && effectiveAlias !== callerAlias) {
|
|
682
|
+
return {
|
|
683
|
+
content: [{
|
|
684
|
+
type: "text" as const,
|
|
685
|
+
text: JSON.stringify({
|
|
686
|
+
ok: false,
|
|
687
|
+
error: "alias_identity_mismatch",
|
|
688
|
+
message: "report_status alias does not match the token-bound node alias; use anet node rename to change identity",
|
|
689
|
+
token_alias: callerAlias,
|
|
690
|
+
reported_alias: effectiveAlias,
|
|
691
|
+
}),
|
|
692
|
+
}],
|
|
693
|
+
};
|
|
694
|
+
}
|
|
321
695
|
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
696
|
if (callerTokenIsNetwork && callerTokenId) {
|
|
323
697
|
try {
|
|
@@ -342,6 +716,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
342
716
|
const processCpuPct = typeof proc?.cpu_pct === "number" ? proc.cpu_pct : null;
|
|
343
717
|
const processUptimeSeconds = typeof proc?.uptime_seconds === "number" ? proc.uptime_seconds : null;
|
|
344
718
|
const processInFlightCount = typeof proc?.in_flight_count === "number" ? proc.in_flight_count : null;
|
|
719
|
+
const externalSchedulesJson = externalSchedules === undefined ? null : JSON.stringify(externalSchedules);
|
|
720
|
+
const trustedCurrentSnapshot = node_id
|
|
721
|
+
? trustedConfigSnapshotForNode(cfgSnap ?? null, callerTokenId ?? null, node_id)
|
|
722
|
+
: null;
|
|
723
|
+
const peerReplyInboxCapable = !!(
|
|
724
|
+
trustedCurrentSnapshot
|
|
725
|
+
&& typeof trustedCurrentSnapshot === "object"
|
|
726
|
+
&& (trustedCurrentSnapshot as Record<string, unknown>).peer_reply_inbox_capable === true
|
|
727
|
+
);
|
|
345
728
|
const statusHostTelemetry = host ? {
|
|
346
729
|
hostname: hostHostname,
|
|
347
730
|
ip: hostIp,
|
|
@@ -366,8 +749,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
366
749
|
// Only delete same-alias sessions within the same network
|
|
367
750
|
db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [effectiveAlias, resume_id, sessionNetId]);
|
|
368
751
|
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'))
|
|
752
|
+
`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)
|
|
753
|
+
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
754
|
ON CONFLICT(resume_id) DO UPDATE SET
|
|
372
755
|
alias = COALESCE(?2, sessions.alias), tmux_name = COALESCE(?3, sessions.tmux_name),
|
|
373
756
|
server = COALESCE(?4, sessions.server), ip = COALESCE(?5, sessions.ip),
|
|
@@ -392,8 +775,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
392
775
|
process_cpu_pct = COALESCE(?31, sessions.process_cpu_pct),
|
|
393
776
|
process_uptime_seconds = COALESCE(?32, sessions.process_uptime_seconds),
|
|
394
777
|
process_in_flight_count = COALESCE(?33, sessions.process_in_flight_count),
|
|
778
|
+
external_schedules = COALESCE(?34, sessions.external_schedules),
|
|
779
|
+
peer_reply_inbox_capable = ?35,
|
|
395
780
|
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]
|
|
781
|
+
[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
782
|
);
|
|
398
783
|
if (host || proc) {
|
|
399
784
|
db.run(
|
|
@@ -443,6 +828,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
443
828
|
upsertNodeWithSec1Guard({
|
|
444
829
|
node_id,
|
|
445
830
|
callerNetworkId: effectiveNetId ?? null,
|
|
831
|
+
callerUserId: enforceUserId ?? null,
|
|
832
|
+
callerTokenId: callerTokenId ?? null,
|
|
446
833
|
node_name: nn || effectiveAlias,
|
|
447
834
|
alias: effectiveAlias,
|
|
448
835
|
runtime: nodeRuntime,
|
|
@@ -534,6 +921,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
534
921
|
} else {
|
|
535
922
|
updatedTaskId = task;
|
|
536
923
|
}
|
|
924
|
+
if (updatedTaskId) syncScheduledRunForTask(updatedTaskId, effectiveNetId);
|
|
537
925
|
});
|
|
538
926
|
// Log event after transaction
|
|
539
927
|
if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
|
|
@@ -594,7 +982,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
594
982
|
const rows0 = db.get<{ cnt: number }>(countSql, ...countParams);
|
|
595
983
|
console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
|
|
596
984
|
const rowsParams: any[] = [alias];
|
|
597
|
-
let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json
|
|
985
|
+
let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json,
|
|
986
|
+
CASE WHEN type = 'task' THEN COALESCE(task_id, id) ELSE task_id END AS task_id
|
|
598
987
|
FROM inbox WHERE session_name = ?1 AND acked = 0`;
|
|
599
988
|
rowsSql = addReadScope(rowsSql, rowsParams, readScope);
|
|
600
989
|
rowsSql += ` ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
|
|
@@ -618,13 +1007,32 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
618
1007
|
alias: z.string().min(1).max(200).describe("Session alias"),
|
|
619
1008
|
message_id: z.string().min(1).max(200),
|
|
620
1009
|
response: z.string().max(10000).optional(),
|
|
1010
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
621
1011
|
},
|
|
622
|
-
async ({ alias, message_id, response }) => {
|
|
623
|
-
const effectiveNetId = getNetworkId(
|
|
1012
|
+
async ({ alias, message_id, response, network_id: netId }) => {
|
|
1013
|
+
const effectiveNetId = getNetworkId(netId);
|
|
624
1014
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
625
1015
|
console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
|
|
626
|
-
|
|
627
|
-
|
|
1016
|
+
// New task consumers ACK by the logical tasks.task_id exposed by
|
|
1017
|
+
// get_inbox. Keep exact inbox.id support for legacy consumers and for
|
|
1018
|
+
// non-task messages. Restrict the lookup to pending rows: after a retry
|
|
1019
|
+
// the original row can have id == task_id but is already ACKed, while
|
|
1020
|
+
// the current row has a fresh transport id and the same logical task_id.
|
|
1021
|
+
const inboxTaskParams: any[] = [message_id, alias];
|
|
1022
|
+
let inboxTaskSql = `SELECT id, type, COALESCE(task_id, id) AS task_id
|
|
1023
|
+
FROM inbox
|
|
1024
|
+
WHERE session_name = ?2 AND acked = 0
|
|
1025
|
+
AND (id = ?1 OR (type = 'task' AND task_id = ?1))`;
|
|
1026
|
+
inboxTaskSql = addScope(inboxTaskSql, inboxTaskParams, effectiveNetId);
|
|
1027
|
+
inboxTaskSql += " ORDER BY created_at DESC LIMIT 1";
|
|
1028
|
+
const inboxTask = db.get<{ id: string; type: string; task_id: string }>(inboxTaskSql, ...inboxTaskParams);
|
|
1029
|
+
if (!inboxTask) {
|
|
1030
|
+
return {
|
|
1031
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "message not found or already acknowledged" }) }],
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
const ackParams: any[] = [inboxTask.id, alias];
|
|
1035
|
+
let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2 AND acked = 0";
|
|
628
1036
|
ackSql = addScope(ackSql, ackParams, effectiveNetId);
|
|
629
1037
|
const result = db.run(ackSql, ackParams);
|
|
630
1038
|
if (result.changes === 0) {
|
|
@@ -634,11 +1042,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
634
1042
|
}
|
|
635
1043
|
// V2: sync tasks table — ack_inbox means delivered→acked
|
|
636
1044
|
try {
|
|
637
|
-
|
|
1045
|
+
if (inboxTask?.type !== "task") {
|
|
1046
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }] };
|
|
1047
|
+
}
|
|
1048
|
+
const taskParams: any[] = [inboxTask.task_id];
|
|
638
1049
|
let taskSql = "UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'";
|
|
639
1050
|
taskSql = addScope(taskSql, taskParams, effectiveNetId);
|
|
640
1051
|
const ackResult = db.run(taskSql, taskParams);
|
|
641
|
-
if (ackResult.changes > 0) logTaskEvent(
|
|
1052
|
+
if (ackResult.changes > 0) logTaskEvent(inboxTask.task_id, "delivered", "acked", alias);
|
|
642
1053
|
} catch {}
|
|
643
1054
|
return {
|
|
644
1055
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }],
|
|
@@ -646,22 +1057,208 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
646
1057
|
}
|
|
647
1058
|
);
|
|
648
1059
|
|
|
1060
|
+
// #520 — two monotonic runtime-evidence levels for exact tasks.
|
|
1061
|
+
//
|
|
1062
|
+
// runtime_submitted_at means agent-node handed the body to the vendor
|
|
1063
|
+
// runtime. consumed_at is stronger: an attributable turn-start or first
|
|
1064
|
+
// activity event came back. Merely fetching/acking an inbox row sets neither.
|
|
1065
|
+
// Node identity comes exclusively from the ntok; callers cannot self-report
|
|
1066
|
+
// an alias or node_id. A consumed mark also fills runtime_submitted_at because
|
|
1067
|
+
// that stronger fact logically implies submission.
|
|
1068
|
+
const markTaskRuntimeEvidence = (
|
|
1069
|
+
taskIds: string[],
|
|
1070
|
+
level: "submitted" | "consumed",
|
|
1071
|
+
taskContexts: Array<{ task_id: string; thread_id: string; turn_id: string }> = [],
|
|
1072
|
+
) => {
|
|
1073
|
+
const task_ids = taskIds;
|
|
1074
|
+
if (!callerTokenIsNetwork || !callerAlias || !enforceNetworkId) {
|
|
1075
|
+
return {
|
|
1076
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_required" }) }],
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
// PgAdapter.transaction currently opens a fresh subprocess/connection
|
|
1080
|
+
// per statement. The all-or-nothing batch promise cannot be made there;
|
|
1081
|
+
// refuse evidence rather than publish a partially stamped batch.
|
|
1082
|
+
if (db.dialect !== "sqlite") {
|
|
1083
|
+
return {
|
|
1084
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
1085
|
+
ok: false,
|
|
1086
|
+
error: "task_runtime_evidence_backend_unsupported",
|
|
1087
|
+
}) }],
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const uniqueTaskIds = [...new Set(task_ids)];
|
|
1092
|
+
if (uniqueTaskIds.length !== task_ids.length) {
|
|
1093
|
+
return {
|
|
1094
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_id" }) }],
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
if (level !== "consumed" && taskContexts.length > 0) {
|
|
1099
|
+
return {
|
|
1100
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "runtime_context_requires_consumed" }) }],
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
const contextByTaskId = new Map(taskContexts.map((context) => [context.task_id, context]));
|
|
1104
|
+
if (contextByTaskId.size !== taskContexts.length) {
|
|
1105
|
+
return {
|
|
1106
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_context" }) }],
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
const foreignContext = taskContexts.find((context) => !uniqueTaskIds.includes(context.task_id));
|
|
1110
|
+
if (foreignContext) {
|
|
1111
|
+
return {
|
|
1112
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task_context_not_requested", task_id: foreignContext.task_id }) }],
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const placeholders = uniqueTaskIds.map((_, i) => `?${i + 2}`).join(", ");
|
|
1117
|
+
|
|
1118
|
+
// Keep ownership preflight and every stamp in one SQLite transaction.
|
|
1119
|
+
// This closes the cross-process preflight→UPDATE race: no other writer
|
|
1120
|
+
// can reassign a task between the identity decision and the write.
|
|
1121
|
+
const outcome = db.transaction(() => {
|
|
1122
|
+
const canonicalCaller = resolveCanonicalAlias(enforceNetworkId, callerAlias).alias;
|
|
1123
|
+
const callerSession = db.get<{ node_id: string | null }>(
|
|
1124
|
+
`SELECT node_id FROM sessions
|
|
1125
|
+
WHERE network_id = ?1 AND alias = ?2
|
|
1126
|
+
ORDER BY updated_at DESC LIMIT 1`,
|
|
1127
|
+
enforceNetworkId,
|
|
1128
|
+
canonicalCaller,
|
|
1129
|
+
);
|
|
1130
|
+
const rows = db.all<{ task_id: string; to_node_id: string | null; to_name: string; thread_id: string | null; turn_id: string | null }>(
|
|
1131
|
+
`SELECT task_id, to_node_id, to_name, thread_id, turn_id FROM tasks
|
|
1132
|
+
WHERE network_id = ?1 AND task_id IN (${placeholders})`,
|
|
1133
|
+
enforceNetworkId,
|
|
1134
|
+
...uniqueTaskIds,
|
|
1135
|
+
);
|
|
1136
|
+
const owned = new Map(rows.map((row) => [row.task_id, row]));
|
|
1137
|
+
const rejectedTaskId = uniqueTaskIds.find((taskId) => {
|
|
1138
|
+
const row = owned.get(taskId);
|
|
1139
|
+
if (!row) return true;
|
|
1140
|
+
// Prefer immutable node identity whenever the task has one. Direct
|
|
1141
|
+
// and legacy token-bound sessions legitimately have NULL node_id;
|
|
1142
|
+
// only that shape may fall back to the canonical token alias.
|
|
1143
|
+
if (row.to_node_id) return row.to_node_id !== callerSession?.node_id;
|
|
1144
|
+
return resolveCanonicalAlias(enforceNetworkId, row.to_name).alias !== canonicalCaller;
|
|
1145
|
+
});
|
|
1146
|
+
if (rejectedTaskId) {
|
|
1147
|
+
return { ok: false as const, error: "task_not_owned", task_id: rejectedTaskId };
|
|
1148
|
+
}
|
|
1149
|
+
const conflictingContext = uniqueTaskIds.find((taskId) => {
|
|
1150
|
+
const context = contextByTaskId.get(taskId);
|
|
1151
|
+
if (!context) return false;
|
|
1152
|
+
const row = owned.get(taskId)!;
|
|
1153
|
+
return (row.thread_id !== null && row.thread_id !== context.thread_id)
|
|
1154
|
+
|| (row.turn_id !== null && row.turn_id !== context.turn_id);
|
|
1155
|
+
});
|
|
1156
|
+
if (conflictingContext) {
|
|
1157
|
+
return { ok: false as const, error: "task_runtime_context_conflict", task_id: conflictingContext };
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
for (const taskId of uniqueTaskIds) {
|
|
1161
|
+
const row = owned.get(taskId)!;
|
|
1162
|
+
const ownershipSql = row.to_node_id
|
|
1163
|
+
? "to_node_id = ?3"
|
|
1164
|
+
: "to_node_id IS NULL AND to_name = ?3";
|
|
1165
|
+
const ownerValue = row.to_node_id ?? row.to_name;
|
|
1166
|
+
let updateResult;
|
|
1167
|
+
if (level === "consumed") {
|
|
1168
|
+
const context = contextByTaskId.get(taskId);
|
|
1169
|
+
updateResult = db.run(
|
|
1170
|
+
`UPDATE tasks SET
|
|
1171
|
+
runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now')),
|
|
1172
|
+
consumed_at = COALESCE(consumed_at, datetime('now')),
|
|
1173
|
+
thread_id = COALESCE(thread_id, ?4),
|
|
1174
|
+
turn_id = COALESCE(turn_id, ?5)
|
|
1175
|
+
WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
|
|
1176
|
+
[enforceNetworkId, taskId, ownerValue, context?.thread_id ?? null, context?.turn_id ?? null],
|
|
1177
|
+
);
|
|
1178
|
+
} else {
|
|
1179
|
+
updateResult = db.run(
|
|
1180
|
+
`UPDATE tasks SET runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now'))
|
|
1181
|
+
WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
|
|
1182
|
+
[enforceNetworkId, taskId, ownerValue],
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
if (updateResult.changes !== 1) {
|
|
1186
|
+
// A zero-row write after a successful preflight is an invariant
|
|
1187
|
+
// failure, never a successful evidence report.
|
|
1188
|
+
throw new Error(`task_runtime_evidence_write_race:${taskId}`);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
const evidenceRows = db.all<{
|
|
1192
|
+
task_id: string;
|
|
1193
|
+
runtime_submitted_at: string;
|
|
1194
|
+
consumed_at: string | null;
|
|
1195
|
+
thread_id: string | null;
|
|
1196
|
+
turn_id: string | null;
|
|
1197
|
+
}>(
|
|
1198
|
+
`SELECT task_id, runtime_submitted_at, consumed_at, thread_id, turn_id FROM tasks
|
|
1199
|
+
WHERE network_id = ?1 AND task_id IN (${placeholders})`,
|
|
1200
|
+
enforceNetworkId,
|
|
1201
|
+
...uniqueTaskIds,
|
|
1202
|
+
);
|
|
1203
|
+
return { ok: true as const, tasks: evidenceRows };
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
return {
|
|
1207
|
+
content: [{
|
|
1208
|
+
type: "text" as const,
|
|
1209
|
+
text: JSON.stringify(outcome),
|
|
1210
|
+
}],
|
|
1211
|
+
};
|
|
1212
|
+
};
|
|
1213
|
+
|
|
1214
|
+
const taskRuntimeEvidenceSchema = {
|
|
1215
|
+
task_ids: z.array(z.string().min(1).max(200)).min(1).max(100),
|
|
1216
|
+
task_contexts: z.array(z.object({
|
|
1217
|
+
task_id: z.string().min(1).max(200),
|
|
1218
|
+
thread_id: z.string().min(1).max(500),
|
|
1219
|
+
turn_id: z.string().min(1).max(500),
|
|
1220
|
+
}).strict()).max(100).optional(),
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
server.tool(
|
|
1224
|
+
"mark_tasks_runtime_submitted",
|
|
1225
|
+
"Internal agent-node signal: exact task bodies were submitted to this token-bound node's vendor runtime.",
|
|
1226
|
+
taskRuntimeEvidenceSchema,
|
|
1227
|
+
async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "submitted", task_contexts),
|
|
1228
|
+
);
|
|
1229
|
+
|
|
1230
|
+
server.tool(
|
|
1231
|
+
"mark_tasks_consumed",
|
|
1232
|
+
"Internal agent-node signal: exact tasks produced attributable turn-start/activity evidence in this token-bound node's runtime.",
|
|
1233
|
+
taskRuntimeEvidenceSchema,
|
|
1234
|
+
async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "consumed", task_contexts),
|
|
1235
|
+
);
|
|
1236
|
+
|
|
649
1237
|
// ═══════════════════════════════════════════
|
|
650
1238
|
// Hub Tools (5)
|
|
651
1239
|
// ═══════════════════════════════════════════
|
|
652
1240
|
|
|
653
1241
|
server.tool(
|
|
654
1242
|
"get_all_status",
|
|
655
|
-
"Get status of all sessions. Hub uses this for the patrol loop."
|
|
1243
|
+
"Get status of all sessions. Hub uses this for the patrol loop. " +
|
|
1244
|
+
"Pass filter_alias (comma-separated) when you only care about specific " +
|
|
1245
|
+
"nodes — the unfiltered result is one row per session with 31 columns and " +
|
|
1246
|
+
"is large enough on a real fleet that callers cannot read it.",
|
|
656
1247
|
{
|
|
657
1248
|
filter_status: z.string().max(50).optional(),
|
|
658
1249
|
filter_server: z.string().max(200).optional(),
|
|
1250
|
+
// 2026-08-17: on a 222-session hub the unfiltered response is ~259 KB, past
|
|
1251
|
+
// what an MCP client can take in one result — so the caller that wanted the
|
|
1252
|
+
// status of THREE nodes could not get it from this tool at all. The patrol
|
|
1253
|
+
// loop still wants everything, hence optional rather than required.
|
|
1254
|
+
filter_alias: z.string().max(2000).optional()
|
|
1255
|
+
.describe("One alias, or several separated by commas. Exact matches only."),
|
|
659
1256
|
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
660
1257
|
},
|
|
661
|
-
async ({ filter_status, filter_server, network_id: netId }) => {
|
|
1258
|
+
async ({ filter_status, filter_server, filter_alias, network_id: netId }) => {
|
|
662
1259
|
const readScope = resolveReadScope(netId);
|
|
663
1260
|
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) : ""}`);
|
|
1261
|
+
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
1262
|
|
|
666
1263
|
// Round-2/4 review ③: stale-marking moved to startStaleSessionSweeper()
|
|
667
1264
|
// (background timer, ~60s cadence). Read path no longer fires UPDATE.
|
|
@@ -670,20 +1267,42 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
670
1267
|
sql = addReadScope(sql, params, readScope);
|
|
671
1268
|
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
672
1269
|
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
1270
|
+
const aliasFilter = parseAliasFilter(filter_alias);
|
|
1271
|
+
const aliases = aliasFilter.aliases;
|
|
1272
|
+
if (aliasFilter.sql) {
|
|
1273
|
+
sql += aliasFilter.sql;
|
|
1274
|
+
params.push(...aliases);
|
|
1275
|
+
}
|
|
673
1276
|
sql += " ORDER BY updated_at DESC";
|
|
674
1277
|
const sessions = db.all(sql, ...params);
|
|
675
1278
|
|
|
1279
|
+
// `summary` has always counted every session in the read scope, ignoring
|
|
1280
|
+
// filter_status / filter_server — and now filter_alias. That is fine for
|
|
1281
|
+
// the patrol loop, but a caller who asked about three aliases and gets
|
|
1282
|
+
// back three rows plus "idle: 96" can easily read the 96 as being about
|
|
1283
|
+
// their three. A count that does not say what it counted invites exactly
|
|
1284
|
+
// that. So the response now says so, rather than the semantics changing
|
|
1285
|
+
// under existing callers.
|
|
676
1286
|
const summaryParams: any[] = [];
|
|
677
1287
|
let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
|
|
678
1288
|
summarySql = addReadScope(summarySql, summaryParams, readScope);
|
|
679
1289
|
summarySql += " GROUP BY status";
|
|
680
1290
|
const summary = db.all(summarySql, ...summaryParams);
|
|
681
1291
|
|
|
1292
|
+
const filtered = !!(filter_status || filter_server || aliases.length > 0);
|
|
682
1293
|
return {
|
|
683
1294
|
content: [
|
|
684
1295
|
{
|
|
685
1296
|
type: "text" as const,
|
|
686
|
-
text: JSON.stringify({
|
|
1297
|
+
text: JSON.stringify({
|
|
1298
|
+
ok: true,
|
|
1299
|
+
sessions,
|
|
1300
|
+
summary,
|
|
1301
|
+
summary_scope: filtered
|
|
1302
|
+
? "every session in the read scope — NOT narrowed by the filters applied to `sessions`"
|
|
1303
|
+
: "every session in the read scope",
|
|
1304
|
+
sessions_returned: sessions.length,
|
|
1305
|
+
}),
|
|
687
1306
|
},
|
|
688
1307
|
],
|
|
689
1308
|
};
|
|
@@ -741,7 +1360,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
741
1360
|
},
|
|
742
1361
|
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
1362
|
const effectiveNetId = getNetworkId(netId);
|
|
744
|
-
const
|
|
1363
|
+
const authOrigin: TaskAuthOrigin = callerTokenIsNetwork
|
|
1364
|
+
? "node"
|
|
1365
|
+
: enforceUserId
|
|
1366
|
+
? "user"
|
|
1367
|
+
: "legacy";
|
|
1368
|
+
const metaJson = normalizeMetaJson(stampTaskAuthOrigin(meta, authOrigin));
|
|
745
1369
|
|
|
746
1370
|
// Role check FIRST — round5 follow-up (通信牛 oracle catch):
|
|
747
1371
|
// the explicit-parent verification below distinguishes
|
|
@@ -816,6 +1440,45 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
816
1440
|
const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
|
|
817
1441
|
if (target.state === "not_found") return deliveryTargetReply(target)!;
|
|
818
1442
|
|
|
1443
|
+
// Dashboard sends carry a random client_request_id inside meta. Derive
|
|
1444
|
+
// the task primary key from authenticated scope + that request id so a
|
|
1445
|
+
// lost HTTP response can be retried safely, even after a hub restart.
|
|
1446
|
+
// The existing row must match the full request; key reuse with changed
|
|
1447
|
+
// content/target fails closed instead of silently returning another task.
|
|
1448
|
+
const clientRequestId = clientRequestIdFromMeta(meta);
|
|
1449
|
+
const id = clientRequestId
|
|
1450
|
+
? idempotentTaskId(effectiveNetId ?? null, from_session, clientRequestId)
|
|
1451
|
+
: uuidv4();
|
|
1452
|
+
if (clientRequestId) {
|
|
1453
|
+
const existing = db.get<StoredIdempotentTask & { to_node_id: string | null }>(
|
|
1454
|
+
"SELECT task_id, from_name, to_node_id, to_name, priority, content, network_id, meta_json, status FROM tasks WHERE task_id = ?1",
|
|
1455
|
+
[id],
|
|
1456
|
+
);
|
|
1457
|
+
if (existing) {
|
|
1458
|
+
if (!idempotentTaskMatches(existing, {
|
|
1459
|
+
fromName: from_session, toName: targetAlias, priority, content: task,
|
|
1460
|
+
networkId: effectiveNetId ?? null, metaJson,
|
|
1461
|
+
})) {
|
|
1462
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
1463
|
+
ok: false, error: "idempotency_conflict",
|
|
1464
|
+
message: "client_request_id was already used with a different task payload",
|
|
1465
|
+
}) }] };
|
|
1466
|
+
}
|
|
1467
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
1468
|
+
ok: true, message_id: existing.task_id, task_id: existing.task_id,
|
|
1469
|
+
task_status: existing.status, idempotent_replay: true,
|
|
1470
|
+
// A replay acknowledges the already-persisted dispatch. Use that
|
|
1471
|
+
// row rather than today's session record so alias reuse or a
|
|
1472
|
+
// restarted process cannot rewrite historical recipient identity.
|
|
1473
|
+
actual_to: {
|
|
1474
|
+
alias: existing.to_name,
|
|
1475
|
+
to_node_id: existing.to_node_id ?? null,
|
|
1476
|
+
network_id: existing.network_id ?? null,
|
|
1477
|
+
},
|
|
1478
|
+
}) }] };
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
819
1482
|
// #212 dedup guardrail. If this exact (from, to, content) has already
|
|
820
1483
|
// been delivered within COMMHUB_SEND_DEDUP_WINDOW_MS (default 5 min)
|
|
821
1484
|
// we refuse the call and surface a structured `duplicate_send`
|
|
@@ -836,18 +1499,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
836
1499
|
return { content: [{ type: "text" as const, text: JSON.stringify(payload) }] };
|
|
837
1500
|
}
|
|
838
1501
|
|
|
1502
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 1/6).
|
|
1503
|
+
{
|
|
1504
|
+
const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
|
|
1505
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
1506
|
+
}
|
|
1507
|
+
|
|
839
1508
|
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
1509
|
const fromNodeId = resolveNodeIdForAlias(from_session, effectiveNetId);
|
|
842
1510
|
const targetNodeId = target.session?.node_id ?? null;
|
|
1511
|
+
const resolvedActualTo = actualTo(target, effectiveNetId);
|
|
843
1512
|
// 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
|
|
844
1513
|
// dashboard 在派任务一刻就反映出"任务已下发",不再等 agent 的
|
|
845
1514
|
// report_status 心跳;status 字段交给 agent,避免与 working/idle
|
|
846
1515
|
// 报告冲突)。
|
|
847
1516
|
db.transaction(() => {
|
|
848
1517
|
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)`,
|
|
1518
|
+
`INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, context, from_session, requires_response, network_id, meta_json)
|
|
1519
|
+
VALUES (?1, ?1, ?2, ?3, 'task', ?4, ?5, ?6, ?7, 'reply', ?8, ?9)`,
|
|
851
1520
|
[id, targetAlias, targetNodeId, priority, task, context ?? null, from_session, effectiveNetId ?? null, metaJson]
|
|
852
1521
|
);
|
|
853
1522
|
db.run(
|
|
@@ -877,9 +1546,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
877
1546
|
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
878
1547
|
pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
|
|
879
1548
|
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
1549
|
+
// 🔴 不要用 last_seen_at 的时间戳去猜「有没有人在听」——SSE 订阅者注册表
|
|
1550
|
+
// 就是那件事本身。pushEvent 在没有订阅者时已经是 no-op,所以这道闸不提供
|
|
1551
|
+
// 任何保护,只会把**连着但最近没心跳**的会话排除掉;而全网「停发心跳/状态类
|
|
1552
|
+
// 消息」的省额度规则让安静恰恰是常态。上面那段注释早就写着 "Push
|
|
1553
|
+
// unconditionally",同一类缺陷上次也是打在 Dashboard 任务上(f015d9d6:
|
|
1554
|
+
// 任务标了 delivered,会话却从未收到推送)。
|
|
1555
|
+
pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
|
|
1556
|
+
const actuallyDelivered = hasSubscribers(targetAlias, effectiveNetId);
|
|
1557
|
+
// #461 network observer summary — unconditional (task row exists
|
|
1558
|
+
// even when the target is offline/queued), metadata only.
|
|
1559
|
+
pushNetworkObserverEvent(effectiveNetId, { type: "new_task", task_id: id, from: from_session, to: targetAlias, status: actuallyDelivered ? "delivered" : "queued", priority });
|
|
883
1560
|
|
|
884
1561
|
if (target.state === "offline") {
|
|
885
1562
|
return {
|
|
@@ -894,6 +1571,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
894
1571
|
task_id: id,
|
|
895
1572
|
message_id: id,
|
|
896
1573
|
session_status: target.session.status ?? "offline",
|
|
1574
|
+
actual_to: resolvedActualTo,
|
|
897
1575
|
...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
|
|
898
1576
|
}),
|
|
899
1577
|
}],
|
|
@@ -907,6 +1585,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
907
1585
|
text: JSON.stringify({
|
|
908
1586
|
ok: true,
|
|
909
1587
|
message_id: id,
|
|
1588
|
+
actual_to: resolvedActualTo,
|
|
910
1589
|
...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
|
|
911
1590
|
session_status: target.session?.status ?? "unknown",
|
|
912
1591
|
}),
|
|
@@ -923,14 +1602,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
923
1602
|
alias: z.string().min(1).max(200).describe("Target session alias"),
|
|
924
1603
|
message: z.string().min(1).max(10000).describe("Message content"),
|
|
925
1604
|
from_session: z.string().max(200).optional(),
|
|
1605
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
926
1606
|
},
|
|
927
|
-
async ({ alias, message, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
928
|
-
const effectiveNetId = getNetworkId(
|
|
1607
|
+
async ({ alias, message, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1608
|
+
const effectiveNetId = getNetworkId(netId);
|
|
929
1609
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
930
1610
|
const canonical = resolveCanonicalAlias(effectiveNetId, alias);
|
|
931
1611
|
const targetAlias = canonical.alias;
|
|
932
1612
|
const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
|
|
933
1613
|
if (target.state === "not_found") return deliveryTargetReply(target)!;
|
|
1614
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 2/6).
|
|
1615
|
+
{
|
|
1616
|
+
const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
|
|
1617
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
1618
|
+
}
|
|
934
1619
|
console.log(`[${ts()}] ${from_session} → send_message → ${targetAlias}: ${message.slice(0, 60)}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
|
|
935
1620
|
const id = uuidv4();
|
|
936
1621
|
db.run(
|
|
@@ -939,9 +1624,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
939
1624
|
[id, targetAlias, target.session?.node_id ?? null, message, from_session, effectiveNetId ?? null]
|
|
940
1625
|
);
|
|
941
1626
|
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
}
|
|
1627
|
+
// 同 send_task:可达性用订阅者注册表判,不用心跳时间戳猜。
|
|
1628
|
+
pushEvent(targetAlias, { type: "new_message", inbox_count: pendingInboxCount(targetAlias, effectiveNetId), from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
|
|
945
1629
|
|
|
946
1630
|
const offlineReply = deliveryTargetReply(target, { message_id: id });
|
|
947
1631
|
if (offlineReply) {
|
|
@@ -969,96 +1653,253 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
969
1653
|
}
|
|
970
1654
|
);
|
|
971
1655
|
|
|
972
|
-
// ── V2
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1656
|
+
// ── V2/V3 reply primitives ──
|
|
1657
|
+
const replyToolSchema = {
|
|
1658
|
+
// #1085 — alias is OPTIONAL: when omitted (or empty) and `in_reply_to`
|
|
1659
|
+
// is set, the hub derives the target from the original task's
|
|
1660
|
+
// `from_name` (it already SELECTs that task by in_reply_to below).
|
|
1661
|
+
// The SDK-runtime bridge (agent-node/src/commhub-mcp.ts) exposes
|
|
1662
|
+
// send_reply as (task_id, text, status) with NO alias, so before this
|
|
1663
|
+
// it always sent alias=undefined → -32602 "expected string, received
|
|
1664
|
+
// undefined at alias". The target was already knowable from the task,
|
|
1665
|
+
// so requiring the caller to pass it was a redundant-param trap.
|
|
1666
|
+
alias: z.string().min(1).max(200).optional().describe("Target session alias — optional; derived from in_reply_to's task sender when omitted"),
|
|
978
1667
|
text: z.string().min(1).max(10000).describe("Reply content"),
|
|
979
1668
|
in_reply_to: z.string().max(200).optional().describe("Original task/message ID"),
|
|
980
1669
|
status: z.enum(["replied", "failed", "cancelled"]).optional().default("replied").describe("Task outcome"),
|
|
981
1670
|
from_session: z.string().max(200).optional(),
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1671
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1672
|
+
// #507 — top-level attachments (parity with REST /api/task L506). MCP Zod
|
|
1673
|
+
// otherwise silently strips unknown fields, so a caller passing
|
|
1674
|
+
// `attachments` before this schema entry existed would see `ok:true`
|
|
1675
|
+
// and never learn the attachments were dropped. Validated by
|
|
1676
|
+
// validateAttachments (uploads.ts) — the same helper the REST path uses.
|
|
1677
|
+
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."),
|
|
1678
|
+
// #507 — optional structured metadata (parity with send_task L837). If
|
|
1679
|
+
// both `attachments` (top-level) and `meta.attachments` are supplied,
|
|
1680
|
+
// top-level wins (same rule as REST /api/task L2101).
|
|
1681
|
+
meta: z.any().optional().describe("Optional structured reply metadata, e.g. { attachments: [...] }."),
|
|
1682
|
+
};
|
|
1683
|
+
const handleReply = async (args: any, peerCapabilityRequired: boolean) => {
|
|
1684
|
+
const { alias, text, in_reply_to, status: replyStatus = "replied", from_session: _fromIn, network_id: netId, attachments, meta } = args;
|
|
1685
|
+
const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1686
|
+
const effectiveNetId = getNetworkId(netId);
|
|
985
1687
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1688
|
+
if (peerCapabilityRequired && !in_reply_to) {
|
|
1689
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "peer_reply_task_required" }) }] };
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// #507 — validate attachments BEFORE any DB write. Rejects malformed
|
|
1693
|
+
// input (bad file_id, >20 items, size > cap, non-object item, wrong
|
|
1694
|
+
// type field) with an explicit error the caller can act on. Empty /
|
|
1695
|
+
// absent attachments return { ok: true, attachments: [] } — the
|
|
1696
|
+
// reverse-(e) invariant (no attachments → behavior unchanged) is
|
|
1697
|
+
// pinned by tests that assert byte-identical response shape
|
|
1698
|
+
// before/after this validation runs.
|
|
1699
|
+
const attachmentsResult = validateAttachments(attachments ?? (meta && typeof meta === "object" ? (meta as any).attachments : undefined));
|
|
1700
|
+
if (!attachmentsResult.ok) {
|
|
1701
|
+
return {
|
|
1702
|
+
content: [{
|
|
1703
|
+
type: "text" as const,
|
|
1704
|
+
text: JSON.stringify({
|
|
1705
|
+
ok: false,
|
|
1706
|
+
error: "bad_attachments",
|
|
1707
|
+
message: attachmentsResult.error,
|
|
1708
|
+
}),
|
|
1709
|
+
}],
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
const mergedMeta = attachmentsResult.attachments.length
|
|
1713
|
+
? { ...(meta && typeof meta === "object" ? meta : {}), attachments: attachmentsResult.attachments }
|
|
1714
|
+
: meta;
|
|
1715
|
+
const metaJson = normalizeMetaJson(mergedMeta);
|
|
1716
|
+
|
|
1717
|
+
// #1085 — derive the reply target when the caller omitted `alias`.
|
|
1718
|
+
// The original task's `from_name` IS the reply target; the hub knows
|
|
1719
|
+
// it from `in_reply_to`. Without this, an SDK-runtime send_reply
|
|
1720
|
+
// (schema has no alias) died with -32602 before reaching this handler.
|
|
1721
|
+
let effectiveAlias: string | undefined =
|
|
1722
|
+
(typeof alias === "string" && alias.trim()) ? alias : undefined;
|
|
1723
|
+
if (!effectiveAlias) {
|
|
1724
|
+
if (!in_reply_to) {
|
|
1725
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "reply_target_required", message: "alias omitted and no in_reply_to to derive it from" }) }] };
|
|
1007
1726
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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
|
-
};
|
|
1727
|
+
const derivParams: any[] = [in_reply_to];
|
|
1728
|
+
let derivSql = "SELECT from_name FROM tasks WHERE task_id = ?1";
|
|
1729
|
+
derivSql = addScope(derivSql, derivParams, effectiveNetId);
|
|
1730
|
+
const derived = db.get<{ from_name: string }>(derivSql, ...derivParams);
|
|
1731
|
+
if (!derived?.from_name) {
|
|
1732
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "reply_target_unresolved", message: `alias omitted and could not derive target from in_reply_to (${in_reply_to})` }) }] };
|
|
1022
1733
|
}
|
|
1734
|
+
effectiveAlias = derived.from_name;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
const canonicalReplyTarget = resolveCanonicalAlias(effectiveNetId, effectiveAlias);
|
|
1738
|
+
const replyTargetAlias = canonicalReplyTarget.alias;
|
|
1739
|
+
console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${replyTargetAlias}: ${text.slice(0, 60)}${attachmentsResult.attachments.length ? ` [+${attachmentsResult.attachments.length} attachments]` : ""}${canonicalReplyTarget.renamed ? ` [renamed from ${effectiveAlias}]` : ""}`);
|
|
1740
|
+
const id = uuidv4();
|
|
1741
|
+
const replyTargetNodeId = resolveNodeIdForAlias(replyTargetAlias, effectiveNetId);
|
|
1742
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 3/6).
|
|
1743
|
+
{
|
|
1744
|
+
const lc = assertNodeActive(replyTargetAlias, effectiveNetId ?? null);
|
|
1745
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
1023
1746
|
}
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1747
|
+
const replyOutcome = db.transaction(() => {
|
|
1748
|
+
type ReplyTask = {
|
|
1749
|
+
status: string;
|
|
1750
|
+
from_node_id: string | null;
|
|
1751
|
+
from_name: string;
|
|
1752
|
+
to_name: string;
|
|
1753
|
+
to_node_id: string | null;
|
|
1754
|
+
};
|
|
1755
|
+
let taskBefore: ReplyTask | null = null;
|
|
1756
|
+
let callerNodeId: string | null = null;
|
|
1757
|
+
if (in_reply_to) {
|
|
1758
|
+
const taskParams: any[] = [in_reply_to];
|
|
1759
|
+
let taskSql = "SELECT status, from_node_id, from_name, to_name, to_node_id FROM tasks WHERE task_id = ?1";
|
|
1760
|
+
taskSql = addScope(taskSql, taskParams, effectiveNetId);
|
|
1761
|
+
taskBefore = db.get<ReplyTask>(taskSql, ...taskParams) ?? null;
|
|
1762
|
+
if (!taskBefore) return { ok: false as const, error: "reply_task_not_found" as const };
|
|
1763
|
+
if (!["created", "delivered", "acked", "running"].includes(taskBefore.status)) {
|
|
1764
|
+
return { ok: false as const, error: "reply_task_terminal" as const, taskStatus: taskBefore.status };
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
// V3 atomic peer replies are capability-negotiated. Both identity
|
|
1768
|
+
// bindings and the recipient capability are re-read inside this
|
|
1769
|
+
// transaction before any inbox/task/run write. Legacy/unbound rows
|
|
1770
|
+
// fail toward compatibility send_reply, which terminalizes the
|
|
1771
|
+
// original task instead of creating a second response-requiring row.
|
|
1772
|
+
if (peerCapabilityRequired) {
|
|
1773
|
+
if (!taskBefore.from_node_id) {
|
|
1774
|
+
// Origin type is an intrinsic property of the task and must be
|
|
1775
|
+
// decided before caller capability. A Dashboard/human task still
|
|
1776
|
+
// needs the established send_reply terminalization path when the
|
|
1777
|
+
// replying node uses a legacy/unbound token.
|
|
1778
|
+
return { ok: false as const, error: "peer_reply_origin_not_node" as const };
|
|
1779
|
+
}
|
|
1780
|
+
if (!callerTokenIsNetwork || !callerTokenId || !enforceNetworkId) {
|
|
1781
|
+
return { ok: false as const, error: "peer_reply_node_token_required" as const };
|
|
1782
|
+
}
|
|
1783
|
+
const token = db.get<{ bound_node_id: string | null }>(
|
|
1784
|
+
"SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
|
|
1785
|
+
callerTokenId,
|
|
1786
|
+
enforceNetworkId,
|
|
1787
|
+
);
|
|
1788
|
+
if (!token?.bound_node_id || !taskBefore.to_node_id) {
|
|
1789
|
+
return { ok: false as const, error: "peer_reply_unsupported" as const };
|
|
1790
|
+
}
|
|
1791
|
+
if (token.bound_node_id !== taskBefore.to_node_id) {
|
|
1792
|
+
return { ok: false as const, error: "reply_task_not_owned" as const };
|
|
1793
|
+
}
|
|
1794
|
+
callerNodeId = token.bound_node_id;
|
|
1795
|
+
const canonicalTarget = replyTargetAlias;
|
|
1796
|
+
const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, taskBefore.from_name).alias;
|
|
1797
|
+
if (canonicalTarget !== canonicalOrigin) {
|
|
1798
|
+
return { ok: false as const, error: "reply_target_mismatch" as const };
|
|
1799
|
+
}
|
|
1800
|
+
const recipient = db.get<{ peer_reply_inbox_capable: number }>(
|
|
1801
|
+
`SELECT peer_reply_inbox_capable FROM sessions
|
|
1802
|
+
WHERE node_id = ?1 AND network_id = ?2 AND alias = ?3
|
|
1803
|
+
ORDER BY updated_at DESC LIMIT 1`,
|
|
1804
|
+
taskBefore.from_node_id,
|
|
1805
|
+
enforceNetworkId,
|
|
1806
|
+
canonicalOrigin,
|
|
1807
|
+
);
|
|
1808
|
+
if (recipient?.peer_reply_inbox_capable !== 1) {
|
|
1809
|
+
return { ok: false as const, error: "peer_reply_unsupported" as const };
|
|
1810
|
+
}
|
|
1811
|
+
} else if (callerTokenIsNetwork && enforceNetworkId) {
|
|
1812
|
+
// Compatibility send_reply is still an exact task transition for
|
|
1813
|
+
// node callers. Bind it to the token-authenticated assignee alias
|
|
1814
|
+
// and immutable original sender. Alias binding survives a
|
|
1815
|
+
// legitimate node-id rotation without letting another node close
|
|
1816
|
+
// the task.
|
|
1817
|
+
const canonicalCaller = resolveCanonicalAlias(enforceNetworkId, from_session).alias;
|
|
1818
|
+
const canonicalAssignee = resolveCanonicalAlias(enforceNetworkId, taskBefore.to_name).alias;
|
|
1819
|
+
if (canonicalCaller !== canonicalAssignee) {
|
|
1820
|
+
return { ok: false as const, error: "reply_task_not_owned" as const };
|
|
1821
|
+
}
|
|
1822
|
+
const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, taskBefore.from_name).alias;
|
|
1823
|
+
if (replyTargetAlias !== canonicalOrigin) {
|
|
1824
|
+
return { ok: false as const, error: "reply_target_mismatch" as const };
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// #507 — write meta_json on inbox insert (parity with send_task L952).
|
|
1831
|
+
// Prior to this the attachments field on a send_reply call was
|
|
1832
|
+
// silently stripped by MCP Zod, leaving `ok:true` with no persisted
|
|
1833
|
+
// meta.
|
|
1026
1834
|
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,
|
|
1835
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id, meta_json)
|
|
1836
|
+
VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7, ?8)`,
|
|
1837
|
+
[id, replyTargetAlias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null, metaJson]
|
|
1030
1838
|
);
|
|
1031
1839
|
|
|
1032
1840
|
// 更新 tasks 表
|
|
1033
1841
|
if (in_reply_to) {
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1842
|
+
// #507 — persist meta_json onto the tasks row too, so
|
|
1843
|
+
// dashboard's task view sees the reply's attachments alongside
|
|
1844
|
+
// the reply text (parity with send_task L957/959 which writes
|
|
1845
|
+
// meta_json into both inbox and tasks). The COALESCE guards
|
|
1846
|
+
// against clobbering pre-existing meta_json when this reply
|
|
1847
|
+
// brings no attachments (metaJson === null → keep the existing
|
|
1848
|
+
// task meta_json unchanged). Reverse-(e) invariant.
|
|
1849
|
+
const updateParams: any[] = [replyStatus, text, metaJson, in_reply_to];
|
|
1850
|
+
let updateSql = `UPDATE tasks
|
|
1851
|
+
SET status = ?1,
|
|
1852
|
+
result = ?2,
|
|
1853
|
+
completed_at = datetime('now'),
|
|
1854
|
+
meta_json = COALESCE(?3, meta_json)
|
|
1855
|
+
WHERE task_id = ?4 AND status IN ('created', 'delivered', 'acked', 'running')`;
|
|
1856
|
+
if (peerCapabilityRequired && callerTokenIsNetwork && taskBefore) {
|
|
1857
|
+
if (taskBefore.to_node_id) {
|
|
1858
|
+
updateParams.push(callerNodeId);
|
|
1859
|
+
updateSql += ` AND to_node_id = ?${updateParams.length}`;
|
|
1860
|
+
} else {
|
|
1861
|
+
updateParams.push(taskBefore.to_name);
|
|
1862
|
+
updateSql += ` AND to_node_id IS NULL AND to_name = ?${updateParams.length}`;
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1037
1865
|
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
1038
1866
|
const result = db.run(updateSql, updateParams);
|
|
1039
1867
|
if (result.changes === 0) {
|
|
1040
|
-
|
|
1041
|
-
return false;
|
|
1868
|
+
throw new Error(`reply_atomic_cas_failed:${in_reply_to}`);
|
|
1042
1869
|
}
|
|
1043
|
-
|
|
1870
|
+
syncScheduledRunForTask(in_reply_to, effectiveNetId);
|
|
1871
|
+
return { ok: true as const, replyLogged: true };
|
|
1044
1872
|
}
|
|
1045
|
-
return false;
|
|
1873
|
+
return { ok: true as const, replyLogged: false };
|
|
1046
1874
|
});
|
|
1047
1875
|
|
|
1048
|
-
if (
|
|
1876
|
+
if (!replyOutcome.ok) {
|
|
1877
|
+
const taskStatus = "taskStatus" in replyOutcome ? replyOutcome.taskStatus : undefined;
|
|
1878
|
+
const messages: Record<string, string> = {
|
|
1879
|
+
reply_task_not_found: `cannot apply reply: task not found (${in_reply_to})`,
|
|
1880
|
+
reply_task_terminal: `cannot apply reply: task is already terminal (${taskStatus})`,
|
|
1881
|
+
peer_reply_node_token_required: "atomic peer reply requires a node token",
|
|
1882
|
+
peer_reply_unsupported: "recipient or caller does not support atomic peer replies",
|
|
1883
|
+
peer_reply_origin_not_node: "original task sender is not a node; use send_reply",
|
|
1884
|
+
reply_node_identity_unbound: "cannot apply reply: node identity is not token-bound",
|
|
1885
|
+
reply_task_not_owned: "cannot apply reply: task is not owned by this node",
|
|
1886
|
+
reply_target_mismatch: "cannot apply reply: target is not the original task sender",
|
|
1887
|
+
};
|
|
1049
1888
|
return {
|
|
1050
1889
|
content: [{
|
|
1051
1890
|
type: "text" as const,
|
|
1052
1891
|
text: JSON.stringify({
|
|
1053
1892
|
ok: false,
|
|
1054
|
-
error:
|
|
1055
|
-
message:
|
|
1893
|
+
error: replyOutcome.error,
|
|
1894
|
+
message: messages[replyOutcome.error],
|
|
1056
1895
|
in_reply_to,
|
|
1896
|
+
...(taskStatus ? { task_status: taskStatus } : {}),
|
|
1057
1897
|
reply_queued: false,
|
|
1058
1898
|
}),
|
|
1059
1899
|
}],
|
|
1060
1900
|
};
|
|
1061
1901
|
}
|
|
1902
|
+
const replyLogged = replyOutcome.replyLogged;
|
|
1062
1903
|
|
|
1063
1904
|
// Log event after commit (outside transaction)
|
|
1064
1905
|
if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
|
|
@@ -1095,16 +1936,71 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1095
1936
|
}
|
|
1096
1937
|
}
|
|
1097
1938
|
|
|
1098
|
-
const session = scopedSessionStatus(
|
|
1099
|
-
pushEvent(
|
|
1939
|
+
const session = scopedSessionStatus(replyTargetAlias, effectiveNetId);
|
|
1940
|
+
pushEvent(replyTargetAlias, { type: "new_reply", inbox_count: pendingInboxCount(replyTargetAlias, effectiveNetId), from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
|
|
1941
|
+
// #461 network observer summary — ids + routing only, no reply text.
|
|
1942
|
+
pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to ?? null, message_id: id, from: from_session, to: replyTargetAlias, status: replyStatus });
|
|
1943
|
+
|
|
1944
|
+
// #498 compatibility tripwire. Legacy send_reply is intentionally kept
|
|
1945
|
+
// for old agents and Dashboard callers during rollout, but it is not the
|
|
1946
|
+
// capability-negotiated atomic peer primitive. Warn only for an actual
|
|
1947
|
+
// agent target; Dashboard hub/api replies remain quiet. New agents call
|
|
1948
|
+
// send_peer_reply and never see this warning.
|
|
1949
|
+
const targetIsAgent = replyTargetNodeId !== null
|
|
1950
|
+
&& replyTargetAlias !== "hub"
|
|
1951
|
+
&& replyTargetAlias !== "api";
|
|
1952
|
+
const warning = !peerCapabilityRequired && targetIsAgent
|
|
1953
|
+
? `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.)`
|
|
1954
|
+
: undefined;
|
|
1955
|
+
|
|
1956
|
+
// #507 — echo attachments READ BACK FROM DB (lead 2b5f6634): the point
|
|
1957
|
+
// of the echo is to prove attachments actually landed in storage, not
|
|
1958
|
+
// to reflect the in-memory variable we tried to write. Reading
|
|
1959
|
+
// `attachmentsResult.attachments` here would still show `ok:true` +
|
|
1960
|
+
// full echo if the UPDATE failed with `changes=0` (e.g. task moved to
|
|
1961
|
+
// terminal between the pre-check and the transaction). SELECT from
|
|
1962
|
+
// inbox.meta_json (always written, uses the id we just generated) so
|
|
1963
|
+
// the echo means "these attachments are on disk now". Absent field
|
|
1964
|
+
// when the caller sent no attachments — reverse-(e) invariant: a
|
|
1965
|
+
// no-attachment call returns the exact same response shape as before
|
|
1966
|
+
// this PR. Persisted-attachments field is only added when the caller
|
|
1967
|
+
// actually asked for attachments.
|
|
1968
|
+
let attachmentsSaved: unknown[] | null = null;
|
|
1969
|
+
if (attachmentsResult.attachments.length > 0) {
|
|
1970
|
+
const persistedRow = db.get<{ meta_json: string | null }>(
|
|
1971
|
+
"SELECT meta_json FROM inbox WHERE id = ?1", [id]
|
|
1972
|
+
);
|
|
1973
|
+
const persistedMeta = parseMetaJson(persistedRow?.meta_json ?? null);
|
|
1974
|
+
attachmentsSaved = persistedMeta && typeof persistedMeta === "object" && Array.isArray((persistedMeta as any).attachments)
|
|
1975
|
+
? (persistedMeta as any).attachments
|
|
1976
|
+
: [];
|
|
1977
|
+
}
|
|
1100
1978
|
|
|
1101
1979
|
return {
|
|
1102
1980
|
content: [{
|
|
1103
1981
|
type: "text" as const,
|
|
1104
|
-
text: JSON.stringify({
|
|
1982
|
+
text: JSON.stringify({
|
|
1983
|
+
ok: true,
|
|
1984
|
+
message_id: id,
|
|
1985
|
+
session_status: session?.status ?? "unknown",
|
|
1986
|
+
...(warning ? { warning } : {}),
|
|
1987
|
+
...(attachmentsSaved !== null ? { attachments_saved: attachmentsSaved } : {}),
|
|
1988
|
+
}),
|
|
1105
1989
|
}],
|
|
1106
1990
|
};
|
|
1107
|
-
}
|
|
1991
|
+
};
|
|
1992
|
+
|
|
1993
|
+
server.tool(
|
|
1994
|
+
"send_reply",
|
|
1995
|
+
"Reply to a Dashboard/UI-originated task. Also serves as the terminal legacy fallback when atomic peer reply capability is unavailable.",
|
|
1996
|
+
replyToolSchema,
|
|
1997
|
+
(args) => handleReply(args, false),
|
|
1998
|
+
);
|
|
1999
|
+
server.tool(
|
|
2000
|
+
"send_peer_reply",
|
|
2001
|
+
"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.",
|
|
2002
|
+
replyToolSchema,
|
|
2003
|
+
(args) => handleReply(args, true),
|
|
1108
2004
|
);
|
|
1109
2005
|
|
|
1110
2006
|
// ── V2: send_ack (不入 inbox,仅更新状态) ──
|
|
@@ -1114,9 +2010,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1114
2010
|
{
|
|
1115
2011
|
task_id: z.string().min(1).max(200).describe("Task ID to acknowledge"),
|
|
1116
2012
|
from_session: z.string().max(200).optional(),
|
|
2013
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1117
2014
|
},
|
|
1118
|
-
async ({ task_id, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1119
|
-
const effectiveNetId = getNetworkId(
|
|
2015
|
+
async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
2016
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1120
2017
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1121
2018
|
console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
|
|
1122
2019
|
const updateParams: any[] = [task_id];
|
|
@@ -1140,9 +2037,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1140
2037
|
{
|
|
1141
2038
|
task_id: z.string().min(1).max(200).describe("Task ID to retry"),
|
|
1142
2039
|
from_session: z.string().max(200).optional(),
|
|
2040
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1143
2041
|
},
|
|
1144
|
-
async ({ task_id, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1145
|
-
const effectiveNetId = getNetworkId(
|
|
2042
|
+
async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
2043
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1146
2044
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1147
2045
|
console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
|
|
1148
2046
|
// Find the original task
|
|
@@ -1156,6 +2054,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1156
2054
|
if (!["failed", "expired", "cancelled"].includes(task.status)) {
|
|
1157
2055
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task status is ${task.status}, not retryable` }) }] };
|
|
1158
2056
|
}
|
|
2057
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 4/6).
|
|
2058
|
+
{
|
|
2059
|
+
const lc = assertNodeActive(task.to_name, effectiveNetId ?? task.network_id ?? null);
|
|
2060
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
2061
|
+
}
|
|
1159
2062
|
db.transaction(() => {
|
|
1160
2063
|
// Reset task status
|
|
1161
2064
|
const updateParams: any[] = [task_id];
|
|
@@ -1163,17 +2066,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1163
2066
|
WHERE task_id = ?1`;
|
|
1164
2067
|
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
1165
2068
|
db.run(updateSql, updateParams);
|
|
2069
|
+
syncScheduledRunForTask(task_id, effectiveNetId ?? task.network_id ?? null);
|
|
1166
2070
|
// Re-queue in inbox with new ID (original ID may already exist)
|
|
1167
2071
|
const retryInboxId = uuidv4();
|
|
1168
2072
|
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]
|
|
2073
|
+
`INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id)
|
|
2074
|
+
VALUES (?1, ?2, ?3, ?4, 'task', ?5, ?6, ?7, 'reply', ?8)`,
|
|
2075
|
+
[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
2076
|
);
|
|
1173
2077
|
});
|
|
1174
2078
|
logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
|
|
1175
2079
|
// SSE push (unconditional — channel is keyed by alias, not network)
|
|
1176
|
-
pushEvent(task.to_name, { type: "new_task", inbox_count:
|
|
2080
|
+
pushEvent(task.to_name, { type: "new_task", inbox_count: pendingInboxCount(task.to_name, effectiveNetId ?? task.network_id ?? null), priority: task.priority, from: from_session }, effectiveNetId ?? task.network_id ?? null);
|
|
2081
|
+
pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: task.to_name, status: "delivered", priority: task.priority });
|
|
1177
2082
|
return {
|
|
1178
2083
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
|
|
1179
2084
|
};
|
|
@@ -1213,19 +2118,64 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1213
2118
|
from_name: z.string().max(200).optional().describe("Filter by sender"),
|
|
1214
2119
|
from_node_id: z.string().max(200).optional().describe("Filter by immutable sender node_id"),
|
|
1215
2120
|
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
2121
|
+
before_created_at: z.string().max(64).optional().describe("Pagination cursor timestamp"),
|
|
2122
|
+
before_task_id: z.string().max(200).optional().describe("Pagination cursor task id"),
|
|
2123
|
+
durable_cursor: z.boolean().optional().describe("Require immutable node-scoped cursor protocol"),
|
|
2124
|
+
durable_terminal_cursor: z.boolean().optional().describe("Read terminal outbound journal in monotonic order"),
|
|
2125
|
+
after_terminal_seq: z.number().int().min(0).optional().describe("Exclusive terminal journal watermark"),
|
|
1216
2126
|
limit: z.number().min(1).max(100).optional().default(20),
|
|
1217
2127
|
},
|
|
1218
|
-
async ({ alias, status, from_name, from_node_id, network_id: netId, limit }) => {
|
|
2128
|
+
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
2129
|
const readScope = resolveReadScope(netId);
|
|
1220
2130
|
if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
1221
|
-
|
|
2131
|
+
if (durable_cursor) {
|
|
2132
|
+
if (!callerTokenIsNetwork) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_required" }) }] };
|
|
2133
|
+
const boundNodeId = callerTokenId && enforceNetworkId
|
|
2134
|
+
? db.get<{ bound_node_id: string | null }>(
|
|
2135
|
+
"SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
|
|
2136
|
+
callerTokenId,
|
|
2137
|
+
enforceNetworkId,
|
|
2138
|
+
)?.bound_node_id ?? null
|
|
2139
|
+
: null;
|
|
2140
|
+
if (!boundNodeId || from_node_id !== boundNodeId) {
|
|
2141
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "from_node_id_identity_mismatch" }) }] };
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
if (durable_terminal_cursor) {
|
|
2145
|
+
if (!durable_cursor) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "durable_cursor_required" }) }] };
|
|
2146
|
+
const params: any[] = [enforceNetworkId, from_node_id, after_terminal_seq ?? 0, limit];
|
|
2147
|
+
const tasks = db.all(
|
|
2148
|
+
`SELECT t.task_id, t.from_node_id, t.from_name, t.to_node_id, t.to_name, t.priority,
|
|
2149
|
+
t.status, t.content, t.result, t.created_at, t.runtime_submitted_at,
|
|
2150
|
+
t.consumed_at, t.thread_id, t.turn_id, t.completed_at, e.terminal_seq
|
|
2151
|
+
FROM task_terminal_events e
|
|
2152
|
+
JOIN tasks t ON t.task_id = e.task_id
|
|
2153
|
+
WHERE COALESCE(e.network_id, 'default') = COALESCE(?1, 'default')
|
|
2154
|
+
AND e.from_node_id = ?2 AND e.terminal_seq > ?3
|
|
2155
|
+
ORDER BY e.terminal_seq ASC LIMIT ?4`,
|
|
2156
|
+
...params,
|
|
2157
|
+
);
|
|
2158
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
2159
|
+
ok: true,
|
|
2160
|
+
capability: "list_tasks.immutable-terminal-sequence.v2",
|
|
2161
|
+
tasks,
|
|
2162
|
+
count: tasks.length,
|
|
2163
|
+
next_terminal_seq: tasks.length ? tasks[tasks.length - 1]?.terminal_seq : (after_terminal_seq ?? 0),
|
|
2164
|
+
has_more: tasks.length === limit,
|
|
2165
|
+
}) }] };
|
|
2166
|
+
}
|
|
2167
|
+
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, thread_id, turn_id, completed_at FROM tasks WHERE 1=1";
|
|
1222
2168
|
const params: any[] = [];
|
|
1223
2169
|
sql = addReadScope(sql, params, readScope);
|
|
1224
2170
|
if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
|
|
1225
2171
|
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
1226
2172
|
if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
|
|
1227
2173
|
if (from_node_id) { sql += ` AND from_node_id = ?${params.length + 1}`; params.push(from_node_id); }
|
|
1228
|
-
|
|
2174
|
+
if (before_created_at && before_task_id) {
|
|
2175
|
+
sql += ` AND (created_at < ?${params.length + 1} OR (created_at = ?${params.length + 1} AND task_id < ?${params.length + 2}))`;
|
|
2176
|
+
params.push(before_created_at, before_task_id);
|
|
2177
|
+
}
|
|
2178
|
+
sql += ` ORDER BY created_at DESC, task_id DESC LIMIT ?${params.length + 1}`;
|
|
1229
2179
|
params.push(limit);
|
|
1230
2180
|
const tasks = db.all(sql, ...params);
|
|
1231
2181
|
|
|
@@ -1239,7 +2189,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1239
2189
|
return {
|
|
1240
2190
|
content: [{
|
|
1241
2191
|
type: "text" as const,
|
|
1242
|
-
text: JSON.stringify({
|
|
2192
|
+
text: JSON.stringify({
|
|
2193
|
+
ok: true,
|
|
2194
|
+
...(durable_cursor ? { capability: "list_tasks.immutable-node-cursor.v1" } : {}),
|
|
2195
|
+
tasks,
|
|
2196
|
+
count: tasks.length,
|
|
2197
|
+
next_cursor: tasks.length === limit ? {
|
|
2198
|
+
before_created_at: tasks[tasks.length - 1]?.created_at,
|
|
2199
|
+
before_task_id: tasks[tasks.length - 1]?.task_id,
|
|
2200
|
+
} : null,
|
|
2201
|
+
stats,
|
|
2202
|
+
}),
|
|
1243
2203
|
}],
|
|
1244
2204
|
};
|
|
1245
2205
|
}
|
|
@@ -1253,24 +2213,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1253
2213
|
task_id: z.string().min(1).max(200).describe("Task ID to cancel"),
|
|
1254
2214
|
reason: z.string().max(1000).optional().describe("Cancellation reason"),
|
|
1255
2215
|
from_session: z.string().max(200).optional(),
|
|
2216
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1256
2217
|
},
|
|
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(
|
|
2218
|
+
async ({ task_id, reason, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
2219
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1259
2220
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1260
2221
|
console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
|
|
1261
2222
|
const updateParams: any[] = [reason || "cancelled by " + from_session, task_id];
|
|
1262
2223
|
let updateSql = `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
|
|
1263
2224
|
WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`;
|
|
1264
2225
|
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
1265
|
-
const result = db.
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
2226
|
+
const result = db.transaction(() => {
|
|
2227
|
+
const updated = db.run(updateSql, updateParams);
|
|
2228
|
+
// Also ack the inbox entry to prevent agent from picking it up.
|
|
2229
|
+
if (updated.changes > 0) {
|
|
2230
|
+
const inboxParams: any[] = [task_id];
|
|
2231
|
+
let inboxSql = "UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0";
|
|
2232
|
+
inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
|
|
2233
|
+
db.run(inboxSql, inboxParams);
|
|
2234
|
+
syncScheduledRunForTask(task_id, effectiveNetId);
|
|
2235
|
+
}
|
|
2236
|
+
return updated;
|
|
2237
|
+
});
|
|
2238
|
+
if (result.changes > 0) logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
|
|
1274
2239
|
return {
|
|
1275
2240
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: result.changes > 0, task_id, cancelled: result.changes > 0 }) }],
|
|
1276
2241
|
};
|
|
@@ -1285,9 +2250,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1285
2250
|
task_id: z.string().min(1).max(200).describe("Task ID to reassign"),
|
|
1286
2251
|
new_alias: z.string().min(1).max(200).describe("Target agent alias"),
|
|
1287
2252
|
from_session: z.string().max(200).optional(),
|
|
2253
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1288
2254
|
},
|
|
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(
|
|
2255
|
+
async ({ task_id, new_alias, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
2256
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1291
2257
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1292
2258
|
console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
|
|
1293
2259
|
const taskParams: any[] = [task_id];
|
|
@@ -1303,10 +2269,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1303
2269
|
const reassignedAlias = canonical.alias;
|
|
1304
2270
|
const target = resolveDeliveryTarget(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
|
|
1305
2271
|
const newNodeId = target.state === "not_found" ? null : (target.session?.node_id ?? null);
|
|
2272
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 5/6).
|
|
2273
|
+
{
|
|
2274
|
+
const lc = assertNodeActive(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
|
|
2275
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
2276
|
+
}
|
|
1306
2277
|
db.transaction(() => {
|
|
1307
2278
|
// Ack old inbox to prevent original agent from picking it up
|
|
1308
2279
|
const inboxParams: any[] = [task_id];
|
|
1309
|
-
let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
|
|
2280
|
+
let inboxSql = "UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0";
|
|
1310
2281
|
inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
|
|
1311
2282
|
db.run(inboxSql, inboxParams);
|
|
1312
2283
|
|
|
@@ -1316,28 +2287,141 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1316
2287
|
db.run(updateSql, updateParams);
|
|
1317
2288
|
|
|
1318
2289
|
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]);
|
|
2290
|
+
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)",
|
|
2291
|
+
[newInboxId, task_id, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
|
|
1321
2292
|
});
|
|
1322
2293
|
logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${reassignedAlias}`);
|
|
1323
|
-
pushEvent(reassignedAlias, { type: "new_task", inbox_count:
|
|
2294
|
+
pushEvent(reassignedAlias, { type: "new_task", inbox_count: pendingInboxCount(reassignedAlias, effectiveNetId ?? task.network_id ?? null), priority: task.priority, from: from_session, ...(canonical.renamed ? { renamed_from: new_alias } : {}) }, effectiveNetId ?? task.network_id ?? null);
|
|
2295
|
+
pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: reassignedAlias, status: "delivered", priority: task.priority });
|
|
1324
2296
|
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
2297
|
}
|
|
1326
2298
|
);
|
|
1327
2299
|
|
|
1328
2300
|
server.tool(
|
|
1329
|
-
"
|
|
1330
|
-
"
|
|
2301
|
+
"send_desktop_message",
|
|
2302
|
+
"Push a message to a user's active Desktop/web clients in one network. Targets user identity, not node alias.",
|
|
1331
2303
|
{
|
|
2304
|
+
to_user_id: z.string().min(1).max(200).optional().describe("Target user_id. Mutually checked with to_username when both are supplied."),
|
|
2305
|
+
to_username: z.string().min(1).max(50).optional().describe("Target username. Mutually checked with to_user_id when both are supplied."),
|
|
2306
|
+
title: z.string().max(200).optional(),
|
|
1332
2307
|
message: z.string().min(1).max(10000),
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
2308
|
+
severity: z.enum(["info", "success", "warning", "error"]).optional().default("info"),
|
|
2309
|
+
kind: z.string().min(1).max(80).optional().default("agent_message"),
|
|
2310
|
+
meta: z.record(z.unknown()).optional(),
|
|
2311
|
+
from_session: z.string().max(200).optional(),
|
|
2312
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens; ntok stays bound to its network)."),
|
|
1336
2313
|
},
|
|
1337
|
-
async ({ message,
|
|
2314
|
+
async ({ to_user_id, to_username, title, message, severity, kind, meta, from_session: _fromIn, network_id: netId }) => {
|
|
2315
|
+
const fromMismatch = fromIdentityMismatchReply(_fromIn);
|
|
2316
|
+
if (fromMismatch) return fromMismatch;
|
|
2317
|
+
const from_session = defaultFrom(_fromIn);
|
|
1338
2318
|
const effectiveNetId = getNetworkId(netId);
|
|
1339
|
-
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1340
|
-
|
|
2319
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
2320
|
+
if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
|
|
2321
|
+
|
|
2322
|
+
if (!to_user_id && !to_username) {
|
|
2323
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_required", message: "to_user_id or to_username is required" }) }] };
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
const byId = to_user_id
|
|
2327
|
+
? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE user_id = ?1", to_user_id)
|
|
2328
|
+
: null;
|
|
2329
|
+
const byName = to_username
|
|
2330
|
+
? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE username = ?1", to_username)
|
|
2331
|
+
: null;
|
|
2332
|
+
|
|
2333
|
+
if (to_user_id && !byId) {
|
|
2334
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_user_id" }) }] };
|
|
2335
|
+
}
|
|
2336
|
+
if (to_username && !byName) {
|
|
2337
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_username" }) }] };
|
|
2338
|
+
}
|
|
2339
|
+
if (byId && byName && byId.user_id !== byName.user_id) {
|
|
2340
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_mismatch", to_user_id, to_username }) }] };
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
const target = byId ?? byName;
|
|
2344
|
+
if (!target) {
|
|
2345
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found" }) }] };
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
const targetRole = getUserNetworkRole(target.user_id, effectiveNetId);
|
|
2349
|
+
if (!targetRole) {
|
|
2350
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_in_network", network_id: effectiveNetId }) }] };
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
const messageId = `dm_${uuidv4()}`;
|
|
2354
|
+
const event = {
|
|
2355
|
+
type: "desktop_message",
|
|
2356
|
+
message_id: messageId,
|
|
2357
|
+
kind,
|
|
2358
|
+
from: from_session,
|
|
2359
|
+
title: title || null,
|
|
2360
|
+
message,
|
|
2361
|
+
severity,
|
|
2362
|
+
created_at: new Date().toISOString(),
|
|
2363
|
+
...(meta && Object.keys(meta).length ? { meta } : {}),
|
|
2364
|
+
};
|
|
2365
|
+
|
|
2366
|
+
db.run(
|
|
2367
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
2368
|
+
VALUES (?1, ?2, 'send_desktop_message', 'user', ?3, ?4, ?5)`,
|
|
2369
|
+
[
|
|
2370
|
+
enforceUserId || null,
|
|
2371
|
+
callerAlias || null,
|
|
2372
|
+
target.user_id,
|
|
2373
|
+
JSON.stringify({
|
|
2374
|
+
message_id: messageId,
|
|
2375
|
+
to_username: target.username,
|
|
2376
|
+
from: from_session,
|
|
2377
|
+
severity,
|
|
2378
|
+
kind,
|
|
2379
|
+
title: title || null,
|
|
2380
|
+
meta_keys: meta && typeof meta === "object" ? Object.keys(meta) : [],
|
|
2381
|
+
}),
|
|
2382
|
+
effectiveNetId,
|
|
2383
|
+
],
|
|
2384
|
+
);
|
|
2385
|
+
// #1459 — pushUserEvent 在没有订阅者时静默 return,而这个工具既不写
|
|
2386
|
+
// inbox、也没有任何 user 级回读路径(/api/messages 只 SELECT FROM inbox)。
|
|
2387
|
+
// 也就是说用户 dashboard 关着的时候这条消息就永久没了。
|
|
2388
|
+
//
|
|
2389
|
+
// 这里先把**丢失变可见**:`ok` 仍表示「请求被接受、审计已落库」,
|
|
2390
|
+
// 新增 `delivered` 表示「此刻真的有人收到」。判据取自订阅者注册表,
|
|
2391
|
+
// 不是猜的。持久化 + 重连补投是另一条(按 user_id 寻址的新收件表,#1459)。
|
|
2392
|
+
//
|
|
2393
|
+
// 🔴 `reason` 报的是**观测到的事实**(此刻没有活订阅者),不是它的后果。
|
|
2394
|
+
// 今天「没有活订阅者」确实等于「丢了」,但持久化落地之后同一个事实会
|
|
2395
|
+
// 变成「已入库、等重连补投」。把值取成 no_live_subscriber 而不是
|
|
2396
|
+
// "lost"/"dropped",就是为了那天不用改写历史含义 —— 届时按需细分
|
|
2397
|
+
// (如 lost vs queued)即可,现在不预先把语义焊死。
|
|
2398
|
+
const delivered = hasUserSubscribers(effectiveNetId, target.user_id);
|
|
2399
|
+
pushUserEvent(effectiveNetId, target.user_id, event);
|
|
2400
|
+
|
|
2401
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
2402
|
+
ok: true,
|
|
2403
|
+
message_id: messageId,
|
|
2404
|
+
delivered,
|
|
2405
|
+
...(delivered ? {} : { reason: "no_live_subscriber" }),
|
|
2406
|
+
delivered_to_user_id: target.user_id,
|
|
2407
|
+
network_id: effectiveNetId,
|
|
2408
|
+
}) }] };
|
|
2409
|
+
},
|
|
2410
|
+
);
|
|
2411
|
+
|
|
2412
|
+
server.tool(
|
|
2413
|
+
"broadcast",
|
|
2414
|
+
"Send a message to multiple sessions.",
|
|
2415
|
+
{
|
|
2416
|
+
message: z.string().min(1).max(10000),
|
|
2417
|
+
filter_server: z.string().max(200).optional(),
|
|
2418
|
+
filter_status: z.string().max(50).optional(),
|
|
2419
|
+
network_id: z.string().max(200).optional().describe("Broadcast within a specific network"),
|
|
2420
|
+
},
|
|
2421
|
+
async ({ message, filter_server, filter_status, network_id: netId }) => {
|
|
2422
|
+
const effectiveNetId = getNetworkId(netId);
|
|
2423
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
2424
|
+
console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${effectiveNetId ? " [net=" + effectiveNetId.slice(0, 12) + "]" : ""}`);
|
|
1341
2425
|
let sql = "SELECT alias, node_id, network_id FROM sessions WHERE alias IS NOT NULL";
|
|
1342
2426
|
const params: any[] = [];
|
|
1343
2427
|
sql = addScope(sql, params, effectiveNetId);
|
|
@@ -1346,26 +2430,39 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1346
2430
|
|
|
1347
2431
|
const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
|
|
1348
2432
|
const ids: string[] = [];
|
|
2433
|
+
// #1440 ① — the push loop must walk the recipients that actually
|
|
2434
|
+
// got an inbox row, not every candidate target. Walking `targets`
|
|
2435
|
+
// announced a broadcast to nodes the lifecycle guard had just
|
|
2436
|
+
// skipped, i.e. nodes with nothing to fetch.
|
|
2437
|
+
const delivered: Array<{ alias: string; netId: string | null }> = [];
|
|
1349
2438
|
|
|
1350
2439
|
for (const t of targets) {
|
|
2440
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 6/6).
|
|
2441
|
+
// Broadcast skips non-active recipients silently rather than
|
|
2442
|
+
// failing the entire send — broadcast semantics are best-effort
|
|
2443
|
+
// per-recipient and a stopped node simply gets nothing.
|
|
2444
|
+
const netId = effectiveNetId ?? t.network_id ?? null;
|
|
2445
|
+
const lc = assertNodeActive(t.alias, netId);
|
|
2446
|
+
if (!lc.ok) continue;
|
|
1351
2447
|
const id = uuidv4();
|
|
1352
2448
|
db.run(
|
|
1353
2449
|
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
|
|
1354
2450
|
VALUES (?1, ?2, ?3, 'broadcast', 'normal', ?4, 'hub', ?5)`,
|
|
1355
|
-
[id, t.alias, t.node_id ?? null, message,
|
|
2451
|
+
[id, t.alias, t.node_id ?? null, message, netId]
|
|
1356
2452
|
);
|
|
1357
2453
|
ids.push(id);
|
|
2454
|
+
delivered.push({ alias: t.alias, netId });
|
|
1358
2455
|
}
|
|
1359
2456
|
|
|
1360
|
-
for (const
|
|
1361
|
-
pushEvent(
|
|
2457
|
+
for (const d of delivered) {
|
|
2458
|
+
pushEvent(d.alias, { type: "broadcast", inbox_count: pendingInboxCount(d.alias, d.netId) }, d.netId);
|
|
1362
2459
|
}
|
|
1363
2460
|
|
|
1364
2461
|
return {
|
|
1365
2462
|
content: [
|
|
1366
2463
|
{
|
|
1367
2464
|
type: "text" as const,
|
|
1368
|
-
text: JSON.stringify({ ok: true, recipients:
|
|
2465
|
+
text: JSON.stringify({ ok: true, recipients: ids.length, message_ids: ids }),
|
|
1369
2466
|
},
|
|
1370
2467
|
],
|
|
1371
2468
|
};
|
|
@@ -1458,15 +2555,34 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1458
2555
|
"update_node_config",
|
|
1459
2556
|
"Set the desired per-node config (model + flags) and push a doorbell to the node. The node pulls + validates + applies (hot or restart per field tier). RFC-024.",
|
|
1460
2557
|
{
|
|
1461
|
-
|
|
2558
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
1462
2559
|
base_revision: z.number().int().min(0).describe("Current revision per the dashboard's last GET — 409 if hub's current revision differs."),
|
|
1463
2560
|
patch: z.object({
|
|
1464
2561
|
model: z.string().max(200).optional(),
|
|
1465
2562
|
flags: z.record(z.unknown()).optional(),
|
|
2563
|
+
// #260 P5 — channel enable/disable. Restart-tier field (agent-node
|
|
2564
|
+
// reads config.channels once at boot to fork per-channel workers,
|
|
2565
|
+
// so the swap takes effect via process restart). Not
|
|
2566
|
+
// SECURITY_SENSITIVE — this is a lifecycle op, gated by SEC-1
|
|
2567
|
+
// (network scope) only.
|
|
2568
|
+
//
|
|
2569
|
+
// Deliberately `z.array(z.unknown()).max(16)` (mirrors flags's
|
|
2570
|
+
// `z.record(z.unknown())`): trust nothing at the wire boundary,
|
|
2571
|
+
// narrow the same way `narrowChannelsPatch` narrows raw untrusted
|
|
2572
|
+
// JSON (typeof + allowlist + dedup + case-fold). A strict
|
|
2573
|
+
// `z.array(z.string())` would fail-fast on a single non-string
|
|
2574
|
+
// entry, but the wire contract wants junk silently dropped so a
|
|
2575
|
+
// dashboard fat-finger doesn't turn into a 400 the user has to
|
|
2576
|
+
// interpret. validatePatch then re-rejects if the caller bypassed
|
|
2577
|
+
// narrowing.
|
|
2578
|
+
channels: z.array(z.unknown()).max(16).optional(),
|
|
1466
2579
|
}).describe("Fields to update. Empty patch → no-op (use restart_node for that)."),
|
|
1467
2580
|
network_id: z.string().max(200).optional(),
|
|
1468
2581
|
},
|
|
1469
|
-
async ({ node_id
|
|
2582
|
+
async ({ node_id, child_node_id, base_revision: baseRev, patch, network_id: clientNetId }) => {
|
|
2583
|
+
const idArg = resolveNodeIdArg({ node_id, child_node_id });
|
|
2584
|
+
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
2585
|
+
const nodeId = idArg.node_id;
|
|
1470
2586
|
const effectiveNetId = getNetworkId(clientNetId);
|
|
1471
2587
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
1472
2588
|
|
|
@@ -1480,6 +2596,50 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1480
2596
|
|
|
1481
2597
|
const model = typeof patch.model === "string" ? patch.model : undefined;
|
|
1482
2598
|
const flags = (patch.flags && typeof patch.flags === "object") ? patch.flags as Record<string, unknown> : {};
|
|
2599
|
+
// Narrow untrusted `channels` at the boundary (typeof + allowlist +
|
|
2600
|
+
// dedup + case-fold), and distinguish two very different cases
|
|
2601
|
+
// that both narrow to `[]`:
|
|
2602
|
+
// (a) `patch.channels === []` (explicit "disable all editable
|
|
2603
|
+
// channels"). Downstream must proceed and write channels=[]
|
|
2604
|
+
// so the node's next restart forks no workers.
|
|
2605
|
+
// (b) `patch.channels === ["commhub"]` or similar — the caller
|
|
2606
|
+
// sent items but every single one was invalid (dashboard PR
|
|
2607
|
+
// #31 still ships commhub, and a typo like "telegarm" would
|
|
2608
|
+
// hit the same path). Downstream MUST NOT treat this as
|
|
2609
|
+
// (a) — silently converting to disable-all would nuke the
|
|
2610
|
+
// user's existing telegram/feishu workers.
|
|
2611
|
+
//
|
|
2612
|
+
// The reject error is `channels_all_invalid` so the dashboard
|
|
2613
|
+
// can surface the mismatch instead of showing a false success.
|
|
2614
|
+
let channels: string[] | undefined = undefined;
|
|
2615
|
+
if (patch.channels !== undefined) {
|
|
2616
|
+
if (!Array.isArray(patch.channels)) {
|
|
2617
|
+
// Zod already permits arrays only; belt+braces if it drifts.
|
|
2618
|
+
return {
|
|
2619
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_patch", field: "channels", reason: "must be an array" }) }],
|
|
2620
|
+
};
|
|
2621
|
+
}
|
|
2622
|
+
const narrowed = narrowChannelsPatch(patch.channels) ?? [];
|
|
2623
|
+
if (patch.channels.length === 0) {
|
|
2624
|
+
channels = []; // (a) explicit disable-all
|
|
2625
|
+
} else if (narrowed.length === 0) {
|
|
2626
|
+
// (b) every entry was invalid — refuse to write. Distinct
|
|
2627
|
+
// error so the dashboard can surface the failure.
|
|
2628
|
+
return {
|
|
2629
|
+
content: [{
|
|
2630
|
+
type: "text" as const,
|
|
2631
|
+
text: JSON.stringify({
|
|
2632
|
+
ok: false,
|
|
2633
|
+
error: "channels_all_invalid",
|
|
2634
|
+
requested: patch.channels,
|
|
2635
|
+
message: "every requested channel is unknown or unsupported; use an explicit empty array to disable-all",
|
|
2636
|
+
}),
|
|
2637
|
+
}],
|
|
2638
|
+
};
|
|
2639
|
+
} else {
|
|
2640
|
+
channels = narrowed;
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
1483
2643
|
|
|
1484
2644
|
// SEC-2 (final policy 2026-06-28) — security-sensitive flags
|
|
1485
2645
|
// (permissionMode / dangerouslySkipPermissions / teammateMode)
|
|
@@ -1506,7 +2666,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1506
2666
|
};
|
|
1507
2667
|
}
|
|
1508
2668
|
|
|
1509
|
-
const validationFail = validatePatch(model, flags);
|
|
2669
|
+
const validationFail = validatePatch(model, flags, channels);
|
|
1510
2670
|
if (validationFail) {
|
|
1511
2671
|
return {
|
|
1512
2672
|
content: [{
|
|
@@ -1584,8 +2744,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1584
2744
|
|
|
1585
2745
|
// Compute apply_mode + persist + push doorbell.
|
|
1586
2746
|
const updateId = `cu_${uuidv4()}`;
|
|
1587
|
-
const applyMode = computeApplyMode(model, flags);
|
|
1588
|
-
const patchJson = JSON.stringify({
|
|
2747
|
+
const applyMode = computeApplyMode(model, flags, channels);
|
|
2748
|
+
const patchJson = JSON.stringify({
|
|
2749
|
+
...(model !== undefined ? { model } : {}),
|
|
2750
|
+
flags,
|
|
2751
|
+
...(channels !== undefined ? { channels } : {}),
|
|
2752
|
+
});
|
|
1589
2753
|
const networkId = node.network_id || "default";
|
|
1590
2754
|
db.run(
|
|
1591
2755
|
`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)`,
|
|
@@ -1727,10 +2891,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1727
2891
|
"restart_node",
|
|
1728
2892
|
"Trigger a node restart without changing config. RFC-024 Vincent 2026-06-28 increment. Network-scoped (SEC-1); member+ role suffices (lifecycle ops are not privilege elevation).",
|
|
1729
2893
|
{
|
|
1730
|
-
|
|
2894
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
1731
2895
|
network_id: z.string().max(200).optional(),
|
|
1732
2896
|
},
|
|
1733
|
-
async ({ node_id
|
|
2897
|
+
async ({ node_id, child_node_id, network_id: clientNetId }) => {
|
|
2898
|
+
const idArg = resolveNodeIdArg({ node_id, child_node_id });
|
|
2899
|
+
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
2900
|
+
const nodeId = idArg.node_id;
|
|
1734
2901
|
const effectiveNetId = getNetworkId(clientNetId);
|
|
1735
2902
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
1736
2903
|
|
|
@@ -1762,10 +2929,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1762
2929
|
}
|
|
1763
2930
|
const updateId = `cu_${uuidv4()}`;
|
|
1764
2931
|
const networkId = node.network_id || "default";
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
2932
|
+
// RFC-027 PR1.2a latent fix (#346 ack): restart_node must reset
|
|
2933
|
+
// lifecycle_state to 'active'. Otherwise a node that was previously
|
|
2934
|
+
// stop_node'd → 'stopped' would, after restart, stay marked
|
|
2935
|
+
// 'stopped' in the nodes table → the 6 MCP + 2 REST inbox guards
|
|
2936
|
+
// would refuse every routing attempt → node silently unreachable
|
|
2937
|
+
// (no error to operator, just no traffic). Pair the schema flip
|
|
2938
|
+
// with the config_updates INSERT inside one tx so we don't
|
|
2939
|
+
// half-commit if the dispatch INSERT throws.
|
|
2940
|
+
db.transaction(() => {
|
|
2941
|
+
db.run(
|
|
2942
|
+
`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)`,
|
|
2943
|
+
[updateId, nodeId, networkId, node.config_revision || 0, Date.now(), callerTokenId || "unknown"],
|
|
2944
|
+
);
|
|
2945
|
+
db.run(
|
|
2946
|
+
`UPDATE nodes SET lifecycle_state = 'active' WHERE node_id = ?1`,
|
|
2947
|
+
[nodeId],
|
|
2948
|
+
);
|
|
2949
|
+
});
|
|
1769
2950
|
pushEvent(node.alias, { type: "restart", update_id: updateId }, networkId);
|
|
1770
2951
|
return {
|
|
1771
2952
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update_id: updateId, apply_mode: "restart_only" }) }],
|
|
@@ -1798,6 +2979,143 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1798
2979
|
throw e;
|
|
1799
2980
|
};
|
|
1800
2981
|
|
|
2982
|
+
// RFC-026 §9.2.1 / #338 PR2 — list_host_supervisors.
|
|
2983
|
+
// Surfaces host_supervisor daemons in the caller's network with
|
|
2984
|
+
// online flag + declared capabilities. Replaces the prior `node_daemon_`
|
|
2985
|
+
// prefix heuristic (dashboard /api/anet/node-create) + the role-extract
|
|
2986
|
+
// path on /api/nodes (#337). Member脱敏 strips host_telemetry IP /
|
|
2987
|
+
// cpu / mem (per RFC-026 §6 #3 — daemons can leak internal topology
|
|
2988
|
+
// to non-admin readers). Revoked daemon ntoks filtered out via
|
|
2989
|
+
// join on api_tokens.revoked_at.
|
|
2990
|
+
server.tool(
|
|
2991
|
+
"list_host_supervisors",
|
|
2992
|
+
"List host_supervisor daemon nodes in the caller's network (online status + runtimes_supported + allowed_secret_keys + telemetry; member-脱敏). RFC-026 §9.2.1.",
|
|
2993
|
+
{
|
|
2994
|
+
network_id: z.string().max(200).optional(),
|
|
2995
|
+
},
|
|
2996
|
+
async ({ network_id: clientNetId }) => {
|
|
2997
|
+
// SEC-1 — use resolveReadScope, NOT getNetworkId. getNetworkId is
|
|
2998
|
+
// for write-tool helpers and pairs with `canWrite`; READ tools that
|
|
2999
|
+
// bypass canWrite and trust getNetworkId leak across tenants
|
|
3000
|
+
// (PR2 v1 BLOCKER per 通信龙 audit — utok_ caller with
|
|
3001
|
+
// enforceNetworkId=null could pass any network_id and read daemon
|
|
3002
|
+
// names + allowed_secret_keys for tenants they're not a member of).
|
|
3003
|
+
// resolveReadScope checks network_members for the user + denies
|
|
3004
|
+
// on non-membership, mirroring REST resolveRestNetworkScope.
|
|
3005
|
+
const readScope = resolveReadScope(clientNetId);
|
|
3006
|
+
if (readScope.denied) {
|
|
3007
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
3008
|
+
}
|
|
3009
|
+
// Caller role for 脱敏 decision (admin/owner = full, others = masked).
|
|
3010
|
+
// Use the resolved scope's networkId (the verified one), not the
|
|
3011
|
+
// unchecked clientNetId.
|
|
3012
|
+
const scopedNetId = readScope.networkId ?? null;
|
|
3013
|
+
const callerRole = enforceUserId && scopedNetId
|
|
3014
|
+
? getUserNetworkRole(enforceUserId, scopedNetId)
|
|
3015
|
+
: null;
|
|
3016
|
+
// Network-bound tokens get full telemetry within their bound network
|
|
3017
|
+
// (already gate-locked by enforceNetworkId path of resolveReadScope).
|
|
3018
|
+
const isPrivileged = callerRole === "admin" || callerRole === "owner" || (!enforceUserId);
|
|
3019
|
+
|
|
3020
|
+
// Pull candidate daemons. Active-token EXISTS subquery handles BOTH
|
|
3021
|
+
// revoked daemon ntoks (revoked_at set) AND DELETEd token rows
|
|
3022
|
+
// (the standard revokeToken path deletes the row, not flagging
|
|
3023
|
+
// revoked_at — PR2 v1 SHOULD-FIX per 通信龙 audit). EXISTS naturally
|
|
3024
|
+
// dedupes if a daemon ever had multiple tokens (e.g. rotation; nit ⚪).
|
|
3025
|
+
let sql = `
|
|
3026
|
+
SELECT
|
|
3027
|
+
n.node_id, n.alias, n.hostname, n.network_id,
|
|
3028
|
+
n.runtimes_supported, n.allowed_secret_keys,
|
|
3029
|
+
n.created_at, n.updated_at,
|
|
3030
|
+
s.last_seen_at AS session_last_seen,
|
|
3031
|
+
s.status AS session_status,
|
|
3032
|
+
s.cpu_cores AS session_cpu_cores,
|
|
3033
|
+
s.mem_total_gb AS session_mem_total_gb,
|
|
3034
|
+
s.ip AS session_ip,
|
|
3035
|
+
n.config_snapshot
|
|
3036
|
+
FROM nodes n
|
|
3037
|
+
LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
|
|
3038
|
+
WHERE EXISTS (
|
|
3039
|
+
SELECT 1 FROM api_tokens t
|
|
3040
|
+
WHERE t.network_id = n.network_id
|
|
3041
|
+
AND t.name = 'node:' || n.alias
|
|
3042
|
+
AND t.revoked_at IS NULL
|
|
3043
|
+
)
|
|
3044
|
+
`;
|
|
3045
|
+
const sqlParams: any[] = [];
|
|
3046
|
+
sql = addReadScope(sql, sqlParams, readScope, "n.network_id");
|
|
3047
|
+
sql += ` ORDER BY n.updated_at DESC`;
|
|
3048
|
+
const rows = db.all<Record<string, any>>(sql, ...sqlParams);
|
|
3049
|
+
|
|
3050
|
+
// Filter to role=host_supervisor (read from config_snapshot;
|
|
3051
|
+
// schema-promoted columns runtimes_supported/allowed_secret_keys
|
|
3052
|
+
// are pre-extracted but role isn't a first-class column).
|
|
3053
|
+
const nowMs = Date.now();
|
|
3054
|
+
// 心跳周期是 3 分钟(agent-node/src/cli.ts 的 report_status 定时器),
|
|
3055
|
+
// 窗口必须大于它,否则每次心跳后的 60s~180s 之间必然抖成 offline
|
|
3056
|
+
// (通信评审牛 #1279 独审④)。取 5min,与仓内既有 stale 口径一致。
|
|
3057
|
+
const ONLINE_MS = 5 * 60_000;
|
|
3058
|
+
const daemons = rows
|
|
3059
|
+
.map(r => {
|
|
3060
|
+
let snapRole: string | null = null;
|
|
3061
|
+
if (r.config_snapshot) {
|
|
3062
|
+
try {
|
|
3063
|
+
const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
|
|
3064
|
+
snapRole = typeof parsed?.role === "string" ? parsed.role : null;
|
|
3065
|
+
} catch { /* malformed snapshot — role stays null */ }
|
|
3066
|
+
}
|
|
3067
|
+
return { row: r, role: snapRole };
|
|
3068
|
+
})
|
|
3069
|
+
.filter(({ role }) => role === "host_supervisor")
|
|
3070
|
+
.map(({ row: r }) => {
|
|
3071
|
+
// online = sessions.last_seen_at within ONLINE_MS
|
|
3072
|
+
let online = false;
|
|
3073
|
+
let lastSeenAt: string | null = null;
|
|
3074
|
+
if (r.session_last_seen) {
|
|
3075
|
+
lastSeenAt = r.session_last_seen;
|
|
3076
|
+
const t = parseDbTimestampMs(r.session_last_seen);
|
|
3077
|
+
if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
|
|
3078
|
+
}
|
|
3079
|
+
// Parse self-declare arrays (default to [] for pre-PR2 daemons)
|
|
3080
|
+
let runtimes: string[] = [];
|
|
3081
|
+
let secrets: string[] = [];
|
|
3082
|
+
try {
|
|
3083
|
+
if (r.runtimes_supported) {
|
|
3084
|
+
const parsed = JSON.parse(r.runtimes_supported);
|
|
3085
|
+
runtimes = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
|
|
3086
|
+
}
|
|
3087
|
+
} catch { /* malformed — empty */ }
|
|
3088
|
+
try {
|
|
3089
|
+
if (r.allowed_secret_keys) {
|
|
3090
|
+
const parsed = JSON.parse(r.allowed_secret_keys);
|
|
3091
|
+
secrets = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
|
|
3092
|
+
}
|
|
3093
|
+
} catch { /* malformed — empty */ }
|
|
3094
|
+
// host_telemetry — member脱敏 drops IP/cpu/mem
|
|
3095
|
+
const telemetry: Record<string, unknown> = {
|
|
3096
|
+
alert_level: online ? "green" : "gray",
|
|
3097
|
+
};
|
|
3098
|
+
if (isPrivileged) {
|
|
3099
|
+
telemetry.cpu_cores = r.session_cpu_cores ?? null;
|
|
3100
|
+
telemetry.mem_gb = r.session_mem_total_gb ?? null;
|
|
3101
|
+
telemetry.ip_internal = r.session_ip ?? null;
|
|
3102
|
+
}
|
|
3103
|
+
return {
|
|
3104
|
+
daemon_node_id: r.node_id,
|
|
3105
|
+
alias: r.alias,
|
|
3106
|
+
hostname: r.hostname,
|
|
3107
|
+
online,
|
|
3108
|
+
last_seen_at: lastSeenAt,
|
|
3109
|
+
runtimes_supported: runtimes,
|
|
3110
|
+
allowed_secret_keys: secrets,
|
|
3111
|
+
host_telemetry: telemetry,
|
|
3112
|
+
};
|
|
3113
|
+
});
|
|
3114
|
+
|
|
3115
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, daemons, count: daemons.length }) }] };
|
|
3116
|
+
},
|
|
3117
|
+
);
|
|
3118
|
+
|
|
1801
3119
|
// §2.5 step 1+2 — dashboard-facing tool. Validates spec, mint
|
|
1802
3120
|
// child-ntok, stash env_blob in pendingEnvBlobs Map, write request
|
|
1803
3121
|
// row (metadata only — no env_blob in SQL), pushEvent doorbell.
|
|
@@ -1809,7 +3127,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1809
3127
|
node_spec: z.object({
|
|
1810
3128
|
name: z.string().min(1).max(64),
|
|
1811
3129
|
runtime: z.string().min(1).max(64),
|
|
1812
|
-
model: z.string().min(1).max(100),
|
|
3130
|
+
model: z.string().min(1).max(100).optional().nullable(),
|
|
1813
3131
|
flags: z.record(z.unknown()).optional(),
|
|
1814
3132
|
env_refs: z.array(z.string().max(64)).optional(),
|
|
1815
3133
|
channels: z.array(z.unknown()).optional(),
|
|
@@ -1840,21 +3158,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1840
3158
|
}
|
|
1841
3159
|
|
|
1842
3160
|
// Read daemon's host_supervisor capability + allowlist from its
|
|
1843
|
-
// last reported config_snapshot.
|
|
1844
|
-
// (
|
|
1845
|
-
//
|
|
1846
|
-
//
|
|
3161
|
+
// last reported config_snapshot. PR3 (#338) canonical path is
|
|
3162
|
+
// `daemon_capabilities.runtimes_supported` (RFC-026 §9.3).
|
|
3163
|
+
// Pre-PR3 daemons (preview.10 and earlier) place these at the
|
|
3164
|
+
// TOP level of the snapshot rather than nested — those reads
|
|
3165
|
+
// return undefined here and `daemonAllowedRuntimes` stays null
|
|
3166
|
+
// (permissive — no allowlist enforcement); they fall back to
|
|
3167
|
+
// §4.2.2 structural validation only, identical to pre-PR3
|
|
3168
|
+
// behavior. No regression on in-flight daemons.
|
|
1847
3169
|
let daemonAllowList = new Set<string>();
|
|
1848
3170
|
let daemonAllowedRuntimes: string[] | null = null;
|
|
1849
3171
|
try {
|
|
1850
3172
|
const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
|
|
1851
|
-
|
|
1852
|
-
|
|
3173
|
+
const caps = snap?.daemon_capabilities;
|
|
3174
|
+
if (Array.isArray(caps?.allowed_secret_keys)) {
|
|
3175
|
+
daemonAllowList = new Set(caps.allowed_secret_keys);
|
|
1853
3176
|
}
|
|
1854
|
-
if (Array.isArray(
|
|
1855
|
-
daemonAllowedRuntimes =
|
|
3177
|
+
if (Array.isArray(caps?.runtimes_supported)) {
|
|
3178
|
+
daemonAllowedRuntimes = caps.runtimes_supported;
|
|
1856
3179
|
}
|
|
1857
|
-
} catch { /* permissive
|
|
3180
|
+
} catch { /* permissive fallback */ }
|
|
1858
3181
|
|
|
1859
3182
|
// §4.2.2 — structural validation (catches name/runtime/model/
|
|
1860
3183
|
// flag injection at the hub edge). Daemon repeats this; double
|
|
@@ -1960,7 +3283,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1960
3283
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
|
|
1961
3284
|
[
|
|
1962
3285
|
requestId, daemon_node_id, node_spec.name, networkIdForChild,
|
|
1963
|
-
node_spec.runtime, node_spec.model, JSON.stringify(node_spec.flags || {}),
|
|
3286
|
+
node_spec.runtime, node_spec.model ?? null, JSON.stringify(node_spec.flags || {}),
|
|
1964
3287
|
JSON.stringify(envKeys), childTokenId, Date.now(), callerTokenId || "unknown",
|
|
1965
3288
|
],
|
|
1966
3289
|
);
|
|
@@ -1970,9 +3293,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1970
3293
|
// SSE handler resolves the rest via MCP call.
|
|
1971
3294
|
pushEvent(daemon.alias, { type: "create_node", request_id: requestId }, networkIdForChild);
|
|
1972
3295
|
|
|
1973
|
-
// §4.5 audit —
|
|
3296
|
+
// §4.5 audit — request queued and doorbell attempted. pushEvent is
|
|
3297
|
+
// fire-and-forget, so actual SSE delivery is intentionally not claimed.
|
|
1974
3298
|
auditCreateNode({
|
|
1975
|
-
action: "
|
|
3299
|
+
action: "create_node_dispatch_attempted",
|
|
1976
3300
|
user_id: enforceUserId,
|
|
1977
3301
|
network_id: networkIdForChild,
|
|
1978
3302
|
target_id: requestId,
|
|
@@ -1980,7 +3304,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1980
3304
|
daemon_node_id,
|
|
1981
3305
|
child_name: node_spec.name,
|
|
1982
3306
|
runtime: node_spec.runtime,
|
|
1983
|
-
model: node_spec.model,
|
|
3307
|
+
model: node_spec.model ?? null,
|
|
1984
3308
|
flag_keys: Object.keys(node_spec.flags || {}),
|
|
1985
3309
|
env_keys: envKeys,
|
|
1986
3310
|
},
|
|
@@ -1992,6 +3316,78 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1992
3316
|
},
|
|
1993
3317
|
);
|
|
1994
3318
|
|
|
3319
|
+
// #1362 — daemon-side compensation for missed SSE doorbells. pushEvent is
|
|
3320
|
+
// intentionally best-effort; after a daemon connects or reconnects, it pulls
|
|
3321
|
+
// any still-pending requests bound to its own daemon token.
|
|
3322
|
+
server.tool(
|
|
3323
|
+
"list_my_pending_create_requests",
|
|
3324
|
+
"Daemon lists pending create-node requests bound to itself for SSE reconnect compensation. RFC-026/#1362.",
|
|
3325
|
+
{},
|
|
3326
|
+
async () => {
|
|
3327
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
3328
|
+
if (!callerDaemon.ok) {
|
|
3329
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
3330
|
+
}
|
|
3331
|
+
const rows = db.all<{ request_id: string; child_name: string; created_at: number }>(
|
|
3332
|
+
`SELECT request_id, child_name, created_at
|
|
3333
|
+
FROM node_create_requests
|
|
3334
|
+
WHERE daemon_node_id = ?1
|
|
3335
|
+
AND network_id = ?2
|
|
3336
|
+
AND status = 'pending'
|
|
3337
|
+
ORDER BY created_at ASC`,
|
|
3338
|
+
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
3339
|
+
);
|
|
3340
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: rows.length, requests: rows }) }] };
|
|
3341
|
+
},
|
|
3342
|
+
);
|
|
3343
|
+
|
|
3344
|
+
// #1448 — stop/delete/start 门铃的 SSE 重连补偿,镜像 create 的
|
|
3345
|
+
// list_my_pending_create_requests(#1394)。pushEvent(push.ts) 是纯内存
|
|
3346
|
+
// fan-out:派发门铃那刻若 daemon 无 live SSE 订阅者(hub 重启/网络抖/boot
|
|
3347
|
+
// 窗口)就静默丢弃、不入队,于是 node_stop_requests/node_start_requests 的行
|
|
3348
|
+
// 永远停在 pending、节点卡死 stopping/deleting/starting。daemon 每次 SSE
|
|
3349
|
+
// connected 调本工具把漏掉的门铃拉回来重放(reconcilePendingLifecycleRequestsOnConnect)。
|
|
3350
|
+
//
|
|
3351
|
+
// 统一一个工具返回两张表的 pending 行(stop 表用 action 区分 stop/delete),
|
|
3352
|
+
// 省一次往返。鉴权同 create:token-bound daemon 身份(alias 不是安全边界),
|
|
3353
|
+
// 只返回绑定到本 daemon + 本网络的 pending 行。
|
|
3354
|
+
server.tool(
|
|
3355
|
+
"list_my_pending_lifecycle_requests",
|
|
3356
|
+
"Daemon lists pending stop/delete/start requests bound to itself for SSE reconnect compensation. Mirrors list_my_pending_create_requests (#1394/#1448).",
|
|
3357
|
+
{},
|
|
3358
|
+
async () => {
|
|
3359
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
3360
|
+
if (!callerDaemon.ok) {
|
|
3361
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
3362
|
+
}
|
|
3363
|
+
const stop_requests = db.all<{ request_id: string; action: string; child_node_id: string; child_alias: string; created_at: number }>(
|
|
3364
|
+
`SELECT request_id, action, child_node_id, child_alias, created_at
|
|
3365
|
+
FROM node_stop_requests
|
|
3366
|
+
WHERE daemon_node_id = ?1
|
|
3367
|
+
AND network_id = ?2
|
|
3368
|
+
AND status = 'pending'
|
|
3369
|
+
ORDER BY created_at ASC`,
|
|
3370
|
+
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
3371
|
+
);
|
|
3372
|
+
const start_requests = db.all<{ request_id: string; child_node_id: string; child_alias: string; created_at: number }>(
|
|
3373
|
+
`SELECT request_id, child_node_id, child_alias, created_at
|
|
3374
|
+
FROM node_start_requests
|
|
3375
|
+
WHERE daemon_node_id = ?1
|
|
3376
|
+
AND network_id = ?2
|
|
3377
|
+
AND status = 'pending'
|
|
3378
|
+
ORDER BY created_at ASC`,
|
|
3379
|
+
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
3380
|
+
);
|
|
3381
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
3382
|
+
ok: true,
|
|
3383
|
+
stop_count: stop_requests.length,
|
|
3384
|
+
start_count: start_requests.length,
|
|
3385
|
+
stop_requests,
|
|
3386
|
+
start_requests,
|
|
3387
|
+
}) }] };
|
|
3388
|
+
},
|
|
3389
|
+
);
|
|
3390
|
+
|
|
1995
3391
|
// §2.5 step 3 — daemon-facing pull. Returns full spec + env_blob +
|
|
1996
3392
|
// child_ntok in one shot. Map evicted on take (one-shot consume).
|
|
1997
3393
|
//
|
|
@@ -2044,7 +3440,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2044
3440
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "env_blob_unavailable" }) }] };
|
|
2045
3441
|
}
|
|
2046
3442
|
// Hydrate spec from row.
|
|
2047
|
-
const specRow = db.get<{ child_name: string; runtime: string; model: string; flags_json: string }>(
|
|
3443
|
+
const specRow = db.get<{ child_name: string; runtime: string; model: string | null; flags_json: string }>(
|
|
2048
3444
|
`SELECT child_name, runtime, model, flags_json FROM node_create_requests WHERE request_id = ?1`,
|
|
2049
3445
|
request_id,
|
|
2050
3446
|
);
|
|
@@ -2084,14 +3480,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2084
3480
|
// record fork-side info.
|
|
2085
3481
|
server.tool(
|
|
2086
3482
|
"ack_create_request",
|
|
2087
|
-
"Daemon acks a create-node request (called after fork). status='started'
|
|
3483
|
+
"Daemon acks a create-node request (called after fork). status='started' | 'failed' | 'rejected' | 'runtime_capability_check_failed'. RFC-026 §9.3 D2.",
|
|
2088
3484
|
{
|
|
2089
3485
|
request_id: z.string().min(1).max(200),
|
|
2090
|
-
|
|
3486
|
+
// RFC-026 §9.3 D2 — runtime_capability_check_failed signals the
|
|
3487
|
+
// daemon spawned the child OK but it died within FAIL_FAST_MS
|
|
3488
|
+
// (5s in agent-node v2.5.0-preview.11+), indicating a
|
|
3489
|
+
// declaration↔reality gap on this daemon's runtimes_supported.
|
|
3490
|
+
// Treated terminal like 'failed' but fires a distinct audit_log
|
|
3491
|
+
// action so dashboards can highlight "lying daemons" separately
|
|
3492
|
+
// from generic spawn failures.
|
|
3493
|
+
status: z.enum(["started", "failed", "rejected", "runtime_capability_check_failed"]),
|
|
2091
3494
|
error: z.string().max(1000).optional(),
|
|
2092
3495
|
child_pid: z.number().int().optional(),
|
|
3496
|
+
runtime: z.string().max(64).optional(), // populated by daemon when status=runtime_capability_check_failed
|
|
2093
3497
|
},
|
|
2094
|
-
async ({ request_id, status, error: ackError, child_pid: _pid }) => {
|
|
3498
|
+
async ({ request_id, status, error: ackError, child_pid: _pid, runtime: ackRuntime }) => {
|
|
2095
3499
|
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
2096
3500
|
if (!callerDaemon.ok) {
|
|
2097
3501
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
@@ -2117,7 +3521,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2117
3521
|
);
|
|
2118
3522
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "awaiting_register" }) }] };
|
|
2119
3523
|
}
|
|
2120
|
-
// failed / rejected — revoke
|
|
3524
|
+
// failed / rejected / runtime_capability_check_failed — revoke
|
|
3525
|
+
// child-ntok + mark request terminal.
|
|
2121
3526
|
if (row.child_token_id) {
|
|
2122
3527
|
db.run(`UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = ?1 AND revoked_at IS NULL`, [row.child_token_id]);
|
|
2123
3528
|
}
|
|
@@ -2125,9 +3530,1256 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2125
3530
|
`UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3 WHERE request_id = ?4 AND status IN ('pending', 'delivered')`,
|
|
2126
3531
|
[status, ackError || null, ackedAt, request_id],
|
|
2127
3532
|
);
|
|
3533
|
+
// RFC-026 §9.3 D2 — surface declaration↔reality gap on a
|
|
3534
|
+
// distinct audit_log action so dashboards / operators can spot
|
|
3535
|
+
// chronically-lying daemons separate from generic spawn-failed.
|
|
3536
|
+
if (status === "runtime_capability_check_failed") {
|
|
3537
|
+
auditCreateNode({
|
|
3538
|
+
action: "daemon_capability_lied",
|
|
3539
|
+
user_id: null,
|
|
3540
|
+
network_id: row.network_id,
|
|
3541
|
+
target_id: request_id,
|
|
3542
|
+
detail: {
|
|
3543
|
+
daemon_node_id: row.daemon_node_id,
|
|
3544
|
+
runtime: ackRuntime || null,
|
|
3545
|
+
error: ackError ? ackError.slice(0, 500) : null,
|
|
3546
|
+
acked_at: ackedAt,
|
|
3547
|
+
},
|
|
3548
|
+
});
|
|
3549
|
+
}
|
|
3550
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
|
|
3551
|
+
},
|
|
3552
|
+
);
|
|
3553
|
+
|
|
3554
|
+
// ─── RFC-027 §2 — stop/delete node lifecycle ───────────────────────
|
|
3555
|
+
//
|
|
3556
|
+
// Two user-facing MCP tools (stop_node, delete_node) + two daemon-
|
|
3557
|
+
// facing tools (get_stop_request, ack_stop_request). State machine
|
|
3558
|
+
// per §2.3 — nodes.lifecycle_state ∈ {active, stopping, stopped,
|
|
3559
|
+
// deleting}. row-gone implies deleted. Security per §4:
|
|
3560
|
+
// §4.1 SEC-1: trust-root SQL join, not getNetworkId
|
|
3561
|
+
// §4.2 D6: delete_node refuses target.role==host_supervisor
|
|
3562
|
+
// §4.3 D4: in-flight inbox default-refuse + force+audit
|
|
3563
|
+
// §4.4 D7: daemon writes backup chmod 700, sweeper真删
|
|
3564
|
+
// §4.5 D8: audit_log in same SQLite tx as state UPDATE
|
|
3565
|
+
//
|
|
3566
|
+
// The dispatcher logic shared by stop_node + delete_node lives in
|
|
3567
|
+
// dispatchStopOrDelete; the two tool handlers thin-wrap it with
|
|
3568
|
+
// their respective action discriminator.
|
|
3569
|
+
type DispatchAction = "stop" | "delete";
|
|
3570
|
+
type DispatchArgs = {
|
|
3571
|
+
action: DispatchAction;
|
|
3572
|
+
child_node_id: string;
|
|
3573
|
+
daemon_node_id: string;
|
|
3574
|
+
force: boolean;
|
|
3575
|
+
grace_seconds: number;
|
|
3576
|
+
delete_config: boolean;
|
|
3577
|
+
confirm_alias?: string;
|
|
3578
|
+
};
|
|
3579
|
+
// RFC-027 PR2 prereq — auto-resolve daemon_node_id from child_node_id.
|
|
3580
|
+
// The dashboard (and most callers of stop_node / delete_node) shouldn't
|
|
3581
|
+
// need to track the daemon→child mapping themselves; the hub has it on
|
|
3582
|
+
// node_create_requests at child creation time. Returns null when no
|
|
3583
|
+
// creation record exists (orphan node row OR pre-RFC-026 node) — caller
|
|
3584
|
+
// must then pass daemon_node_id explicitly. node_id derivation:
|
|
3585
|
+
// `node_${request_id.replace(/^cr_/,"")}` (see create-node-daemon.ts
|
|
3586
|
+
// and PR1 BLOCKER-1 fix), so we reverse it: `cr_${node_id.slice(5)}`.
|
|
3587
|
+
const resolveDaemonForChild = (child_node_id: string): string | null => {
|
|
3588
|
+
if (!child_node_id.startsWith("node_")) return null;
|
|
3589
|
+
const requestId = `cr_${child_node_id.slice(5)}`;
|
|
3590
|
+
const row = db.get<{ daemon_node_id: string }>(
|
|
3591
|
+
`SELECT daemon_node_id FROM node_create_requests WHERE request_id = ?1`,
|
|
3592
|
+
requestId,
|
|
3593
|
+
);
|
|
3594
|
+
return row?.daemon_node_id ?? null;
|
|
3595
|
+
};
|
|
3596
|
+
|
|
3597
|
+
const dispatchStopOrDelete = (args: DispatchArgs, clientNetId?: string | null) => {
|
|
3598
|
+
// §4.1 — SEC-1 trust-root join: resolve target node row WITHIN the
|
|
3599
|
+
// caller's scope. resolveReadScope returns 'denied' if the caller
|
|
3600
|
+
// doesn't belong to clientNetId; for the row-existence test we
|
|
3601
|
+
// re-join with member's networks so an attacker can't probe by
|
|
3602
|
+
// node_id from a network they don't belong to.
|
|
3603
|
+
const scope = resolveReadScope(clientNetId);
|
|
3604
|
+
if (scope.denied) {
|
|
3605
|
+
return { ok: false, error: "forbidden_cross_tenant", message: scope.denied };
|
|
3606
|
+
}
|
|
3607
|
+
const nodeQ: any[] = [args.child_node_id];
|
|
3608
|
+
let nodeSql = `SELECT node_id, alias, network_id, lifecycle_state, config_snapshot, runtimes_supported
|
|
3609
|
+
FROM nodes WHERE node_id = ?1`;
|
|
3610
|
+
nodeSql = addReadScope(nodeSql, nodeQ, scope);
|
|
3611
|
+
const node = db.get<{
|
|
3612
|
+
node_id: string;
|
|
3613
|
+
alias: string;
|
|
3614
|
+
network_id: string;
|
|
3615
|
+
lifecycle_state: string | null;
|
|
3616
|
+
config_snapshot: string | null;
|
|
3617
|
+
}>(nodeSql, ...nodeQ);
|
|
3618
|
+
if (!node) return { ok: false, error: "forbidden_cross_tenant", message: "node not found in your networks" };
|
|
3619
|
+
|
|
3620
|
+
// Caller must hold a write-capable role on the resolved network
|
|
3621
|
+
// (admin/owner/member; viewer denied). canWrite walks getUserNetworkRole.
|
|
3622
|
+
if (!canWrite(node.network_id)) {
|
|
3623
|
+
return { ok: false, error: "permission_denied", message: "viewer role cannot stop/delete nodes" };
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
// §4.2 D6 — delete refuses targets whose role is host_supervisor.
|
|
3627
|
+
// Stop is allowed against any node per RFC table (host_supervisor's
|
|
3628
|
+
// stop is functionally a no-op anyway: the daemon's children_map
|
|
3629
|
+
// only tracks its child nodes, never the daemon itself).
|
|
3630
|
+
//
|
|
3631
|
+
// PR1 SF-4 (#345 review) — fail-CLOSED on role read. The first cut
|
|
3632
|
+
// parsed config_snapshot and treated parse-fail / missing-field as
|
|
3633
|
+
// role=null which slipped through the gate. A daemon row with a
|
|
3634
|
+
// corrupt snapshot would then be deletable via delete_node. Fix:
|
|
3635
|
+
// - read role via two independent paths (snapshot.role + the
|
|
3636
|
+
// indexed `runtimes_supported` column whose presence ≈ daemon)
|
|
3637
|
+
// - if EITHER path indicates host_supervisor → refuse
|
|
3638
|
+
// - if snapshot parsed but role missing AND the node has any
|
|
3639
|
+
// daemon-shape evidence (non-null runtimes_supported column,
|
|
3640
|
+
// present in any node_create_requests as daemon_node_id) →
|
|
3641
|
+
// refuse defensively. The defense-in-depth daemon-side
|
|
3642
|
+
// children_map miss is the second layer; the hub gate must
|
|
3643
|
+
// itself fail-closed when role is ambiguous.
|
|
3644
|
+
if (args.action === "delete") {
|
|
3645
|
+
let role: string | null = null;
|
|
3646
|
+
let snapshotParseFailed = false;
|
|
3647
|
+
try {
|
|
3648
|
+
const snap = node.config_snapshot ? JSON.parse(node.config_snapshot) : null;
|
|
3649
|
+
role = typeof snap?.role === "string" ? snap.role : null;
|
|
3650
|
+
} catch { snapshotParseFailed = true; }
|
|
3651
|
+
const ambiguous = snapshotParseFailed || (role == null);
|
|
3652
|
+
// Independent corroborating check: nodes flagged as daemons by
|
|
3653
|
+
// create_node dispatchers will show up as daemon_node_id in
|
|
3654
|
+
// node_create_requests. Costs one indexed query.
|
|
3655
|
+
let looksLikeDaemon = false;
|
|
3656
|
+
if (ambiguous) {
|
|
3657
|
+
const m = db.get<{ n: number }>(
|
|
3658
|
+
`SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1`,
|
|
3659
|
+
node.node_id,
|
|
3660
|
+
);
|
|
3661
|
+
if ((m?.n ?? 0) > 0) looksLikeDaemon = true;
|
|
3662
|
+
}
|
|
3663
|
+
if (role === "host_supervisor" || (ambiguous && looksLikeDaemon)) {
|
|
3664
|
+
return {
|
|
3665
|
+
ok: false, error: "cannot_delete_daemon_via_delete_node",
|
|
3666
|
+
message: snapshotParseFailed
|
|
3667
|
+
? "node config_snapshot unreadable; node is referenced as a daemon — refuse delete defensively (use delete_daemon path)"
|
|
3668
|
+
: "host_supervisor daemons must be removed via the dedicated delete_daemon path (RFC-027.5)",
|
|
3669
|
+
};
|
|
3670
|
+
}
|
|
3671
|
+
}
|
|
3672
|
+
|
|
3673
|
+
// delete_node confirm_alias gate: dashboard's二次确认 input must
|
|
3674
|
+
// match the actual alias byte-for-byte. Hub rejects mismatched
|
|
3675
|
+
// input even if dashboard UI claims it's disabled.
|
|
3676
|
+
if (args.action === "delete") {
|
|
3677
|
+
if (typeof args.confirm_alias !== "string" || args.confirm_alias !== node.alias) {
|
|
3678
|
+
return { ok: false, error: "confirm_alias_mismatch", message: "confirm_alias must equal the node's alias" };
|
|
3679
|
+
}
|
|
3680
|
+
}
|
|
3681
|
+
|
|
3682
|
+
// Daemon must exist + be in the same network. We do NOT enforce
|
|
3683
|
+
// daemon.role==host_supervisor here: a child whose creator daemon
|
|
3684
|
+
// got demoted should still be stoppable. The daemon will refuse
|
|
3685
|
+
// with noop_not_my_child if children_map doesn't know it.
|
|
3686
|
+
//
|
|
3687
|
+
// PR1.2 e2e catch (BLOCKER): pushEvent keys SSE by clientKey ≈
|
|
3688
|
+
// `${networkId}:${sessionName}`, and agent-node registers its SSE
|
|
3689
|
+
// connection under its ALIAS (not its node_id). The create_node
|
|
3690
|
+
// doorbell at line ~2197 correctly pushes via `daemon.alias`;
|
|
3691
|
+
// stop_node was pushing via `daemon_node_id` so every doorbell
|
|
3692
|
+
// missed every SSE client → daemon never woke up → request_id
|
|
3693
|
+
// stayed 'pending' forever → child never reaped. The 24 unit tests
|
|
3694
|
+
// in stop-delete-node.test.ts all mock pushEvent and inject the
|
|
3695
|
+
// post-doorbell handler call directly, so single-unit coverage
|
|
3696
|
+
// couldn't surface this — exactly the failure class 通信龙 said
|
|
3697
|
+
// the docker e2e gate exists to catch (BLOCKER-1 同源). Load the
|
|
3698
|
+
// alias along with node_id + network_id and dispatch to the alias.
|
|
3699
|
+
const daemon = db.get<{ node_id: string; alias: string; network_id: string }>(
|
|
3700
|
+
`SELECT node_id, alias, network_id FROM nodes WHERE node_id = ?1`, args.daemon_node_id,
|
|
3701
|
+
);
|
|
3702
|
+
if (!daemon) return { ok: false, error: "daemon_not_found" };
|
|
3703
|
+
if (daemon.network_id !== node.network_id) {
|
|
3704
|
+
// Cross-tenant child↔daemon mismatch shouldn't be possible in
|
|
3705
|
+
// normal flow but is a SEC-1 hardening — refuse loud.
|
|
3706
|
+
return { ok: false, error: "daemon_cross_tenant", message: "daemon and child are in different networks" };
|
|
3707
|
+
}
|
|
3708
|
+
|
|
3709
|
+
// #1448 finding-4 — daemon 必须是该 child 的**权威创建者**,镜像 start_node 的
|
|
3710
|
+
// daemon_child_mismatch。此前 stop/delete 接受调用方传入的 daemon_node_id,只校验
|
|
3711
|
+
// 它存在 + 同网,不验它真的创建了这个 child。同网调用方传错 daemon_node_id → 门铃
|
|
3712
|
+
// 路由到错 daemon。🔴 f3(#1453) 合入后更糟:错 daemon 无该 child 的 map entry → 走
|
|
3713
|
+
// 收敛分支 sweep(错机器 pgrep 空) + ack stopped → hub 假收敛 stopped、child 仍在
|
|
3714
|
+
// 正确机器上跑(旧 noop 至少留下可见的卡死态)。网络 scope 只挡跨租户,网内是 footgun。
|
|
3715
|
+
//
|
|
3716
|
+
// 用「确定性不匹配」判据:authority 已知(child 有 create 记录)且不等 → 拒。不对
|
|
3717
|
+
// authority==null(create 记录被裁剪的旧 child)加拒,避免给它们的 stop/delete 引入
|
|
3718
|
+
// 新失败面——handler 的 daemon_node_id??resolveDaemonForChild 已兜住无法解析的情形。
|
|
3719
|
+
const authoritativeDaemonId = resolveDaemonForChild(args.child_node_id);
|
|
3720
|
+
if (authoritativeDaemonId && authoritativeDaemonId !== args.daemon_node_id) {
|
|
3721
|
+
return { ok: false, error: "daemon_child_mismatch", message: "daemon_node_id is not this child's authoritative creator daemon" };
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
// State machine gate.
|
|
3725
|
+
const state = node.lifecycle_state ?? "active";
|
|
3726
|
+
if (args.action === "stop" && state !== "active") {
|
|
3727
|
+
return { ok: false, error: state === "stopping" ? "node_already_stopping" : "node_not_active", current_state: state };
|
|
3728
|
+
}
|
|
3729
|
+
if (args.action === "delete" && state === "deleting") {
|
|
3730
|
+
// #1286 —— stale-deleting 重派出口。
|
|
3731
|
+
//
|
|
3732
|
+
// 在 .40 埋点修复之前,daemon 丢失 ack 会让行**永远**停在 deleting:
|
|
3733
|
+
// 重发 delete 被这里挡住,force=true 也一样 —— 状态机没有出口,
|
|
3734
|
+
// 2026-08-28 实测两行卡死(acc-…-3373 / probe-tl2),API 层面无解。
|
|
3735
|
+
//
|
|
3736
|
+
// 出口的三个条件,缺一不放行:
|
|
3737
|
+
// ① 调用方显式 force=true(不改默认行为 —— 正常并发的重复 delete 仍被拒)
|
|
3738
|
+
// ② 该 child 最近一条 delete 请求不是终态(pending/dispatched)
|
|
3739
|
+
// —— 终态说明 daemon 刚 ack 过,行马上会转移,不该重派
|
|
3740
|
+
// ③ 那条请求已经晾了超过 STALE_DELETING_MS —— 🔴 不是"刚派发就能 force":
|
|
3741
|
+
// 给正常的 doorbell→ack 往返留时间,否则 force 变成竞态放大器。
|
|
3742
|
+
const STALE_DELETING_MS = 5 * 60_000;
|
|
3743
|
+
const last = db.get<{ request_id: string; status: string; created_at: number }>(
|
|
3744
|
+
`SELECT request_id, status, created_at FROM node_stop_requests
|
|
3745
|
+
WHERE child_node_id = ?1 AND action = 'delete'
|
|
3746
|
+
ORDER BY created_at DESC LIMIT 1`, node.node_id,
|
|
3747
|
+
);
|
|
3748
|
+
const lastIsStale = !!last
|
|
3749
|
+
&& (last.status === "pending" || last.status === "dispatched")
|
|
3750
|
+
&& (Date.now() - last.created_at) > STALE_DELETING_MS;
|
|
3751
|
+
if (!(args.force === true && (lastIsStale || !last))) {
|
|
3752
|
+
return {
|
|
3753
|
+
ok: false, error: "node_already_deleting", current_state: state,
|
|
3754
|
+
// 🔴 告诉调用方出口在哪,而不是让他反复撞同一面墙。
|
|
3755
|
+
...(last ? { last_request_id: last.request_id, last_request_status: last.status } : {}),
|
|
3756
|
+
hint: last && (last.status === "pending" || last.status === "dispatched")
|
|
3757
|
+
? `再等一下;若超过 ${STALE_DELETING_MS / 60000} 分钟仍未收敛,带 force=true 重试可重新派发`
|
|
3758
|
+
: "上一条请求已终态,行应即将转移;稍后重查",
|
|
3759
|
+
};
|
|
3760
|
+
}
|
|
3761
|
+
// 放行:走正常派发路径,生成新 request_id。旧请求行保留(审计线索),
|
|
3762
|
+
// daemon 侧对未知 request 的 get_stop_request 会拿到 request_not_found,无副作用。
|
|
3763
|
+
}
|
|
3764
|
+
if (args.action === "delete" && state === "stopping") {
|
|
3765
|
+
// Can't start delete while a stop is mid-flight — race-prone.
|
|
3766
|
+
return { ok: false, error: "node_stopping_in_progress", current_state: state };
|
|
3767
|
+
}
|
|
3768
|
+
|
|
3769
|
+
// §4.3 D4 — in-flight inbox check. Count unacked tasks routed to
|
|
3770
|
+
// this node's alias (inbox routing uses session_name == alias).
|
|
3771
|
+
// Default refuse with the count surfaced; force=true overrides
|
|
3772
|
+
// and triggers the forced_stop_with_in_flight audit row.
|
|
3773
|
+
// SF-5 (#345 review): legacy inbox rows may have network_id=NULL
|
|
3774
|
+
// and would silently miss a network_id=?2 strict equality match.
|
|
3775
|
+
// Use COALESCE so a NULL inbox row is counted against the same
|
|
3776
|
+
// network as the target node (which is the only safe default
|
|
3777
|
+
// — pre-multi-network rows existed in single-network mode).
|
|
3778
|
+
const inFlightRow = db.get<{ n: number }>(
|
|
3779
|
+
`SELECT COUNT(*) AS n FROM inbox WHERE session_name = ?1 AND acked = 0
|
|
3780
|
+
AND COALESCE(network_id, ?2) = ?2`,
|
|
3781
|
+
node.alias, node.network_id,
|
|
3782
|
+
);
|
|
3783
|
+
const inFlight = inFlightRow?.n ?? 0;
|
|
3784
|
+
if (inFlight > 0 && !args.force) {
|
|
3785
|
+
return { ok: false, error: "node_busy_in_flight", in_flight_count: inFlight, hint: "set force=true to override (audit logged)" };
|
|
3786
|
+
}
|
|
3787
|
+
|
|
3788
|
+
// ── all gates passed; create request + transition state + push doorbell, transactionally ──
|
|
3789
|
+
const requestId = generateId("sr");
|
|
3790
|
+
const now = Date.now();
|
|
3791
|
+
const newState = args.action === "stop" ? "stopping" : "deleting";
|
|
3792
|
+
|
|
3793
|
+
// #1448 finding-5 — 记下该 child **当前活着的** ntok 的 token_id,让 ack 精确按
|
|
3794
|
+
// token_id 撤销,而非按 name='node:<alias>' 广撤。后者会在「同 alias 节点在这次
|
|
3795
|
+
// delete 未收敛的窗口内被重建(拿到同名新 token)」时,把新 token 一并误撤。这里
|
|
3796
|
+
// 在派发时刻定住身份;若此刻没有活 token(异常),存 null,ack 退回按 name 撤(旧语义)。
|
|
3797
|
+
const childTokenRow = db.get<{ token_id: string }>(
|
|
3798
|
+
`SELECT token_id FROM api_tokens WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
|
|
3799
|
+
node.network_id, `node:${node.alias}`,
|
|
3800
|
+
);
|
|
3801
|
+
const childTokenId = childTokenRow?.token_id ?? null;
|
|
3802
|
+
|
|
3803
|
+
// §4.5 D8 — lifecycle UPDATE + audit INSERTs + request INSERT atomic.
|
|
3804
|
+
// PR1.1: use db.transaction() (SQLiteAdapter wraps better-sqlite3's
|
|
3805
|
+
// native transaction; PgAdapter ships a real BEGIN/COMMIT/ROLLBACK
|
|
3806
|
+
// shim). Lets us drop the open-coded BEGIN/COMMIT + makes the
|
|
3807
|
+
// SF-2 failure-injection test (mock db.run throw inside callback)
|
|
3808
|
+
// reliable across both backends.
|
|
3809
|
+
try {
|
|
3810
|
+
db.transaction(() => {
|
|
3811
|
+
db.run(
|
|
3812
|
+
`INSERT INTO node_stop_requests
|
|
3813
|
+
(request_id, network_id, daemon_node_id, child_node_id, child_alias, child_token_id, action,
|
|
3814
|
+
delete_config, grace_seconds, force, in_flight_at_dispatch, created_by_token,
|
|
3815
|
+
status, created_at)
|
|
3816
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'pending', ?13)`,
|
|
3817
|
+
[
|
|
3818
|
+
requestId, node.network_id, args.daemon_node_id, node.node_id, node.alias, childTokenId, args.action,
|
|
3819
|
+
args.delete_config ? 1 : 0, args.grace_seconds, args.force ? 1 : 0, inFlight,
|
|
3820
|
+
callerTokenId || "unknown", now,
|
|
3821
|
+
],
|
|
3822
|
+
);
|
|
3823
|
+
db.run(
|
|
3824
|
+
`UPDATE nodes SET lifecycle_state = ?1 WHERE node_id = ?2`,
|
|
3825
|
+
[newState, node.node_id],
|
|
3826
|
+
);
|
|
3827
|
+
// §4.5 D8 — audit dispatch action. Uses the STRICT variant so a
|
|
3828
|
+
// failed audit INSERT propagates and triggers ROLLBACK (PR1 SF-2
|
|
3829
|
+
// review catch — auditCreateNode's swallow-and-warn would have
|
|
3830
|
+
// committed the lifecycle UPDATE without an audit row, leaving
|
|
3831
|
+
// exactly the "deleted but no audit" window §4.5 closes).
|
|
3832
|
+
auditCreateNodeStrict({
|
|
3833
|
+
action: args.action === "stop" ? "stop_node_dispatched" : "delete_node_dispatched",
|
|
3834
|
+
user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
|
|
3835
|
+
detail: {
|
|
3836
|
+
child_node_id: node.node_id, child_alias: node.alias,
|
|
3837
|
+
daemon_node_id: args.daemon_node_id, action: args.action,
|
|
3838
|
+
force: args.force, delete_config: args.delete_config,
|
|
3839
|
+
grace_seconds: args.grace_seconds, in_flight_at_dispatch: inFlight,
|
|
3840
|
+
lifecycle_state_before: state, lifecycle_state_after: newState,
|
|
3841
|
+
ts_request: now,
|
|
3842
|
+
},
|
|
3843
|
+
});
|
|
3844
|
+
if (args.force && inFlight > 0) {
|
|
3845
|
+
auditCreateNodeStrict({
|
|
3846
|
+
action: "forced_stop_with_in_flight",
|
|
3847
|
+
user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
|
|
3848
|
+
detail: { in_flight_count: inFlight, child_alias: node.alias },
|
|
3849
|
+
});
|
|
3850
|
+
}
|
|
3851
|
+
}); // end db.transaction
|
|
3852
|
+
} catch (e: any) {
|
|
3853
|
+
return { ok: false, error: "dispatch_tx_failed", message: e?.message || String(e) };
|
|
3854
|
+
}
|
|
3855
|
+
|
|
3856
|
+
// SSE doorbell — daemon will pull via get_stop_request.
|
|
3857
|
+
// PR1.2 e2e fix: route by daemon.alias (see SELECT above for the
|
|
3858
|
+
// BLOCKER explanation). Mirrors create_node's pushEvent target at
|
|
3859
|
+
// line ~2197.
|
|
3860
|
+
pushEvent(daemon.alias, { type: "stop_node", request_id: requestId }, node.network_id);
|
|
3861
|
+
return {
|
|
3862
|
+
ok: true, request_id: requestId, action: args.action,
|
|
3863
|
+
lifecycle_state: newState, in_flight_at_dispatch: inFlight,
|
|
3864
|
+
};
|
|
3865
|
+
};
|
|
3866
|
+
|
|
3867
|
+
server.tool(
|
|
3868
|
+
"stop_node",
|
|
3869
|
+
"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).",
|
|
3870
|
+
{
|
|
3871
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3872
|
+
// PR2 prereq: dashboard rarely knows daemon_node_id directly.
|
|
3873
|
+
// When omitted, hub resolves from the original creation record
|
|
3874
|
+
// (node_create_requests.daemon_node_id keyed by this child).
|
|
3875
|
+
daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
|
|
3876
|
+
force: z.boolean().optional().default(false),
|
|
3877
|
+
grace_seconds: z.number().int().min(5).max(60).optional().default(10),
|
|
3878
|
+
network_id: z.string().max(200).optional(),
|
|
3879
|
+
},
|
|
3880
|
+
async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, force, grace_seconds, network_id: clientNetId }) => {
|
|
3881
|
+
const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
|
|
3882
|
+
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
3883
|
+
const child_node_id = idArg.node_id;
|
|
3884
|
+
const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
|
|
3885
|
+
if (!resolved) {
|
|
3886
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
3887
|
+
ok: false, error: "daemon_not_resolvable",
|
|
3888
|
+
message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
|
|
3889
|
+
}) }] };
|
|
3890
|
+
}
|
|
3891
|
+
const r = dispatchStopOrDelete(
|
|
3892
|
+
{ action: "stop", child_node_id, daemon_node_id: resolved, force: force ?? false,
|
|
3893
|
+
grace_seconds: grace_seconds ?? 10, delete_config: false },
|
|
3894
|
+
clientNetId,
|
|
3895
|
+
);
|
|
3896
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
|
|
3897
|
+
},
|
|
3898
|
+
);
|
|
3899
|
+
|
|
3900
|
+
server.tool(
|
|
3901
|
+
"delete_node",
|
|
3902
|
+
"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.",
|
|
3903
|
+
{
|
|
3904
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3905
|
+
daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
|
|
3906
|
+
confirm_alias: z.string().min(1).max(200),
|
|
3907
|
+
force: z.boolean().optional().default(false),
|
|
3908
|
+
grace_seconds: z.number().int().min(5).max(60).optional().default(10),
|
|
3909
|
+
delete_config: z.boolean().optional().default(true),
|
|
3910
|
+
network_id: z.string().max(200).optional(),
|
|
3911
|
+
},
|
|
3912
|
+
async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, confirm_alias, force, grace_seconds, delete_config, network_id: clientNetId }) => {
|
|
3913
|
+
const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
|
|
3914
|
+
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
3915
|
+
const child_node_id = idArg.node_id;
|
|
3916
|
+
const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
|
|
3917
|
+
if (!resolved) {
|
|
3918
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
3919
|
+
ok: false, error: "daemon_not_resolvable",
|
|
3920
|
+
message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
|
|
3921
|
+
}) }] };
|
|
3922
|
+
}
|
|
3923
|
+
const r = dispatchStopOrDelete(
|
|
3924
|
+
{ action: "delete", child_node_id, daemon_node_id: resolved, force: force ?? false,
|
|
3925
|
+
grace_seconds: grace_seconds ?? 10, delete_config: delete_config ?? true, confirm_alias },
|
|
3926
|
+
clientNetId,
|
|
3927
|
+
);
|
|
3928
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
|
|
3929
|
+
},
|
|
3930
|
+
);
|
|
3931
|
+
|
|
3932
|
+
server.tool(
|
|
3933
|
+
"get_stop_request",
|
|
3934
|
+
"Daemon pulls a pending stop/delete request (called when SSE stop_node doorbell arrives). RFC-027 §2.4.",
|
|
3935
|
+
{
|
|
3936
|
+
request_id: z.string().min(1).max(200),
|
|
3937
|
+
},
|
|
3938
|
+
async ({ request_id }) => {
|
|
3939
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
3940
|
+
if (!callerDaemon.ok) {
|
|
3941
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
3942
|
+
}
|
|
3943
|
+
const row = db.get<{
|
|
3944
|
+
daemon_node_id: string; status: string; network_id: string;
|
|
3945
|
+
child_node_id: string; child_alias: string; action: string;
|
|
3946
|
+
delete_config: number; grace_seconds: number; force: number;
|
|
3947
|
+
}>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, action,
|
|
3948
|
+
delete_config, grace_seconds, force
|
|
3949
|
+
FROM node_stop_requests WHERE request_id = ?1`, request_id);
|
|
3950
|
+
if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
3951
|
+
if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
|
|
3952
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
3953
|
+
}
|
|
3954
|
+
if (row.network_id !== callerDaemon.networkId) {
|
|
3955
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
|
|
3956
|
+
}
|
|
3957
|
+
// Stamp delivered_at on first pull (idempotent — only if still pending).
|
|
3958
|
+
db.run(
|
|
3959
|
+
`UPDATE node_stop_requests SET status = 'delivered', delivered_at = ?1
|
|
3960
|
+
WHERE request_id = ?2 AND status = 'pending'`,
|
|
3961
|
+
[Date.now(), request_id],
|
|
3962
|
+
);
|
|
3963
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
3964
|
+
ok: true,
|
|
3965
|
+
request_id,
|
|
3966
|
+
child_node_id: row.child_node_id,
|
|
3967
|
+
child_alias: row.child_alias,
|
|
3968
|
+
action: row.action,
|
|
3969
|
+
delete_config: row.delete_config === 1,
|
|
3970
|
+
grace_seconds: row.grace_seconds,
|
|
3971
|
+
force: row.force === 1,
|
|
3972
|
+
}) }] };
|
|
3973
|
+
},
|
|
3974
|
+
);
|
|
3975
|
+
|
|
3976
|
+
server.tool(
|
|
3977
|
+
"ack_stop_request",
|
|
3978
|
+
"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.",
|
|
3979
|
+
{
|
|
3980
|
+
request_id: z.string().min(1).max(200),
|
|
3981
|
+
status: z.enum(["stopped", "stop_failed", "noop_not_my_child"]),
|
|
3982
|
+
exit_signal: z.string().max(16).optional(),
|
|
3983
|
+
backup_path: z.string().max(500).optional(),
|
|
3984
|
+
error: z.string().max(1000).optional(),
|
|
3985
|
+
},
|
|
3986
|
+
async ({ request_id, status, exit_signal, backup_path, error: ackError }) => {
|
|
3987
|
+
// #1358 —— 🔴 在此之前,这个工具的**每一条出口都是静默的**:到达不记、
|
|
3988
|
+
// 三条拒绝不记、成功收尾也不记。而隔壁 create-node 的收尾**是记的**
|
|
3989
|
+
// (`✓ create-node finalize`)。这种不对称最坑人:读日志的人看到 create 有、
|
|
3990
|
+
// delete 没有,第一反应是「delete 没发生」—— 但两者的日志覆盖面本来就不同,
|
|
3991
|
+
// 那条推论没有依据。排查 #1286 时我自己就差点这样读。
|
|
3992
|
+
console.log(`[commhub] ← ack_stop_request request=${request_id} status=${status}${exit_signal ? ` exit_signal=${exit_signal}` : ""}`);
|
|
3993
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
3994
|
+
if (!callerDaemon.ok) {
|
|
3995
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
3996
|
+
}
|
|
3997
|
+
const row = db.get<{
|
|
3998
|
+
daemon_node_id: string; status: string; network_id: string;
|
|
3999
|
+
child_node_id: string; child_alias: string; child_token_id: string | null; action: string;
|
|
4000
|
+
in_flight_at_dispatch: number; force: number;
|
|
4001
|
+
}>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, child_token_id, action,
|
|
4002
|
+
in_flight_at_dispatch, force
|
|
4003
|
+
FROM node_stop_requests WHERE request_id = ?1`, request_id);
|
|
4004
|
+
if (!row) {
|
|
4005
|
+
console.warn(`[commhub] ✕ ack_stop_request rejected: request_not_found request=${request_id}`);
|
|
4006
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
4007
|
+
}
|
|
4008
|
+
if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
|
|
4009
|
+
console.warn(`[commhub] ✕ ack_stop_request rejected: not_your_request request=${request_id} row_daemon=${row.daemon_node_id} caller=${callerDaemon.daemonNodeId}`);
|
|
4010
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
4011
|
+
}
|
|
4012
|
+
if (row.network_id !== callerDaemon.networkId) {
|
|
4013
|
+
console.warn(`[commhub] ✕ ack_stop_request rejected: cross_network_request request=${request_id} row_net=${row.network_id} caller_net=${callerDaemon.networkId}`);
|
|
4014
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
|
|
4015
|
+
}
|
|
4016
|
+
const now = Date.now();
|
|
4017
|
+
|
|
4018
|
+
// §4.5 D8 — lifecycle UPDATE + request UPDATE + audit INSERT
|
|
4019
|
+
// (+ DELETE for delete action) atomic via db.transaction().
|
|
4020
|
+
// PR1.1: replaces hand-rolled BEGIN..COMMIT so PG adapter and
|
|
4021
|
+
// SQLite both get correct rollback semantics from one code path.
|
|
4022
|
+
try {
|
|
4023
|
+
db.transaction(() => {
|
|
4024
|
+
db.run(
|
|
4025
|
+
`UPDATE node_stop_requests SET status = ?1, error = ?2, exit_signal = ?3,
|
|
4026
|
+
backup_path = ?4, acked_at = ?5
|
|
4027
|
+
WHERE request_id = ?6`,
|
|
4028
|
+
[status, ackError || null, exit_signal || null, backup_path || null, now, request_id],
|
|
4029
|
+
);
|
|
4030
|
+
if (status === "stopped" && row.action === "stop") {
|
|
4031
|
+
db.run(`UPDATE nodes SET lifecycle_state = 'stopped' WHERE node_id = ?1`, [row.child_node_id]);
|
|
4032
|
+
auditCreateNodeStrict({
|
|
4033
|
+
action: "stop_node_completed",
|
|
4034
|
+
user_id: null, network_id: row.network_id, target_id: request_id,
|
|
4035
|
+
detail: {
|
|
4036
|
+
child_node_id: row.child_node_id, child_alias: row.child_alias,
|
|
4037
|
+
exit_signal: exit_signal || null, ts_daemon_ack: now,
|
|
4038
|
+
lifecycle_state_after: "stopped",
|
|
4039
|
+
},
|
|
4040
|
+
});
|
|
4041
|
+
} else if (status === "stopped" && row.action === "delete") {
|
|
4042
|
+
// Revoke the child's ntok, then DELETE the nodes row.
|
|
4043
|
+
// #1448 finding-5 — 精确按派发时定住的 child_token_id 撤(镜像
|
|
4044
|
+
// ack_create_request 的 token_id 精撤)。旧行(升级前派发,child_token_id
|
|
4045
|
+
// 为 NULL)fallback 回按 name='node:<alias>' 撤——旧语义,不引入回归。
|
|
4046
|
+
// 按 token_id 撤避免了「同 alias 在本次 delete 窗口内被重建、拿到同名新
|
|
4047
|
+
// token」时把新 token 一并误撤。
|
|
4048
|
+
if (row.child_token_id) {
|
|
4049
|
+
db.run(
|
|
4050
|
+
`UPDATE api_tokens SET revoked_at = datetime('now')
|
|
4051
|
+
WHERE token_id = ?1 AND revoked_at IS NULL`,
|
|
4052
|
+
[row.child_token_id],
|
|
4053
|
+
);
|
|
4054
|
+
} else {
|
|
4055
|
+
db.run(
|
|
4056
|
+
`UPDATE api_tokens SET revoked_at = datetime('now')
|
|
4057
|
+
WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
|
|
4058
|
+
[row.network_id, `node:${row.child_alias}`],
|
|
4059
|
+
);
|
|
4060
|
+
}
|
|
4061
|
+
db.run(`DELETE FROM nodes WHERE node_id = ?1`, [row.child_node_id]);
|
|
4062
|
+
auditCreateNodeStrict({
|
|
4063
|
+
action: "delete_node_completed",
|
|
4064
|
+
user_id: null, network_id: row.network_id, target_id: request_id,
|
|
4065
|
+
detail: {
|
|
4066
|
+
child_node_id: row.child_node_id, child_alias: row.child_alias,
|
|
4067
|
+
exit_signal: exit_signal || null, backup_path: backup_path || null,
|
|
4068
|
+
ts_daemon_ack: now, lifecycle_state_after: "deleted",
|
|
4069
|
+
},
|
|
4070
|
+
});
|
|
4071
|
+
} else if (status === "stop_failed") {
|
|
4072
|
+
db.run(`UPDATE nodes SET lifecycle_state = 'stop_failed' WHERE node_id = ?1`, [row.child_node_id]);
|
|
4073
|
+
// No completion audit; the failure error is on the request row.
|
|
4074
|
+
}
|
|
4075
|
+
// noop_not_my_child: leave lifecycle_state as-is; the hub-side
|
|
4076
|
+
// sweeper / next reconciliation will pick it up. Don't transition.
|
|
4077
|
+
});
|
|
4078
|
+
} catch (e: any) {
|
|
4079
|
+
console.warn(`[commhub] ✕ ack_stop_request finalize_tx_failed request=${request_id} action=${row.action} child=${row.child_alias}: ${e?.message || e}`);
|
|
4080
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "finalize_tx_failed", message: e?.message || String(e) }) }] };
|
|
4081
|
+
}
|
|
4082
|
+
|
|
4083
|
+
// 🔴 关键的是最后那半句「据此做了什么」。#1286 里 hub 的代码路径经核对是
|
|
4084
|
+
// 正确的,缺的正是「它到底走没走那条分支」—— 只说「收到了 ack」回答不了。
|
|
4085
|
+
const did = status === "stopped" && row.action === "delete"
|
|
4086
|
+
? "revoked ntok + DELETE FROM nodes"
|
|
4087
|
+
: status === "stopped" && row.action === "stop"
|
|
4088
|
+
? "lifecycle_state=stopped"
|
|
4089
|
+
: status === "stop_failed"
|
|
4090
|
+
? "lifecycle_state=stop_failed"
|
|
4091
|
+
: "no lifecycle transition (noop_not_my_child)";
|
|
4092
|
+
console.log(`[commhub] ✓ ${row.action}-node finalize: request=${request_id} child=${row.child_alias} (${row.child_node_id}) status=${status} → ${did}`);
|
|
4093
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
|
|
4094
|
+
},
|
|
4095
|
+
);
|
|
4096
|
+
|
|
4097
|
+
// start_node is deliberately daemon-mediated. A stopped child has no SSE
|
|
4098
|
+
// consumer of its own, so restart_node's self-doorbell cannot revive it.
|
|
4099
|
+
server.tool(
|
|
4100
|
+
"start_node",
|
|
4101
|
+
"Start a stopped child through its host supervisor daemon. daemon_node_id is auto-resolved from the original create request.",
|
|
4102
|
+
{
|
|
4103
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
4104
|
+
daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
|
|
4105
|
+
network_id: z.string().max(200).optional(),
|
|
4106
|
+
},
|
|
4107
|
+
async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, network_id: clientNetId }) => {
|
|
4108
|
+
const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
|
|
4109
|
+
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
4110
|
+
const child_node_id = idArg.node_id;
|
|
4111
|
+
const resolvedDaemonId = daemon_node_id ?? resolveDaemonForChild(child_node_id);
|
|
4112
|
+
if (!resolvedDaemonId) {
|
|
4113
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
4114
|
+
ok: false, error: "daemon_not_resolvable",
|
|
4115
|
+
message: "no create request maps this child to a daemon",
|
|
4116
|
+
}) }] };
|
|
4117
|
+
}
|
|
4118
|
+
const scope = resolveReadScope(clientNetId);
|
|
4119
|
+
if (scope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "forbidden_cross_tenant" }) }] };
|
|
4120
|
+
const q: any[] = [child_node_id];
|
|
4121
|
+
const child = db.get<{ node_id: string; alias: string; network_id: string; lifecycle_state: string | null }>(
|
|
4122
|
+
addReadScope(`SELECT node_id, alias, network_id, lifecycle_state FROM nodes WHERE node_id = ?1`, q, scope), ...q,
|
|
4123
|
+
);
|
|
4124
|
+
if (!child) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "forbidden_cross_tenant" }) }] };
|
|
4125
|
+
if (!canWrite(child.network_id)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
4126
|
+
// #1448 finding-2 — stale-starting reaper,对齐 update_node_config / restart_node
|
|
4127
|
+
// 的 60s single-flight stale-supersede。start 只受理 'stopped';一旦卡 'starting'
|
|
4128
|
+
// (门铃丢 / daemon 在 UPDATE 'starting' 后死),config/restart 有 reaper、delete 有
|
|
4129
|
+
// 5min force,唯独 start 裸奔 → 下面的 gate 永远拦、也无人把节点拉回 stopped →
|
|
4130
|
+
// 永久卡 'starting'。出口:'starting' 且最近一条 in-flight start 请求晾过阈值 → 标
|
|
4131
|
+
// 旧请求 timeout 超越、放行重派;未过阈值则拒 node_already_starting(带 age)。
|
|
4132
|
+
const priorState = child.lifecycle_state ?? "active";
|
|
4133
|
+
if (priorState !== "stopped") {
|
|
4134
|
+
if (priorState !== "starting") {
|
|
4135
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_stopped", current_state: priorState }) }] };
|
|
4136
|
+
}
|
|
4137
|
+
const STALE_STARTING_MS = 60_000;
|
|
4138
|
+
const inFlight = db.get<{ request_id: string; created_at: number; delivered_at: number | null; acked_at: number | null }>(
|
|
4139
|
+
`SELECT request_id, created_at, delivered_at, acked_at FROM node_start_requests
|
|
4140
|
+
WHERE child_node_id = ?1 AND status IN ('pending', 'delivered')
|
|
4141
|
+
ORDER BY created_at DESC LIMIT 1`, child.node_id,
|
|
4142
|
+
);
|
|
4143
|
+
if (inFlight) {
|
|
4144
|
+
// Age anchor = COALESCE(acked_at, delivered_at, created_at),同 config reaper:
|
|
4145
|
+
// 已 pull/已 ack 的活着但慢的 start 会刷新存活时钟,不被误 reap。
|
|
4146
|
+
const ageAnchor = inFlight.acked_at ?? inFlight.delivered_at ?? inFlight.created_at;
|
|
4147
|
+
const age = Date.now() - ageAnchor;
|
|
4148
|
+
if (age <= STALE_STARTING_MS) {
|
|
4149
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
4150
|
+
ok: false, error: "node_already_starting",
|
|
4151
|
+
existing_request_id: inFlight.request_id, age_ms: age,
|
|
4152
|
+
hint: `再等一下;超过 ${STALE_STARTING_MS / 1000}s 仍卡 starting,重试 start 会重新派发`,
|
|
4153
|
+
}) }] };
|
|
4154
|
+
}
|
|
4155
|
+
// Stale — 标旧请求 timeout(autocommit,先于下面 tx 的新 INSERT,解除
|
|
4156
|
+
// node_start_requests 对 pending/delivered 的 child 唯一约束),放行重派。
|
|
4157
|
+
db.run(
|
|
4158
|
+
`UPDATE node_start_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3`,
|
|
4159
|
+
[Date.now(), `superseded by new start after ${age}ms stale (> ${STALE_STARTING_MS}ms threshold)`, inFlight.request_id],
|
|
4160
|
+
);
|
|
4161
|
+
}
|
|
4162
|
+
// else: 'starting' 但无 in-flight 行(异常残留态)——照样放行重派以自愈。
|
|
4163
|
+
}
|
|
4164
|
+
// The caller may provide daemon_node_id, but it cannot override the
|
|
4165
|
+
// authoritative child->daemon relationship recorded at creation.
|
|
4166
|
+
const authoritativeDaemonId = resolveDaemonForChild(child_node_id);
|
|
4167
|
+
if (!authoritativeDaemonId || authoritativeDaemonId !== resolvedDaemonId) {
|
|
4168
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_child_mismatch" }) }] };
|
|
4169
|
+
}
|
|
4170
|
+
const daemon = db.get<{ node_id: string; alias: string; network_id: string }>(
|
|
4171
|
+
`SELECT node_id, alias, network_id FROM nodes WHERE node_id = ?1`, resolvedDaemonId,
|
|
4172
|
+
);
|
|
4173
|
+
if (!daemon) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found" }) }] };
|
|
4174
|
+
if (daemon.network_id !== child.network_id) {
|
|
4175
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_cross_tenant" }) }] };
|
|
4176
|
+
}
|
|
4177
|
+
const requestId = generateId("str");
|
|
4178
|
+
const now = Date.now();
|
|
4179
|
+
try {
|
|
4180
|
+
db.transaction(() => {
|
|
4181
|
+
db.run(
|
|
4182
|
+
`INSERT INTO node_start_requests
|
|
4183
|
+
(request_id, network_id, daemon_node_id, child_node_id, child_alias,
|
|
4184
|
+
created_by_token, status, created_at)
|
|
4185
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7)`,
|
|
4186
|
+
[requestId, child.network_id, daemon.node_id, child.node_id, child.alias, callerTokenId || "unknown", now],
|
|
4187
|
+
);
|
|
4188
|
+
// #1448 finding-2 — 也接受从 'starting' 重派(stale-supersede 后原态仍是
|
|
4189
|
+
// starting);两种入态都合法地转/留到 'starting'。
|
|
4190
|
+
db.run(`UPDATE nodes SET lifecycle_state = 'starting' WHERE node_id = ?1 AND lifecycle_state IN ('stopped', 'starting')`, [child.node_id]);
|
|
4191
|
+
auditCreateNodeStrict({
|
|
4192
|
+
action: "start_node_dispatched", user_id: enforceUserId,
|
|
4193
|
+
network_id: child.network_id, target_id: requestId,
|
|
4194
|
+
detail: { child_node_id: child.node_id, child_alias: child.alias, daemon_node_id: daemon.node_id,
|
|
4195
|
+
lifecycle_state_before: priorState, lifecycle_state_after: "starting", ts_request: now },
|
|
4196
|
+
});
|
|
4197
|
+
});
|
|
4198
|
+
} catch (e: any) {
|
|
4199
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "dispatch_tx_failed", message: e?.message || String(e) }) }] };
|
|
4200
|
+
}
|
|
4201
|
+
pushEvent(daemon.alias, { type: "start_node", request_id: requestId }, child.network_id);
|
|
4202
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, lifecycle_state: "starting" }) }] };
|
|
4203
|
+
},
|
|
4204
|
+
);
|
|
4205
|
+
|
|
4206
|
+
server.tool(
|
|
4207
|
+
"get_start_request",
|
|
4208
|
+
"Host supervisor pulls an authenticated pending start request.",
|
|
4209
|
+
{ request_id: z.string().min(1).max(200) },
|
|
4210
|
+
async ({ request_id }) => {
|
|
4211
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
4212
|
+
if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
4213
|
+
const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
|
|
4214
|
+
`SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id = ?1`, request_id,
|
|
4215
|
+
);
|
|
4216
|
+
if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
4217
|
+
if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
4218
|
+
if (row.network_id !== callerDaemon.networkId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
|
|
4219
|
+
if (!['pending', 'delivered'].includes(row.status)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_startable", status: row.status }) }] };
|
|
4220
|
+
db.run(`UPDATE node_start_requests SET status='delivered', delivered_at=?1 WHERE request_id=?2 AND status='pending'`, [Date.now(), request_id]);
|
|
4221
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id, child_node_id: row.child_node_id, child_alias: row.child_alias }) }] };
|
|
4222
|
+
},
|
|
4223
|
+
);
|
|
4224
|
+
|
|
4225
|
+
server.tool(
|
|
4226
|
+
"ack_start_request",
|
|
4227
|
+
"Host supervisor reports start completion or failure.",
|
|
4228
|
+
{
|
|
4229
|
+
request_id: z.string().min(1).max(200),
|
|
4230
|
+
status: z.enum(["started", "start_failed"]),
|
|
4231
|
+
child_pid: z.number().int().positive().optional(),
|
|
4232
|
+
error: z.string().max(1000).optional(),
|
|
4233
|
+
},
|
|
4234
|
+
async ({ request_id, status, child_pid, error: ackError }) => {
|
|
4235
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
4236
|
+
if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
4237
|
+
const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
|
|
4238
|
+
`SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id=?1`, request_id,
|
|
4239
|
+
);
|
|
4240
|
+
if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
4241
|
+
if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
4242
|
+
if (row.network_id !== callerDaemon.networkId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
|
|
4243
|
+
if (row.status === "started" || row.status === "start_failed") {
|
|
4244
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: row.status, idempotent: true }) }] };
|
|
4245
|
+
}
|
|
4246
|
+
if (!['pending', 'delivered'].includes(row.status)) {
|
|
4247
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_ackable", status: row.status }) }] };
|
|
4248
|
+
}
|
|
4249
|
+
if (status === "started" && !child_pid) {
|
|
4250
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "child_pid_required" }) }] };
|
|
4251
|
+
}
|
|
4252
|
+
const now = Date.now();
|
|
4253
|
+
try {
|
|
4254
|
+
db.transaction(() => {
|
|
4255
|
+
db.run(`UPDATE node_start_requests SET status=?1,error=?2,child_pid=?3,acked_at=?4 WHERE request_id=?5`, [status, ackError || null, child_pid || null, now, request_id]);
|
|
4256
|
+
db.run(`UPDATE nodes SET lifecycle_state=?1 WHERE node_id=?2`, [status === "started" ? "active" : "stopped", row.child_node_id]);
|
|
4257
|
+
if (status === "started") auditCreateNodeStrict({
|
|
4258
|
+
action: "start_node_completed", network_id: row.network_id, target_id: request_id,
|
|
4259
|
+
detail: { child_node_id: row.child_node_id, child_alias: row.child_alias, child_pid, lifecycle_state_after: "active", ts_daemon_ack: now },
|
|
4260
|
+
});
|
|
4261
|
+
});
|
|
4262
|
+
} catch (e: any) {
|
|
4263
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "finalize_tx_failed", message: e?.message || String(e) }) }] };
|
|
4264
|
+
}
|
|
2128
4265
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
|
|
2129
4266
|
},
|
|
2130
4267
|
);
|
|
4268
|
+
|
|
4269
|
+
// RFC-027 PR1.1 — list_my_children: daemon-only query. Returns the
|
|
4270
|
+
// {child_node_id, alias, lifecycle_state} tuples for nodes whose
|
|
4271
|
+
// active create-request has this daemon as daemon_node_id. Used at
|
|
4272
|
+
// daemon boot to rebuild the in-memory childrenMap (PR1 dropped
|
|
4273
|
+
// every entry on daemon restart → stop/delete silently no-op'd).
|
|
4274
|
+
//
|
|
4275
|
+
// Network-scope is the daemon's own (resolveCallerDaemonTokenBound
|
|
4276
|
+
// already binds tokenIsNetwork to a single network). No SEC-1 leak:
|
|
4277
|
+
// returns ONLY children whose request landed under this daemon's
|
|
4278
|
+
// token + network.
|
|
4279
|
+
server.tool(
|
|
4280
|
+
"list_my_children",
|
|
4281
|
+
"Daemon pulls the alias + node_id list of children it spawned (for childrenMap rebuild after restart). RFC-027 PR1.1.",
|
|
4282
|
+
{},
|
|
4283
|
+
async () => {
|
|
4284
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
4285
|
+
if (!callerDaemon.ok) {
|
|
4286
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
4287
|
+
}
|
|
4288
|
+
// node_create_requests carries the canonical authoritative
|
|
4289
|
+
// child→daemon mapping (the row id IS the request, and the
|
|
4290
|
+
// child node_id derives from it deterministically via
|
|
4291
|
+
// `node_${request_id.replace(/^cr_/, "")}`). Filter to children
|
|
4292
|
+
// that completed registration (have a nodes row) so we don't
|
|
4293
|
+
// ask the daemon to pgrep for children that never came up.
|
|
4294
|
+
const rows = db.all<{ request_id: string; child_name: string; child_node_id: string; lifecycle_state: string | null }>(
|
|
4295
|
+
`SELECT ncr.request_id, ncr.child_name,
|
|
4296
|
+
('node_' || substr(ncr.request_id, 4)) AS child_node_id,
|
|
4297
|
+
n.lifecycle_state
|
|
4298
|
+
FROM node_create_requests ncr
|
|
4299
|
+
LEFT JOIN nodes n ON n.node_id = ('node_' || substr(ncr.request_id, 4))
|
|
4300
|
+
WHERE ncr.daemon_node_id = ?1
|
|
4301
|
+
AND ncr.network_id = ?2
|
|
4302
|
+
AND ncr.status IN ('succeeded', 'delivered')
|
|
4303
|
+
AND n.node_id IS NOT NULL
|
|
4304
|
+
AND COALESCE(n.lifecycle_state, 'active') NOT IN ('stopped', 'stop_failed')`,
|
|
4305
|
+
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
4306
|
+
);
|
|
4307
|
+
const children = rows.map(r => ({
|
|
4308
|
+
child_node_id: r.child_node_id,
|
|
4309
|
+
alias: r.child_name,
|
|
4310
|
+
lifecycle_state: r.lifecycle_state ?? "active",
|
|
4311
|
+
}));
|
|
4312
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: children.length, children }) }] };
|
|
4313
|
+
},
|
|
4314
|
+
);
|
|
4315
|
+
|
|
4316
|
+
// ── RFC-028 P1 — Provider & Model Registry + connectivity probe ──
|
|
4317
|
+
// Background timers (probe GC + sweeper) idempotent; safe to call
|
|
4318
|
+
// per registerTools.
|
|
4319
|
+
startPendingProbeGcTimer();
|
|
4320
|
+
startProbeSweeperTimer();
|
|
4321
|
+
|
|
4322
|
+
// Helper: zod to JSON-RPC reply for ProbeValidationError + VaultError
|
|
4323
|
+
const probeFailReply = (e: unknown) => {
|
|
4324
|
+
if (e instanceof ProbeValidationError) {
|
|
4325
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: e.code, ...(e.detail || {}) }) }] };
|
|
4326
|
+
}
|
|
4327
|
+
if (e instanceof VaultError) {
|
|
4328
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: e.code, message: e.message }) }] };
|
|
4329
|
+
}
|
|
4330
|
+
throw e;
|
|
4331
|
+
};
|
|
4332
|
+
|
|
4333
|
+
// §2.3.4 — upsert_network_secret (OWNER-ONLY; vault write).
|
|
4334
|
+
server.tool(
|
|
4335
|
+
"upsert_network_secret",
|
|
4336
|
+
"Write or replace a secret value in the network's vault (AES-GCM encrypted at rest). OWNER-only. RFC-028.",
|
|
4337
|
+
{
|
|
4338
|
+
key: z.string().min(1).max(64).regex(/^[A-Z][A-Z0-9_]{0,63}$/),
|
|
4339
|
+
value: z.string().min(1).max(16 * 1024),
|
|
4340
|
+
network_id: z.string().max(200).optional(),
|
|
4341
|
+
},
|
|
4342
|
+
async ({ key, value, network_id: clientNetId }) => {
|
|
4343
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
4344
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
4345
|
+
const callerRole = enforceUserId && effectiveNetId
|
|
4346
|
+
? getUserNetworkRole(enforceUserId, effectiveNetId)
|
|
4347
|
+
: null;
|
|
4348
|
+
if (callerRole !== "owner") {
|
|
4349
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "secret_owner_only", required_role: "owner", caller_role: callerRole }) }] };
|
|
4350
|
+
}
|
|
4351
|
+
try {
|
|
4352
|
+
vaultUpsert(effectiveNetId || "default", key, value);
|
|
4353
|
+
auditCreateNode({
|
|
4354
|
+
action: "create_node_dispatched", // reusing audit_log shape (provider follow-up logs)
|
|
4355
|
+
user_id: enforceUserId, network_id: effectiveNetId, target_id: null,
|
|
4356
|
+
detail: { op: "vault_upsert", key, network_id: effectiveNetId },
|
|
4357
|
+
});
|
|
4358
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, key }) }] };
|
|
4359
|
+
} catch (e) {
|
|
4360
|
+
return probeFailReply(e);
|
|
4361
|
+
}
|
|
4362
|
+
},
|
|
4363
|
+
);
|
|
4364
|
+
|
|
4365
|
+
// §2.3.4b — list_network_secrets (key names only; viewer+).
|
|
4366
|
+
server.tool(
|
|
4367
|
+
"list_network_secrets",
|
|
4368
|
+
"List vault key NAMES (NEVER values) for a network. RFC-028.",
|
|
4369
|
+
{ network_id: z.string().max(200).optional() },
|
|
4370
|
+
async ({ network_id: clientNetId }) => {
|
|
4371
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
4372
|
+
if (!canWrite(effectiveNetId) && enforceUserId) {
|
|
4373
|
+
// viewer-level: allow if caller has any role in network
|
|
4374
|
+
const role = getUserNetworkRole(enforceUserId, effectiveNetId || "default");
|
|
4375
|
+
if (!role) return writeDeniedReply(effectiveNetId, "read");
|
|
4376
|
+
}
|
|
4377
|
+
const keys = vaultListKeys(effectiveNetId || "default");
|
|
4378
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, keys }) }] };
|
|
4379
|
+
},
|
|
4380
|
+
);
|
|
4381
|
+
|
|
4382
|
+
// §2.3.1 — upsert_provider (admin+).
|
|
4383
|
+
server.tool(
|
|
4384
|
+
"upsert_provider",
|
|
4385
|
+
"Create or update a provider (vendor + base_url + secret_key_ref + initial models). Admin+. RFC-028.",
|
|
4386
|
+
{
|
|
4387
|
+
name: z.string().min(1).max(100),
|
|
4388
|
+
vendor: z.string().min(1).max(64),
|
|
4389
|
+
base_url: z.string().min(1).max(500),
|
|
4390
|
+
secret_key_ref: z.string().min(1).max(64),
|
|
4391
|
+
models: z.array(z.object({
|
|
4392
|
+
model_name: z.string().min(1).max(100),
|
|
4393
|
+
display_name: z.string().max(100).optional(),
|
|
4394
|
+
context_window: z.number().int().min(0).optional(),
|
|
4395
|
+
supports_vision: z.boolean().optional(),
|
|
4396
|
+
})).optional(),
|
|
4397
|
+
network_id: z.string().max(200).optional(),
|
|
4398
|
+
},
|
|
4399
|
+
async ({ name, vendor, base_url, secret_key_ref, models, network_id: clientNetId }) => {
|
|
4400
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
4401
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
4402
|
+
const callerRole = enforceUserId && effectiveNetId
|
|
4403
|
+
? getUserNetworkRole(enforceUserId, effectiveNetId)
|
|
4404
|
+
: null;
|
|
4405
|
+
if (callerRole !== "admin" && callerRole !== "owner") {
|
|
4406
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_provider", required_role: "admin", caller_role: callerRole }) }] };
|
|
4407
|
+
}
|
|
4408
|
+
try {
|
|
4409
|
+
_validateBaseUrl(vendor, base_url);
|
|
4410
|
+
} catch (e) {
|
|
4411
|
+
return probeFailReply(e);
|
|
4412
|
+
}
|
|
4413
|
+
// Verify vault key exists (best-effort; just lists names)
|
|
4414
|
+
const vaultKeys = vaultListKeys(effectiveNetId || "default");
|
|
4415
|
+
if (!vaultKeys.includes(secret_key_ref)) {
|
|
4416
|
+
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" }) }] };
|
|
4417
|
+
}
|
|
4418
|
+
const providerId = `prov_${uuidv4()}`;
|
|
4419
|
+
try {
|
|
4420
|
+
db.run(
|
|
4421
|
+
`INSERT INTO providers (provider_id, network_id, name, vendor, base_url, secret_key_ref, created_at, created_by, enabled)
|
|
4422
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)`,
|
|
4423
|
+
[providerId, effectiveNetId || "default", name, vendor, base_url, secret_key_ref, Date.now(), enforceUserId || "unknown"],
|
|
4424
|
+
);
|
|
4425
|
+
} catch (e: any) {
|
|
4426
|
+
if (/UNIQUE constraint failed/.test(e?.message || "")) {
|
|
4427
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_name_conflict", name }) }] };
|
|
4428
|
+
}
|
|
4429
|
+
throw e;
|
|
4430
|
+
}
|
|
4431
|
+
const modelIds: string[] = [];
|
|
4432
|
+
if (models) {
|
|
4433
|
+
for (const m of models) {
|
|
4434
|
+
const mid = `pm_${uuidv4()}`;
|
|
4435
|
+
db.run(
|
|
4436
|
+
`INSERT INTO provider_models (model_id, provider_id, model_name, display_name, context_window, supports_vision, enabled, created_at)
|
|
4437
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)`,
|
|
4438
|
+
[mid, providerId, m.model_name, m.display_name ?? null, m.context_window ?? null, m.supports_vision ? 1 : 0, Date.now()],
|
|
4439
|
+
);
|
|
4440
|
+
modelIds.push(mid);
|
|
4441
|
+
}
|
|
4442
|
+
}
|
|
4443
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id: providerId, model_ids: modelIds }) }] };
|
|
4444
|
+
},
|
|
4445
|
+
);
|
|
4446
|
+
|
|
4447
|
+
// §2.3.2 — update_provider (admin+; patch semantics). RFC-028 P1.5.
|
|
4448
|
+
// Single tool for editing existing providers — patch model: name/base_url/
|
|
4449
|
+
// models/enabled all optional, at least one required. Vendor and
|
|
4450
|
+
// secret_key_ref are IMMUTABLE via this tool (vendor change = create+delete;
|
|
4451
|
+
// secret change goes through upsert_network_secret first). network_id is
|
|
4452
|
+
// immutable (cross-tenant move forbidden).
|
|
4453
|
+
//
|
|
4454
|
+
// base_url change re-runs validateBaseUrl with vendor read from DB row
|
|
4455
|
+
// (NOT from patch) — defends against trying to widen host allowlist by
|
|
4456
|
+
// smuggling a new vendor name. zod .strict() on the wrapping object
|
|
4457
|
+
// surfaces extras as -32602 at MCP boundary (R3 lock, same as
|
|
4458
|
+
// ack_probe_request per #308 fold-in).
|
|
4459
|
+
//
|
|
4460
|
+
// Audit: every successful update writes audit_log with before/after diff
|
|
4461
|
+
// of the changed fields ONLY (no secret values — secret isn't a patch
|
|
4462
|
+
// field, no leak path).
|
|
4463
|
+
server.registerTool(
|
|
4464
|
+
"update_provider",
|
|
4465
|
+
{
|
|
4466
|
+
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.",
|
|
4467
|
+
inputSchema: z.object({
|
|
4468
|
+
provider_id: z.string().regex(/^prov_[a-zA-Z0-9_-]+$/).max(200),
|
|
4469
|
+
network_id: z.string().max(200).optional(),
|
|
4470
|
+
patch: z.object({
|
|
4471
|
+
name: z.string().min(1).max(100).optional(),
|
|
4472
|
+
base_url: z.string().min(1).max(500).optional(),
|
|
4473
|
+
models: z.array(z.object({
|
|
4474
|
+
model_name: z.string().min(1).max(100),
|
|
4475
|
+
display_name: z.string().max(100).optional(),
|
|
4476
|
+
context_window: z.number().int().min(0).optional(),
|
|
4477
|
+
supports_vision: z.boolean().optional(),
|
|
4478
|
+
})).optional(),
|
|
4479
|
+
enabled: z.boolean().optional(),
|
|
4480
|
+
}).strict(),
|
|
4481
|
+
}).strict() as any,
|
|
4482
|
+
},
|
|
4483
|
+
async (args: any) => {
|
|
4484
|
+
const { provider_id, network_id: clientNetId, patch } = args;
|
|
4485
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
4486
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
4487
|
+
const callerRole = enforceUserId && effectiveNetId
|
|
4488
|
+
? getUserNetworkRole(enforceUserId, effectiveNetId)
|
|
4489
|
+
: null;
|
|
4490
|
+
if (callerRole !== "admin" && callerRole !== "owner") {
|
|
4491
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_provider", required_role: "admin", caller_role: callerRole }) }] };
|
|
4492
|
+
}
|
|
4493
|
+
|
|
4494
|
+
// Empty patch — surface noop_no_changes (don't silently succeed)
|
|
4495
|
+
const patchKeys = Object.keys(patch || {});
|
|
4496
|
+
if (patchKeys.length === 0) {
|
|
4497
|
+
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" }) }] };
|
|
4498
|
+
}
|
|
4499
|
+
|
|
4500
|
+
// SEC-1: row must exist within caller's network (SQL-level enforcement)
|
|
4501
|
+
const row = db.get<{ provider_id: string; vendor: string; name: string; base_url: string; enabled: number }>(
|
|
4502
|
+
`SELECT provider_id, vendor, name, base_url, enabled FROM providers WHERE provider_id = ?1 AND network_id = ?2`,
|
|
4503
|
+
provider_id, effectiveNetId || "default",
|
|
4504
|
+
);
|
|
4505
|
+
if (!row) {
|
|
4506
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_not_found", provider_id }) }] };
|
|
4507
|
+
}
|
|
4508
|
+
|
|
4509
|
+
// base_url change → re-run validateBaseUrl with vendor from DB (not patch)
|
|
4510
|
+
if (patch.base_url !== undefined && patch.base_url !== row.base_url) {
|
|
4511
|
+
try {
|
|
4512
|
+
_validateBaseUrl(row.vendor, patch.base_url);
|
|
4513
|
+
} catch (e) {
|
|
4514
|
+
return probeFailReply(e);
|
|
4515
|
+
}
|
|
4516
|
+
}
|
|
4517
|
+
|
|
4518
|
+
// Diff staging — collect before/after for audit (NO secret values; secret
|
|
4519
|
+
// isn't a patch field). Skip fields that didn't actually change.
|
|
4520
|
+
const diff: Record<string, { before: unknown; after: unknown }> = {};
|
|
4521
|
+
const sets: string[] = [];
|
|
4522
|
+
const params: unknown[] = [];
|
|
4523
|
+
let paramIdx = 1;
|
|
4524
|
+
if (patch.name !== undefined && patch.name !== row.name) {
|
|
4525
|
+
sets.push(`name = ?${paramIdx++}`); params.push(patch.name);
|
|
4526
|
+
diff.name = { before: row.name, after: patch.name };
|
|
4527
|
+
}
|
|
4528
|
+
if (patch.base_url !== undefined && patch.base_url !== row.base_url) {
|
|
4529
|
+
sets.push(`base_url = ?${paramIdx++}`); params.push(patch.base_url);
|
|
4530
|
+
diff.base_url = { before: row.base_url, after: patch.base_url };
|
|
4531
|
+
}
|
|
4532
|
+
if (patch.enabled !== undefined) {
|
|
4533
|
+
const newEnabled = patch.enabled ? 1 : 0;
|
|
4534
|
+
if (newEnabled !== row.enabled) {
|
|
4535
|
+
sets.push(`enabled = ?${paramIdx++}`); params.push(newEnabled);
|
|
4536
|
+
diff.enabled = { before: row.enabled === 1, after: patch.enabled };
|
|
4537
|
+
}
|
|
4538
|
+
}
|
|
4539
|
+
|
|
4540
|
+
// Replace models list (if supplied) atomically with the providers UPDATE
|
|
4541
|
+
const willReplaceModels = patch.models !== undefined;
|
|
4542
|
+
const newModelIds: string[] = [];
|
|
4543
|
+
|
|
4544
|
+
// No-op early return (通信牛 nit): all patch fields matched the
|
|
4545
|
+
// existing row values + no models supplied → no real change to
|
|
4546
|
+
// persist. Returning here means we don't write an empty-diff
|
|
4547
|
+
// audit row (audit noise). Audit_log INSERT must only fire when
|
|
4548
|
+
// something actually changed.
|
|
4549
|
+
if (sets.length === 0 && !willReplaceModels) {
|
|
4550
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id, no_changes: true, hint: "patch fields matched existing row, no-op" }) }] };
|
|
4551
|
+
}
|
|
4552
|
+
|
|
4553
|
+
try {
|
|
4554
|
+
db.exec("BEGIN");
|
|
4555
|
+
if (sets.length > 0) {
|
|
4556
|
+
params.push(provider_id);
|
|
4557
|
+
try {
|
|
4558
|
+
db.run(`UPDATE providers SET ${sets.join(", ")} WHERE provider_id = ?${paramIdx}`, params);
|
|
4559
|
+
} catch (e: any) {
|
|
4560
|
+
if (/UNIQUE constraint failed/.test(e?.message || "")) {
|
|
4561
|
+
db.exec("ROLLBACK");
|
|
4562
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_name_conflict", name: patch.name }) }] };
|
|
4563
|
+
}
|
|
4564
|
+
throw e;
|
|
4565
|
+
}
|
|
4566
|
+
}
|
|
4567
|
+
if (willReplaceModels) {
|
|
4568
|
+
db.run(`DELETE FROM provider_models WHERE provider_id = ?1`, [provider_id]);
|
|
4569
|
+
for (const m of patch.models) {
|
|
4570
|
+
const mid = `pm_${uuidv4()}`;
|
|
4571
|
+
db.run(
|
|
4572
|
+
`INSERT INTO provider_models (model_id, provider_id, model_name, display_name, context_window, supports_vision, enabled, created_at)
|
|
4573
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)`,
|
|
4574
|
+
[mid, provider_id, m.model_name, m.display_name ?? null, m.context_window ?? null, m.supports_vision ? 1 : 0, Date.now()],
|
|
4575
|
+
);
|
|
4576
|
+
newModelIds.push(mid);
|
|
4577
|
+
}
|
|
4578
|
+
diff.models = { before: "(prior list)", after: `${patch.models.length} models replaced` };
|
|
4579
|
+
}
|
|
4580
|
+
db.run(
|
|
4581
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
4582
|
+
VALUES (?1, ?2, 'update_provider', 'provider', ?3, ?4, ?5)`,
|
|
4583
|
+
[enforceUserId || null, callerAlias || null, provider_id, JSON.stringify({ diff, fields_changed: Object.keys(diff) }), effectiveNetId || null],
|
|
4584
|
+
);
|
|
4585
|
+
db.exec("COMMIT");
|
|
4586
|
+
} catch (e: any) {
|
|
4587
|
+
try { db.exec("ROLLBACK"); } catch { /* ok */ }
|
|
4588
|
+
throw e;
|
|
4589
|
+
}
|
|
4590
|
+
|
|
4591
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
4592
|
+
ok: true, provider_id,
|
|
4593
|
+
fields_changed: Object.keys(diff),
|
|
4594
|
+
models_replaced: willReplaceModels ? newModelIds.length : null,
|
|
4595
|
+
}) }] };
|
|
4596
|
+
},
|
|
4597
|
+
);
|
|
4598
|
+
|
|
4599
|
+
// §2.3.3 — list_providers (viewer+; never returns secret VALUES).
|
|
4600
|
+
server.tool(
|
|
4601
|
+
"list_providers",
|
|
4602
|
+
"List providers + models in the caller's network. Never returns secret VALUES. RFC-028.",
|
|
4603
|
+
{ network_id: z.string().max(200).optional() },
|
|
4604
|
+
async ({ network_id: clientNetId }) => {
|
|
4605
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
4606
|
+
// viewer access: caller must be member of network (read access)
|
|
4607
|
+
if (enforceUserId) {
|
|
4608
|
+
const role = getUserNetworkRole(enforceUserId, effectiveNetId || "default");
|
|
4609
|
+
if (!role) return writeDeniedReply(effectiveNetId, "read");
|
|
4610
|
+
}
|
|
4611
|
+
const providers = db.all<any>(
|
|
4612
|
+
`SELECT provider_id, name, vendor, base_url, secret_key_ref, enabled FROM providers WHERE network_id = ?1 AND enabled = 1 ORDER BY name`,
|
|
4613
|
+
effectiveNetId || "default",
|
|
4614
|
+
);
|
|
4615
|
+
const out = providers.map(p => {
|
|
4616
|
+
const models = db.all<any>(
|
|
4617
|
+
`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`,
|
|
4618
|
+
p.provider_id,
|
|
4619
|
+
);
|
|
4620
|
+
return { ...p, in_vault: true, models }; // in_vault: true since we required it on upsert
|
|
4621
|
+
});
|
|
4622
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, providers: out }) }] };
|
|
4623
|
+
},
|
|
4624
|
+
);
|
|
4625
|
+
|
|
4626
|
+
// §2.3.5 — probe_provider_model (admin+; dispatches to daemon).
|
|
4627
|
+
server.tool(
|
|
4628
|
+
"probe_provider_model",
|
|
4629
|
+
"Dispatch a connectivity probe to a daemon. Mints ephemeral secret blob; daemon pulls via get_probe_request. Admin+. RFC-028.",
|
|
4630
|
+
{
|
|
4631
|
+
provider_id: z.string().min(1).max(200),
|
|
4632
|
+
model_name: z.string().min(1).max(100),
|
|
4633
|
+
daemon_node_id: z.string().min(1).max(200),
|
|
4634
|
+
network_id: z.string().max(200).optional(),
|
|
4635
|
+
},
|
|
4636
|
+
async ({ provider_id, model_name, daemon_node_id, network_id: clientNetId }) => {
|
|
4637
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
4638
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
4639
|
+
const callerRole = enforceUserId && effectiveNetId
|
|
4640
|
+
? getUserNetworkRole(enforceUserId, effectiveNetId)
|
|
4641
|
+
: null;
|
|
4642
|
+
if (callerRole !== "admin" && callerRole !== "owner") {
|
|
4643
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_probe", required_role: "admin", caller_role: callerRole }) }] };
|
|
4644
|
+
}
|
|
4645
|
+
// Resolve provider + model (network-scoped). Distinguish "not found"
|
|
4646
|
+
// from "disabled" so the dashboard can render the right error
|
|
4647
|
+
// (P1.5 dashboard编辑/停用 needs this to explain why probe rejected).
|
|
4648
|
+
const provider = db.get<any>(
|
|
4649
|
+
`SELECT provider_id, vendor, base_url, secret_key_ref, enabled FROM providers WHERE provider_id = ?1 AND network_id = ?2`,
|
|
4650
|
+
provider_id, effectiveNetId || "default",
|
|
4651
|
+
);
|
|
4652
|
+
if (!provider) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_not_found", provider_id }) }] };
|
|
4653
|
+
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" }) }] };
|
|
4654
|
+
const model = db.get<any>(
|
|
4655
|
+
`SELECT model_id, model_name FROM provider_models WHERE provider_id = ?1 AND model_name = ?2 AND enabled = 1`,
|
|
4656
|
+
provider_id, model_name,
|
|
4657
|
+
);
|
|
4658
|
+
if (!model) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "model_not_found", model_name }) }] };
|
|
4659
|
+
// Resolve daemon (must be in caller network)
|
|
4660
|
+
const { row: daemon, sec1Ok } = resolveTargetNode(daemon_node_id, effectiveNetId);
|
|
4661
|
+
if (!daemon) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found" }) }] };
|
|
4662
|
+
if (!sec1Ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
|
|
4663
|
+
// Vault decrypt the API key (may throw VaultError)
|
|
4664
|
+
let apiKey: string;
|
|
4665
|
+
try {
|
|
4666
|
+
const v = vaultGet(effectiveNetId || "default", provider.secret_key_ref);
|
|
4667
|
+
if (!v) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "secret_not_in_vault", key: provider.secret_key_ref }) }] };
|
|
4668
|
+
apiKey = v;
|
|
4669
|
+
} catch (e) { return probeFailReply(e); }
|
|
4670
|
+
// Mint probe row + stash ephemeral blob
|
|
4671
|
+
const probeId = newProbeId();
|
|
4672
|
+
db.run(
|
|
4673
|
+
`INSERT INTO probe_results (probe_id, provider_id, model_name, daemon_node_id, network_id, status, probed_at, probed_by_user)
|
|
4674
|
+
VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)`,
|
|
4675
|
+
[probeId, provider_id, model_name, daemon_node_id, effectiveNetId || "default", Date.now(), enforceUserId || null],
|
|
4676
|
+
);
|
|
4677
|
+
putPendingProbeSecret({
|
|
4678
|
+
probe_id: probeId,
|
|
4679
|
+
daemon_node_id,
|
|
4680
|
+
provider_id,
|
|
4681
|
+
vendor: provider.vendor,
|
|
4682
|
+
base_url: provider.base_url,
|
|
4683
|
+
model_name,
|
|
4684
|
+
api_key: apiKey,
|
|
4685
|
+
network_id: effectiveNetId || "default",
|
|
4686
|
+
});
|
|
4687
|
+
pushEvent(daemon.alias, { type: "probe_provider", probe_id: probeId }, daemon.network_id || effectiveNetId || "default");
|
|
4688
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, probe_id: probeId }) }] };
|
|
4689
|
+
},
|
|
4690
|
+
);
|
|
4691
|
+
|
|
4692
|
+
// §2.3.6 — get_probe_results (viewer+; matrix renderer source).
|
|
4693
|
+
server.tool(
|
|
4694
|
+
"get_probe_results",
|
|
4695
|
+
"Query probe history (optionally filtered by provider/model/daemon). Used by dashboard reachability matrix. RFC-028.",
|
|
4696
|
+
{
|
|
4697
|
+
provider_id: z.string().max(200).optional(),
|
|
4698
|
+
model_name: z.string().max(100).optional(),
|
|
4699
|
+
daemon_node_id: z.string().max(200).optional(),
|
|
4700
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
4701
|
+
network_id: z.string().max(200).optional(),
|
|
4702
|
+
},
|
|
4703
|
+
async ({ provider_id, model_name, daemon_node_id, limit, network_id: clientNetId }) => {
|
|
4704
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
4705
|
+
if (enforceUserId) {
|
|
4706
|
+
const role = getUserNetworkRole(enforceUserId, effectiveNetId || "default");
|
|
4707
|
+
if (!role) return writeDeniedReply(effectiveNetId, "read");
|
|
4708
|
+
}
|
|
4709
|
+
const where: string[] = ["network_id = ?1"];
|
|
4710
|
+
const params: any[] = [effectiveNetId || "default"];
|
|
4711
|
+
if (provider_id) { where.push(`provider_id = ?${params.length + 1}`); params.push(provider_id); }
|
|
4712
|
+
if (model_name) { where.push(`model_name = ?${params.length + 1}`); params.push(model_name); }
|
|
4713
|
+
if (daemon_node_id) { where.push(`daemon_node_id = ?${params.length + 1}`); params.push(daemon_node_id); }
|
|
4714
|
+
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}`;
|
|
4715
|
+
const rows = db.all<any>(sql, ...params);
|
|
4716
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, results: rows }) }] };
|
|
4717
|
+
},
|
|
4718
|
+
);
|
|
4719
|
+
|
|
4720
|
+
// §2.3.7 daemon-facing — get_probe_request.
|
|
4721
|
+
server.tool(
|
|
4722
|
+
"get_probe_request",
|
|
4723
|
+
"Daemon pulls a pending probe request (called when SSE probe_provider doorbell arrives). RFC-028.",
|
|
4724
|
+
{ probe_id: z.string().min(1).max(200) },
|
|
4725
|
+
async ({ probe_id }) => {
|
|
4726
|
+
const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
|
|
4727
|
+
if (!callerDaemon.ok) {
|
|
4728
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
4729
|
+
}
|
|
4730
|
+
// takePendingProbeSecret enforces daemon binding + evicts
|
|
4731
|
+
const blob = (await import("./probe.js")).takePendingProbeSecret(probe_id, callerDaemon.daemonNodeId);
|
|
4732
|
+
if (!blob) {
|
|
4733
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "probe_request_unavailable", reason: "not_found_or_wrong_daemon_or_expired" }) }] };
|
|
4734
|
+
}
|
|
4735
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
4736
|
+
ok: true,
|
|
4737
|
+
probe_id: blob.probe_id,
|
|
4738
|
+
vendor: blob.vendor,
|
|
4739
|
+
base_url: blob.base_url,
|
|
4740
|
+
model_name: blob.model_name,
|
|
4741
|
+
api_key: blob.api_key, // ephemeral; daemon writes to .env.local or in-memory only
|
|
4742
|
+
}) }] };
|
|
4743
|
+
},
|
|
4744
|
+
);
|
|
4745
|
+
|
|
4746
|
+
// §2.3.8 daemon-facing — ack_probe_request (STRICT whitelist via zod).
|
|
4747
|
+
// R3 LOCK (RFC-028 v3): ack payload has EXACTLY 4 keys. We pass a
|
|
4748
|
+
// ZodObject with .strict() so the MCP SDK's validateToolInput surfaces
|
|
4749
|
+
// any extra field (e.g. an attacker smuggling `error_message` to leak a
|
|
4750
|
+
// secret) as a -32602 Invalid params error at the protocol boundary
|
|
4751
|
+
// BEFORE our handler runs. The SDK invokes z.object(shape) by default
|
|
4752
|
+
// which is strip-mode; passing a fully-constructed strict object
|
|
4753
|
+
// bypasses that and enforces unknownKeys='strict'.
|
|
4754
|
+
server.registerTool(
|
|
4755
|
+
"ack_probe_request",
|
|
4756
|
+
{
|
|
4757
|
+
description: "Daemon acks a probe. Schema is STRICT whitelist (no error_message; v3 R3 LOCK). RFC-028.",
|
|
4758
|
+
// Strict-mode ZodObject so the MCP SDK's validateToolInput surfaces
|
|
4759
|
+
// any extra field as a -32602 Invalid params error BEFORE the
|
|
4760
|
+
// handler runs. server.tool() accepts only ZodRawShape (default
|
|
4761
|
+
// strip-mode); registerTool() accepts a constructed ZodObject and
|
|
4762
|
+
// honors its unknownKeys policy.
|
|
4763
|
+
inputSchema: z.object({
|
|
4764
|
+
probe_id: z.string().min(1).max(200),
|
|
4765
|
+
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"]),
|
|
4766
|
+
raw_status_code: z.number().int().min(100).max(599).optional(),
|
|
4767
|
+
latency_ms: z.number().int().min(0).max(60_000),
|
|
4768
|
+
}).strict() as any,
|
|
4769
|
+
},
|
|
4770
|
+
async (args: any) => {
|
|
4771
|
+
const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
|
|
4772
|
+
if (!callerDaemon.ok) {
|
|
4773
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
4774
|
+
}
|
|
4775
|
+
try {
|
|
4776
|
+
const r = finalizeProbeAck(args, { network_id: callerDaemon.networkId, daemon_node_id: callerDaemon.daemonNodeId });
|
|
4777
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
|
|
4778
|
+
} catch (e) {
|
|
4779
|
+
return probeFailReply(e);
|
|
4780
|
+
}
|
|
4781
|
+
},
|
|
4782
|
+
);
|
|
2131
4783
|
}
|
|
2132
4784
|
|
|
2133
4785
|
// ────────────────────────────────────────────────────────────────────
|
|
@@ -2145,6 +4797,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2145
4797
|
export interface UpsertNodeWithSec1GuardInput {
|
|
2146
4798
|
node_id: string;
|
|
2147
4799
|
callerNetworkId: string | null;
|
|
4800
|
+
callerUserId?: string | null;
|
|
4801
|
+
callerTokenId?: string | null;
|
|
2148
4802
|
node_name?: string | null;
|
|
2149
4803
|
alias?: string | null;
|
|
2150
4804
|
runtime?: string | null;
|
|
@@ -2157,19 +4811,60 @@ export interface UpsertNodeWithSec1GuardInput {
|
|
|
2157
4811
|
}
|
|
2158
4812
|
export type UpsertNodeOutcome =
|
|
2159
4813
|
| { result: "inserted" | "updated"; node_id: string }
|
|
2160
|
-
| { result: "refused"; reason: "cross_network"; existingNet: string | null; callerNet: string | null }
|
|
4814
|
+
| { result: "refused"; reason: "cross_network" | "token_node_mismatch" | "owner_mismatch"; existingNet: string | null; callerNet: string | null }
|
|
2161
4815
|
| { result: "skipped"; reason: "missing_node_id" };
|
|
2162
4816
|
|
|
2163
4817
|
const _norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x);
|
|
2164
4818
|
|
|
4819
|
+
/**
|
|
4820
|
+
* A capability that changes peer delivery semantics is trusted only when the
|
|
4821
|
+
* authenticating ntok is immutably bound to the exact reported node. Legacy
|
|
4822
|
+
* alias-only tokens may still heartbeat, but cannot opt a node into #698.
|
|
4823
|
+
*/
|
|
4824
|
+
export function trustedConfigSnapshotForNode(
|
|
4825
|
+
snapshot: unknown | null | undefined,
|
|
4826
|
+
callerTokenId: string | null | undefined,
|
|
4827
|
+
nodeId: string,
|
|
4828
|
+
): unknown | null | undefined {
|
|
4829
|
+
if (!snapshot || typeof snapshot !== "object") return snapshot;
|
|
4830
|
+
const copy = { ...(snapshot as Record<string, unknown>) };
|
|
4831
|
+
const token = callerTokenId
|
|
4832
|
+
? db.get<{ bound_node_id: string | null }>(
|
|
4833
|
+
"SELECT bound_node_id FROM api_tokens WHERE token_id = ?1",
|
|
4834
|
+
callerTokenId,
|
|
4835
|
+
)
|
|
4836
|
+
: null;
|
|
4837
|
+
if (token?.bound_node_id !== nodeId) delete copy.peer_reply_inbox_capable;
|
|
4838
|
+
return copy;
|
|
4839
|
+
}
|
|
4840
|
+
|
|
2165
4841
|
export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): UpsertNodeOutcome {
|
|
2166
4842
|
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",
|
|
4843
|
+
const existing = db.get<{ network_id: string | null; owner_user_id: string | null }>(
|
|
4844
|
+
"SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1",
|
|
2169
4845
|
input.node_id,
|
|
2170
4846
|
);
|
|
2171
4847
|
const callerNet = input.callerNetworkId;
|
|
2172
4848
|
|
|
4849
|
+
// RFC-036 — when a token was minted with a node_id binding, a heartbeat may
|
|
4850
|
+
// report only that exact node. Legacy unbound ntok rows remain compatible,
|
|
4851
|
+
// but they can never consume owner-gated schedule intents.
|
|
4852
|
+
if (input.callerTokenId) {
|
|
4853
|
+
const token = db.get<{ bound_node_id: string | null; user_id: string; network_id: string | null }>(
|
|
4854
|
+
"SELECT bound_node_id, user_id, network_id FROM api_tokens WHERE token_id = ?1",
|
|
4855
|
+
input.callerTokenId,
|
|
4856
|
+
);
|
|
4857
|
+
if (token?.bound_node_id && token.bound_node_id !== input.node_id) {
|
|
4858
|
+
return { result: "refused", reason: "token_node_mismatch", existingNet: existing?.network_id ?? null, callerNet };
|
|
4859
|
+
}
|
|
4860
|
+
if (token?.network_id && _norm(token.network_id) !== _norm(callerNet)) {
|
|
4861
|
+
return { result: "refused", reason: "cross_network", existingNet: existing?.network_id ?? null, callerNet };
|
|
4862
|
+
}
|
|
4863
|
+
}
|
|
4864
|
+
if (existing?.owner_user_id && input.callerUserId !== existing.owner_user_id) {
|
|
4865
|
+
return { result: "refused", reason: "owner_mismatch", existingNet: existing.network_id, callerNet };
|
|
4866
|
+
}
|
|
4867
|
+
|
|
2173
4868
|
// Legacy / first-write paths: row missing OR network_id NULL → claim.
|
|
2174
4869
|
const isLegacy = !existing
|
|
2175
4870
|
|| existing.network_id === null
|
|
@@ -2215,10 +4910,38 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
|
|
|
2215
4910
|
callerNet ?? null,
|
|
2216
4911
|
],
|
|
2217
4912
|
);
|
|
2218
|
-
|
|
4913
|
+
const trustedSnapshot = trustedConfigSnapshotForNode(
|
|
4914
|
+
input.config_snapshot,
|
|
4915
|
+
input.callerTokenId,
|
|
4916
|
+
input.node_id,
|
|
4917
|
+
);
|
|
4918
|
+
if (trustedSnapshot) {
|
|
4919
|
+
// RFC-026 §9.3 / #338 PR2+PR3 — promote daemon self-declare fields
|
|
4920
|
+
// to first-class indexable columns alongside the snapshot blob.
|
|
4921
|
+
// The snapshot stays the source of truth for non-list reads; the
|
|
4922
|
+
// columns exist so `list_host_supervisors` doesn't JSON.parse on
|
|
4923
|
+
// every call. typeof-narrow per
|
|
4924
|
+
// per team rule (typeof-narrow extracted JSON fields at the boundary) — zod narrowed but
|
|
4925
|
+
// input.config_snapshot is typed `unknown` here.
|
|
4926
|
+
//
|
|
4927
|
+
// PR3 nit ①: read from nested `daemon_capabilities.*` (canonical
|
|
4928
|
+
// per RFC §9.3 + matches existing hub create_node reads at
|
|
4929
|
+
// tools.ts:2010/2075). PR2 ate from top-level keys, which the
|
|
4930
|
+
// hub create_node path never read → max_concurrent_children
|
|
4931
|
+
// backpressure was dead config + allowlist enforcement bypassed.
|
|
4932
|
+
const snap = trustedSnapshot as Record<string, unknown> | null;
|
|
4933
|
+
const caps = (snap?.daemon_capabilities ?? null) as Record<string, unknown> | null;
|
|
4934
|
+
const runtimesRaw = caps?.runtimes_supported;
|
|
4935
|
+
const allowedRaw = caps?.allowed_secret_keys;
|
|
4936
|
+
const runtimesJson = Array.isArray(runtimesRaw) && runtimesRaw.every(s => typeof s === "string")
|
|
4937
|
+
? JSON.stringify(runtimesRaw)
|
|
4938
|
+
: null;
|
|
4939
|
+
const allowedJson = Array.isArray(allowedRaw) && allowedRaw.every(s => typeof s === "string")
|
|
4940
|
+
? JSON.stringify(allowedRaw)
|
|
4941
|
+
: null;
|
|
2219
4942
|
db.run(
|
|
2220
|
-
`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?
|
|
2221
|
-
[JSON.stringify(
|
|
4943
|
+
`UPDATE nodes SET config_snapshot = ?1, runtimes_supported = ?2, allowed_secret_keys = ?3 WHERE node_id = ?4`,
|
|
4944
|
+
[JSON.stringify(trustedSnapshot), runtimesJson, allowedJson, input.node_id],
|
|
2222
4945
|
);
|
|
2223
4946
|
// RFC-024 — finalize any pending/restarting update whose target
|
|
2224
4947
|
// patch is now reflected in the snapshot. Closes the
|
|
@@ -2228,7 +4951,7 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
|
|
|
2228
4951
|
// update_id to call ack_config_update(applied) itself. Hub does
|
|
2229
4952
|
// it on the new child's behalf by content-matching the patch
|
|
2230
4953
|
// against the reported snapshot.
|
|
2231
|
-
finalizePendingMatchingUpdates(input.node_id,
|
|
4954
|
+
finalizePendingMatchingUpdates(input.node_id, trustedSnapshot);
|
|
2232
4955
|
}
|
|
2233
4956
|
// RFC-026 §2.5 step 4 — on every register/report_status, opportunistically
|
|
2234
4957
|
// close out any pending create_node request whose child_name matches
|
|
@@ -2288,6 +5011,15 @@ export function finalizePendingMatchingUpdates(
|
|
|
2288
5011
|
|
|
2289
5012
|
const snapModel: string | null | undefined = snapshot?.model;
|
|
2290
5013
|
const snapFlags: Record<string, unknown> = (snapshot?.flags && typeof snapshot.flags === "object") ? snapshot.flags : {};
|
|
5014
|
+
// #260 P5 — snapshot.channels is the (sorted, bare-type) channel set
|
|
5015
|
+
// the node currently has forked. agent-node's buildConfigSnapshot
|
|
5016
|
+
// always emits it (even as []), so the "absent field" case here means
|
|
5017
|
+
// an older pre-#260-P5 agent-node — for those, don't finalize a
|
|
5018
|
+
// channels-carrying patch (would false-positive on the OTHER fields).
|
|
5019
|
+
const snapChannelsPresent = Array.isArray(snapshot?.channels);
|
|
5020
|
+
const snapChannels: string[] = snapChannelsPresent
|
|
5021
|
+
? (snapshot.channels as unknown[]).filter((c): c is string => typeof c === "string")
|
|
5022
|
+
: [];
|
|
2291
5023
|
|
|
2292
5024
|
const finalizedIds: string[] = [];
|
|
2293
5025
|
|
|
@@ -2312,6 +5044,35 @@ export function finalizePendingMatchingUpdates(
|
|
|
2312
5044
|
if (snapFlags[k] !== v) { matches = false; break; }
|
|
2313
5045
|
}
|
|
2314
5046
|
}
|
|
5047
|
+
// #260 P5 — channels field content-match. Without this the
|
|
5048
|
+
// node's very first startup report_status matches (patch.flags
|
|
5049
|
+
// is `{}` for a channels-only update, so the flags loop is
|
|
5050
|
+
// trivially satisfied) and hub prematurely marks the update
|
|
5051
|
+
// applied, deleting the pending row + bumping config_revision
|
|
5052
|
+
// BEFORE the doorbell reaches the child. Codex catch on PR #411.
|
|
5053
|
+
//
|
|
5054
|
+
// Set-equality is enough because the patch side comes from
|
|
5055
|
+
// narrowChannelsPatch → already deduped + case-folded + in
|
|
5056
|
+
// EDITABLE_CHANNELS iteration order; the snapshot side comes
|
|
5057
|
+
// from buildConfigSnapshot which mirrors the same shape.
|
|
5058
|
+
if (matches && Array.isArray(patch.channels)) {
|
|
5059
|
+
if (!snapChannelsPresent) {
|
|
5060
|
+
// Pre-#260-P5 agent-node — no channels field in snapshot,
|
|
5061
|
+
// so we can't prove the apply landed. Skip finalize; hub's
|
|
5062
|
+
// F-B reaper still supersedes this eventually.
|
|
5063
|
+
matches = false;
|
|
5064
|
+
} else {
|
|
5065
|
+
const wanted = new Set<string>(patch.channels);
|
|
5066
|
+
const have = new Set<string>(snapChannels);
|
|
5067
|
+
if (wanted.size !== have.size) {
|
|
5068
|
+
matches = false;
|
|
5069
|
+
} else {
|
|
5070
|
+
for (const w of wanted) {
|
|
5071
|
+
if (!have.has(w)) { matches = false; break; }
|
|
5072
|
+
}
|
|
5073
|
+
}
|
|
5074
|
+
}
|
|
5075
|
+
}
|
|
2315
5076
|
}
|
|
2316
5077
|
|
|
2317
5078
|
if (!matches) continue;
|