@sleep2agi/commhub-server 0.9.0-preview.6 → 0.9.0-preview.60
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 +88 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -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-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +55 -7
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -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 +710 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -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 +320 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -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 +641 -0
- package/src/http-gzip.test.ts +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2227
- 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/messages-ack-by-agent-http.test.ts +142 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +104 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/observer-avatar-http.test.ts +428 -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/push.ts +163 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -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 +216 -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/retention.ts +10 -1
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -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 +483 -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 +458 -0
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +3842 -0
- package/src/shared/external-schedule-contract.ts +60 -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 +233 -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 +92 -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 +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -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 +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3231 -204
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -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,12 +28,14 @@ import {
|
|
|
22
28
|
startPendingEnvGcTimer,
|
|
23
29
|
startSweeperTimer,
|
|
24
30
|
auditCreateNode,
|
|
31
|
+
auditCreateNodeStrict,
|
|
25
32
|
resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
|
|
26
33
|
} from "./create-node.js";
|
|
27
34
|
import {
|
|
28
35
|
vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
|
|
29
36
|
VaultError,
|
|
30
37
|
} from "./vault.js";
|
|
38
|
+
import { stripHostLocalPathsForCrossHostSafe, validateAttachments } from "./uploads.js";
|
|
31
39
|
import {
|
|
32
40
|
validateBaseUrl as _validateBaseUrl,
|
|
33
41
|
SUPPORTED_VENDORS,
|
|
@@ -44,11 +52,17 @@ import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias
|
|
|
44
52
|
import {
|
|
45
53
|
ALLOWED_FLAGS,
|
|
46
54
|
SECURITY_SENSITIVE_FLAGS,
|
|
55
|
+
EDITABLE_CHANNELS,
|
|
47
56
|
computeApplyMode,
|
|
48
57
|
validatePatch,
|
|
58
|
+
narrowChannelsPatch,
|
|
49
59
|
isAllowedToChangeFlag,
|
|
50
60
|
} from "./config-apply-validate.js";
|
|
51
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";
|
|
64
|
+
import { parseHubTimestamp } from "./hub-timestamp";
|
|
65
|
+
import { noteTerminalResultRead, sweepNodeRequestContent } from "./node-request-retention.js";
|
|
52
66
|
|
|
53
67
|
function ts(): string {
|
|
54
68
|
return new Date().toTimeString().slice(0, 8);
|
|
@@ -61,7 +75,55 @@ function parseMetaJson(value: unknown): unknown | null {
|
|
|
61
75
|
|
|
62
76
|
function normalizeMetaJson(meta: unknown): string | null {
|
|
63
77
|
if (!meta || typeof meta !== "object") return null;
|
|
64
|
-
|
|
78
|
+
// #222 cross-host safety — strip `path` from meta.attachments[] when
|
|
79
|
+
// `file_id` is also present. Shared helper lives in uploads.ts so the
|
|
80
|
+
// REST handler in index.ts can apply the identical sanitization
|
|
81
|
+
// (otherwise REST and MCP transports would have different meta_json
|
|
82
|
+
// shapes for the same send).
|
|
83
|
+
try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── #1281 — 子节点生命周期工具的参数名统一 ────────────────────────────
|
|
87
|
+
//
|
|
88
|
+
// stop_node / start_node / delete_node 历史上用 `child_node_id`(RFC-027),
|
|
89
|
+
// restart_node / update_node_config 用 `node_id`。同一个对象(子节点)两个名字,
|
|
90
|
+
// 调用方按一个工具的习惯给另一个传就吃 -32602。两侧都已是**已发布契约**
|
|
91
|
+
// (start_node 随 #1273 合入),且 hub API 在 RFC-030 混版滚动期老客户端还在
|
|
92
|
+
// 发老名——所以不能硬改名,改为**两个名字都接受**:
|
|
93
|
+
// · canonical = `node_id`(= DB 列 nodes.node_id、= create_node 的输出、
|
|
94
|
+
// restart/update 历史用名;「建完节点→再操作它」用同一个名才是傻瓜式)。
|
|
95
|
+
// · `child_node_id` 作为**兼容 alias 保留** + deprecation 注释;不加运行时
|
|
96
|
+
// warning(老客户端是设计内兼容,不是错误,别刷屏)。
|
|
97
|
+
// create_node 的 `daemon_node_id` 指宿主 daemon,是另一层语义,不在本次统一内。
|
|
98
|
+
//
|
|
99
|
+
// server.tool 收的是 ZodRawShape(裸对象),挂不了 .refine(),所以「至少一个
|
|
100
|
+
// 必填」+「两个都传须相等」在 handler 侧用 resolveNodeIdArg 兜——返回结构化
|
|
101
|
+
// ok:false 反而比 schema 层的 -32602 更友好。两个名字在 schema 层都做成
|
|
102
|
+
// optional + 宽松 .min(1).max(200)(不带 regex),使命名与校验强度同时统一:
|
|
103
|
+
// 对 restart/update 不变(本就宽松),对 stop/start/delete 是放宽(原 regex 会
|
|
104
|
+
// 在 schema 层挡掉 alias/杂串,放宽后落到查表返回 node_not_found,对合法调用
|
|
105
|
+
// 方无影响,只是错误形态更一致)。
|
|
106
|
+
const NODE_ID_ALIAS_FIELDS = {
|
|
107
|
+
node_id: z.string().min(1).max(200).optional()
|
|
108
|
+
.describe("Target child node ID (canonical, #1281). The value create_node returns. Must be in caller's network."),
|
|
109
|
+
child_node_id: z.string().min(1).max(200).optional()
|
|
110
|
+
.describe("DEPRECATED alias for node_id (#1281): kept for back-compat with pre-unification clients; prefer node_id."),
|
|
111
|
+
} as const;
|
|
112
|
+
|
|
113
|
+
/** #1281 — 把 node_id / child_node_id 收敛成单一内部 id。两个都传且不等 ⇒
|
|
114
|
+
* node_id_conflict(挡「node_id 填 A、child_node_id 填 B」的手滑,比单纯二选一强);
|
|
115
|
+
* 都不传 ⇒ node_id_required。纯函数,模块级导出以便单测直接验解析逻辑。 */
|
|
116
|
+
export function resolveNodeIdArg(a: { node_id?: string; child_node_id?: string }):
|
|
117
|
+
{ ok: true; node_id: string } | { ok: false; error: string; message: string } {
|
|
118
|
+
const n = a.node_id, c = a.child_node_id;
|
|
119
|
+
if (n && c && n !== c) {
|
|
120
|
+
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)" };
|
|
121
|
+
}
|
|
122
|
+
const id = n ?? c;
|
|
123
|
+
if (!id) {
|
|
124
|
+
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)" };
|
|
125
|
+
}
|
|
126
|
+
return { ok: true, node_id: id };
|
|
65
127
|
}
|
|
66
128
|
|
|
67
129
|
export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null) {
|
|
@@ -87,30 +149,213 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
87
149
|
}],
|
|
88
150
|
};
|
|
89
151
|
};
|
|
90
|
-
//
|
|
91
|
-
|
|
152
|
+
// MCP-side auth context in the shape network-scope.ts helpers take.
|
|
153
|
+
// null = legacy global-token / open-dev mode (unscoped, allow-all).
|
|
154
|
+
const mcpAuthCtx = enforceUserId ? { userId: enforceUserId, networkId: enforceNetworkId ?? null } : null;
|
|
155
|
+
|
|
156
|
+
// If enforceNetworkId is set (ntok_), override any client-supplied
|
|
157
|
+
// network_id. #517: for utok_ with no explicit network_id, fall back to
|
|
158
|
+
// the user's single membership via the SAME singleNetworkId used by REST
|
|
159
|
+
// POST /api/task (network-scope.ts) — utok_ rows carry network_id=null by
|
|
160
|
+
// design, so without this fallback single-network nodes could read
|
|
161
|
+
// everything but write nothing. Multi-network stays null (ambiguous) and
|
|
162
|
+
// canWrite rejects with network_id_required.
|
|
163
|
+
const getNetworkId = (clientNetId?: string | null) => {
|
|
164
|
+
const explicit = enforceNetworkId ?? clientNetId ?? null;
|
|
165
|
+
if (explicit !== null || !enforceUserId) return explicit;
|
|
166
|
+
return singleNetworkId({ networkId: null, networkIds: getUserNetworkIds(enforceUserId) });
|
|
167
|
+
};
|
|
92
168
|
|
|
93
|
-
// Check write access
|
|
94
|
-
//
|
|
95
|
-
//
|
|
169
|
+
// Check write access — delegates to the shared canRestWriteNetwork so MCP
|
|
170
|
+
// and REST cannot drift again (#517). isAdmin=false: MCP has no admin
|
|
171
|
+
// bypass today; keep behavior identical to before the extraction.
|
|
96
172
|
const canWrite = (effectiveNetworkId?: string | null): boolean => {
|
|
97
|
-
if (!enforceUserId) return true; // legacy global token mode, allow
|
|
98
173
|
const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
|
|
99
|
-
|
|
100
|
-
const role = getUserNetworkRole(enforceUserId, netId);
|
|
101
|
-
return !!role && role !== "viewer"; // owner/admin/member can write
|
|
174
|
+
return canRestWriteNetwork(mcpAuthCtx, netId, false);
|
|
102
175
|
};
|
|
103
176
|
|
|
177
|
+
// #517: name the REAL cause. The old catch-all blamed permissions for
|
|
178
|
+
// what was actually an unresolvable network, sending operators down the
|
|
179
|
+
// wrong debugging path (roles/membership) for hours.
|
|
104
180
|
const writeDeniedReply = (effectiveNetworkId?: string | null, action = "write") => {
|
|
105
181
|
const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
182
|
+
let error: string;
|
|
183
|
+
let message: string;
|
|
184
|
+
if (!netId) {
|
|
185
|
+
const memberships = enforceUserId ? getUserNetworkIds(enforceUserId) : [];
|
|
186
|
+
error = "network_id_required";
|
|
187
|
+
message = memberships.length === 0
|
|
188
|
+
? "user token has no network memberships; join or create a network first"
|
|
189
|
+
: `user token spans ${memberships.length} networks; pass network_id explicitly (see /api/auth/me networks[].network_id)`;
|
|
190
|
+
} else if (enforceUserId && !getUserNetworkRole(enforceUserId, netId)) {
|
|
191
|
+
error = "access_denied";
|
|
192
|
+
message = "access denied to requested network (not a member)";
|
|
193
|
+
} else {
|
|
194
|
+
error = "permission_denied";
|
|
195
|
+
message = action === "send_task"
|
|
109
196
|
? "Viewer role cannot send tasks"
|
|
110
197
|
: "Viewer role cannot write to this network";
|
|
111
|
-
|
|
198
|
+
}
|
|
199
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error, message }) }] };
|
|
112
200
|
};
|
|
113
201
|
|
|
202
|
+
const skillHubReply = (value: Record<string, unknown>) => ({
|
|
203
|
+
content: [{ type: "text" as const, text: JSON.stringify(value) }],
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// SkillHub is a network registry, not a public filesystem. Content is an
|
|
207
|
+
// immutable SKILL.md snapshot; identity comes only from the authenticated
|
|
208
|
+
// MCP principal (never from request fields).
|
|
209
|
+
server.tool(
|
|
210
|
+
"submit_skill",
|
|
211
|
+
"Submit an immutable SKILL.md version to the caller's network SkillHub. Node identity is token-bound. New submissions require review.",
|
|
212
|
+
{
|
|
213
|
+
slug: z.string().min(2).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
214
|
+
name: z.string().min(1).max(120),
|
|
215
|
+
description: z.string().max(1000).optional(),
|
|
216
|
+
version: z.string().min(1).max(40).regex(/^[0-9A-Za-z]+(?:[._-][0-9A-Za-z]+)*$/),
|
|
217
|
+
content: z.string().min(1).max(128 * 1024).refine(value => !value.includes("\0"), "content must not contain NUL bytes"),
|
|
218
|
+
network_id: z.string().max(200).optional(),
|
|
219
|
+
},
|
|
220
|
+
async ({ slug, name, description, version, content, network_id: clientNetId }) => {
|
|
221
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
222
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
223
|
+
if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
|
|
224
|
+
const sourceType = callerTokenIsNetwork ? "node" : "user";
|
|
225
|
+
if (sourceType === "node" && !callerAlias) return skillHubReply({ ok: false, error: "node_identity_unbound" });
|
|
226
|
+
const contentHash = createHash("sha256").update(content, "utf8").digest("hex");
|
|
227
|
+
const existing = db.get<any>(
|
|
228
|
+
`SELECT skill_id, content_hash, status FROM skillhub_skills
|
|
229
|
+
WHERE network_id = ?1 AND slug = ?2 AND version = ?3`,
|
|
230
|
+
effectiveNetId, slug, version,
|
|
231
|
+
);
|
|
232
|
+
if (existing) {
|
|
233
|
+
if (existing.content_hash === contentHash) {
|
|
234
|
+
return skillHubReply({ ok: true, idempotent: true, skill_id: existing.skill_id, status: existing.status });
|
|
235
|
+
}
|
|
236
|
+
return skillHubReply({ ok: false, error: "skill_version_conflict", hint: "publish changed content under a new version" });
|
|
237
|
+
}
|
|
238
|
+
const skillId = `skill_${uuidv4()}`;
|
|
239
|
+
try {
|
|
240
|
+
db.run(
|
|
241
|
+
`INSERT INTO skillhub_skills
|
|
242
|
+
(skill_id, network_id, slug, name, description, version, content, content_hash, status, source_type, source_alias, created_by_user)
|
|
243
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
|
|
244
|
+
[skillId, effectiveNetId, slug, name.trim(), description?.trim() || "", version, content, contentHash, sourceType, callerAlias, enforceUserId || null],
|
|
245
|
+
);
|
|
246
|
+
} catch (error: any) {
|
|
247
|
+
// Two submitters may pass the preflight SELECT together. Resolve the
|
|
248
|
+
// UNIQUE race into the same deterministic idempotent/conflict contract
|
|
249
|
+
// instead of leaking a storage exception as HTTP/MCP 500.
|
|
250
|
+
if (!/unique|duplicate key/i.test(error?.message || "")) throw error;
|
|
251
|
+
const winner = db.get<any>(
|
|
252
|
+
`SELECT skill_id, content_hash, status FROM skillhub_skills
|
|
253
|
+
WHERE network_id = ?1 AND slug = ?2 AND version = ?3`,
|
|
254
|
+
effectiveNetId, slug, version,
|
|
255
|
+
);
|
|
256
|
+
if (!winner) throw error;
|
|
257
|
+
if (winner.content_hash === contentHash) {
|
|
258
|
+
return skillHubReply({ ok: true, idempotent: true, skill_id: winner.skill_id, status: winner.status });
|
|
259
|
+
}
|
|
260
|
+
return skillHubReply({ ok: false, error: "skill_version_conflict", hint: "publish changed content under a new version" });
|
|
261
|
+
}
|
|
262
|
+
db.run(
|
|
263
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
264
|
+
VALUES (?1, ?2, 'skill_submit', 'skill', ?3, ?4, ?5)`,
|
|
265
|
+
[enforceUserId || null, callerAlias || null, skillId, JSON.stringify({ slug, version, source_type: sourceType }), effectiveNetId],
|
|
266
|
+
);
|
|
267
|
+
return skillHubReply({ ok: true, skill_id: skillId, status: "pending", source_type: sourceType, source_alias: callerAlias });
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
server.tool(
|
|
272
|
+
"list_skills",
|
|
273
|
+
"List published skills in a network. Owners/admins may include pending review items.",
|
|
274
|
+
{
|
|
275
|
+
network_id: z.string().max(200).optional(),
|
|
276
|
+
include_pending: z.boolean().optional(),
|
|
277
|
+
query: z.string().max(120).optional(),
|
|
278
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
279
|
+
},
|
|
280
|
+
async ({ network_id: clientNetId, include_pending, query, limit }) => {
|
|
281
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
282
|
+
if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "read");
|
|
283
|
+
const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
|
|
284
|
+
if (enforceUserId && !role) return writeDeniedReply(effectiveNetId, "read");
|
|
285
|
+
// An ntok_ belongs to a node even when it was minted by the network
|
|
286
|
+
// owner. Never inherit the owner's review power through that token.
|
|
287
|
+
const reviewer = !callerTokenIsNetwork && (role === "owner" || role === "admin");
|
|
288
|
+
const showPending = !!include_pending && reviewer;
|
|
289
|
+
const params: unknown[] = [effectiveNetId];
|
|
290
|
+
let sql = `SELECT skill_id, slug, name, description, version, status, source_type, source_alias,
|
|
291
|
+
created_at, updated_at, reviewed_at, review_note
|
|
292
|
+
FROM skillhub_skills WHERE network_id = ?1`;
|
|
293
|
+
if (!showPending) sql += ` AND status = 'published'`;
|
|
294
|
+
if (query?.trim()) {
|
|
295
|
+
params.push(`%${query.trim()}%`);
|
|
296
|
+
sql += ` AND (slug LIKE ?${params.length} OR name LIKE ?${params.length} OR description LIKE ?${params.length})`;
|
|
297
|
+
}
|
|
298
|
+
sql += ` ORDER BY updated_at DESC LIMIT ${limit ?? 100}`;
|
|
299
|
+
return skillHubReply({ ok: true, reviewer, skills: db.all(sql, ...params) });
|
|
300
|
+
},
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
server.tool(
|
|
304
|
+
"get_skill",
|
|
305
|
+
"Read one SkillHub SKILL.md. Pending content is visible only to owners/admins.",
|
|
306
|
+
{ skill_id: z.string().regex(/^skill_[A-Za-z0-9_-]+$/).max(200), network_id: z.string().max(200).optional() },
|
|
307
|
+
async ({ skill_id, network_id: clientNetId }) => {
|
|
308
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
309
|
+
if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "read");
|
|
310
|
+
const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
|
|
311
|
+
if (enforceUserId && !role) return writeDeniedReply(effectiveNetId, "read");
|
|
312
|
+
const row = db.get<any>(
|
|
313
|
+
`SELECT skill_id, slug, name, description, version, content, status,
|
|
314
|
+
source_type, source_alias, created_at, updated_at, reviewed_at, review_note
|
|
315
|
+
FROM skillhub_skills WHERE skill_id = ?1 AND network_id = ?2`,
|
|
316
|
+
skill_id, effectiveNetId,
|
|
317
|
+
);
|
|
318
|
+
if (!row) return skillHubReply({ ok: false, error: "skill_not_found" });
|
|
319
|
+
const reviewer = !callerTokenIsNetwork && (role === "owner" || role === "admin");
|
|
320
|
+
if (row.status !== "published" && !reviewer) {
|
|
321
|
+
return skillHubReply({ ok: false, error: "skill_not_found" });
|
|
322
|
+
}
|
|
323
|
+
return skillHubReply({ ok: true, skill: row });
|
|
324
|
+
},
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
server.tool(
|
|
328
|
+
"review_skill",
|
|
329
|
+
"Publish or reject a pending SkillHub submission. Network owner/admin only.",
|
|
330
|
+
{
|
|
331
|
+
skill_id: z.string().regex(/^skill_[A-Za-z0-9_-]+$/).max(200),
|
|
332
|
+
decision: z.enum(["published", "rejected"]),
|
|
333
|
+
note: z.string().max(1000).optional(),
|
|
334
|
+
network_id: z.string().max(200).optional(),
|
|
335
|
+
},
|
|
336
|
+
async ({ skill_id, decision, note, network_id: clientNetId }) => {
|
|
337
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
338
|
+
if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
|
|
339
|
+
const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
|
|
340
|
+
if (callerTokenIsNetwork || (role !== "owner" && role !== "admin")) return skillHubReply({ ok: false, error: "skill_review_admin_required" });
|
|
341
|
+
const row = db.get<any>(`SELECT status FROM skillhub_skills WHERE skill_id = ?1 AND network_id = ?2`, skill_id, effectiveNetId);
|
|
342
|
+
if (!row) return skillHubReply({ ok: false, error: "skill_not_found" });
|
|
343
|
+
if (row.status !== "pending") return skillHubReply({ ok: false, error: "skill_not_pending", status: row.status });
|
|
344
|
+
const updated = db.run(
|
|
345
|
+
`UPDATE skillhub_skills SET status = ?1, review_note = ?2, reviewed_by_user = ?3,
|
|
346
|
+
reviewed_at = datetime('now'), updated_at = datetime('now') WHERE skill_id = ?4 AND network_id = ?5 AND status = 'pending'`,
|
|
347
|
+
[decision, note?.trim() || null, enforceUserId || null, skill_id, effectiveNetId],
|
|
348
|
+
);
|
|
349
|
+
if (updated.changes !== 1) return skillHubReply({ ok: false, error: "skill_not_pending" });
|
|
350
|
+
db.run(
|
|
351
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
352
|
+
VALUES (?1, ?2, 'skill_review', 'skill', ?3, ?4, ?5)`,
|
|
353
|
+
[enforceUserId || null, callerAlias || null, skill_id, JSON.stringify({ decision }), effectiveNetId],
|
|
354
|
+
);
|
|
355
|
+
return skillHubReply({ ok: true, skill_id, status: decision });
|
|
356
|
+
},
|
|
357
|
+
);
|
|
358
|
+
|
|
114
359
|
const addScope = (sql: string, params: any[], networkId?: string | null, column = "network_id"): string => {
|
|
115
360
|
if (!networkId) return sql;
|
|
116
361
|
sql += ` AND ${column} = ?${params.length + 1}`;
|
|
@@ -118,15 +363,25 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
118
363
|
return sql;
|
|
119
364
|
};
|
|
120
365
|
|
|
366
|
+
/** #1548 —— 把一个还标着 blocked 的会话拉回 idle。只在「它刚发出终态回复 / 刚派了任务」这两个活性证据处调用。 */
|
|
367
|
+
const releaseBlockedSession = (alias: string | null | undefined, networkId: string | null | undefined): void => {
|
|
368
|
+
if (!alias) return;
|
|
369
|
+
const params: any[] = [alias];
|
|
370
|
+
let sql = "UPDATE sessions SET status = 'idle', updated_at = datetime('now') WHERE alias = ?1 AND status = 'blocked'";
|
|
371
|
+
sql = addScope(sql, params, networkId ?? null);
|
|
372
|
+
try {
|
|
373
|
+
db.run(sql, params);
|
|
374
|
+
} catch {
|
|
375
|
+
// 展示层的一次修正失败不能影响主路径。
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
121
379
|
type ReadScope = { networkId?: string | null; networkIds?: string[] | null; denied?: string };
|
|
122
380
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
enforceUserId
|
|
128
|
-
).map((row) => row.network_id);
|
|
129
|
-
};
|
|
381
|
+
// Delegates to the shared membership query (network-scope.ts) — was a
|
|
382
|
+
// byte-for-byte duplicate of getUserNetworkIds before #517.
|
|
383
|
+
const getReadableNetworkIds = (): string[] =>
|
|
384
|
+
enforceUserId ? getUserNetworkIds(enforceUserId) : [];
|
|
130
385
|
|
|
131
386
|
const resolveReadScope = (clientNetId?: string | null): ReadScope => {
|
|
132
387
|
if (!enforceUserId) return { networkId: clientNetId ?? null, networkIds: null };
|
|
@@ -141,6 +396,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
141
396
|
return { networkId: null, networkIds: getReadableNetworkIds() };
|
|
142
397
|
};
|
|
143
398
|
|
|
399
|
+
// RFC-027 §2.3 race-free invariant — assertNodeActive lives in
|
|
400
|
+
// server/src/lifecycle-guard.ts so REST handlers in server/src/index.ts
|
|
401
|
+
// can use the SAME code path. PR1.1 had it inline here; PR1.2a
|
|
402
|
+
// (#346 review catch) extracted because the closure scope made it
|
|
403
|
+
// unreachable from REST and left the §2.3 race open on dashboard
|
|
404
|
+
// Dispatch (POST /api/task + /api/broadcast). Per
|
|
405
|
+
// 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.
|
|
406
|
+
|
|
144
407
|
const addReadScope = (sql: string, params: any[], scope: ReadScope, column = "network_id"): string => {
|
|
145
408
|
if (scope.networkId) {
|
|
146
409
|
sql += ` AND ${column} = ?${params.length + 1}`;
|
|
@@ -161,6 +424,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
161
424
|
| { state: "offline"; alias: string; session: any; message: string }
|
|
162
425
|
| { state: "not_found"; alias: string; message: string };
|
|
163
426
|
|
|
427
|
+
// Only call after the authenticated, network-scoped lookup resolved a
|
|
428
|
+
// concrete target. Error paths intentionally omit this identity object.
|
|
429
|
+
const actualTo = (target: Exclude<DeliveryTarget, { state: "not_found" }>, networkId?: string | null) => ({
|
|
430
|
+
alias: target.alias,
|
|
431
|
+
to_node_id: target.session?.node_id ?? null,
|
|
432
|
+
network_id: networkId ?? null,
|
|
433
|
+
});
|
|
434
|
+
|
|
164
435
|
const scopedSessionStatus = (alias: string, networkId?: string | null) => {
|
|
165
436
|
const params: any[] = [alias];
|
|
166
437
|
let sql = "SELECT status, updated_at, last_seen_at, node_id FROM sessions WHERE alias = ?1";
|
|
@@ -261,9 +532,39 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
261
532
|
model: z.string().max(200).optional().describe("AI model name"),
|
|
262
533
|
node_name: z.string().max(200).optional().describe("Stable node display name (may differ from alias)"),
|
|
263
534
|
network_id: z.string().max(200).optional().describe("Network this agent belongs to"),
|
|
535
|
+
// ╭─ 🔴 往 report_status 里加字段之前先读这一段(#1545 实测,zod 4.3.6)─────────╮
|
|
536
|
+
// │ **这份 schema 的子对象一半严一半松,而后果完全不同。**
|
|
537
|
+
// │
|
|
538
|
+
// │ 非 strict:host / process_telemetry / config_snapshot / daemon_capabilities
|
|
539
|
+
// │ `.strict()`:external_schedules(含其 schedules[] 元素)
|
|
540
|
+
// │ side_thread_capability(含其 exactBoundary)
|
|
541
|
+
// │
|
|
542
|
+
// │ 往**非 strict** 的对象里加一个 hub 还不认识的键 ⇒ zod **静默丢弃**。
|
|
543
|
+
// │ 节点不会掉线,但字段人间蒸发 —— 现场表现为「daemon 明明发了、hub 上没有」,
|
|
544
|
+
// │ 排查起来和 daemon 侧 bug 一模一样。
|
|
545
|
+
// │ 往**strict** 的对象里加同一个键 ⇒ `unrecognized_keys`,**整份 report_status 被拒**。
|
|
546
|
+
// │ 而 agent-node 的 register() 是 `await` 且无人 catch ⇒ **节点进程当场死掉**
|
|
547
|
+
// │ (#1225 就是这么全网躺倒的,见下面 host.ip 那段)。
|
|
548
|
+
// │
|
|
549
|
+
// │ 所以「新字段能不能 daemon 先发」**没有统一答案,取决于你往哪个子对象里加**:
|
|
550
|
+
// │ 非 strict → 可以先发(会丢,不会死);strict → hub 必须先合,否则升级即失联。
|
|
551
|
+
// │
|
|
552
|
+
// │ 🔴 别把这条读成「strict 更危险、都改成非 strict」。两者各有其位:
|
|
553
|
+
// │ external_schedules / side_thread_capability 是**有界快照**,strict 挡的是
|
|
554
|
+
// │ 「节点往里塞任意键」;daemon_capabilities 是**能力自述**,天然要向前兼容
|
|
555
|
+
// │ (旧 hub 见到新能力应当忽略而不是拒绝整份上报)。**加字段前先看清你在哪一边。**
|
|
556
|
+
// ╰──────────────────────────────────────────────────────────────────────────────╯
|
|
264
557
|
host: z.object({
|
|
265
|
-
|
|
266
|
-
ip:
|
|
558
|
+
// 🔴 `.nullable()` 不是防御性冗余,是**发送方声明的类型**:agent-node 的
|
|
559
|
+
// HostTelemetry 是 `ip: string | null`,`firstNonInternalIPv4()` 在没有
|
|
560
|
+
// 非回环 IPv4 的机器上返回 null(--network none 的容器、断网的笔记本、
|
|
561
|
+
// 部分 CI runner)。此前这里只有 `.optional()`,于是那台机器上的
|
|
562
|
+
// agent-node 一启动就收到 MCP -32602,而 register() 是 await 且无人 catch
|
|
563
|
+
// —— **整个节点进程当场死掉**,和 runtime 无关。
|
|
564
|
+
// 同一个对象里四个数值字段本来就写着 `.nullable().optional()`,
|
|
565
|
+
// 两个字符串字段是唯一的例外;正确写法一直在隔壁那一行。
|
|
566
|
+
hostname: z.string().max(200).nullable().optional(),
|
|
567
|
+
ip: z.string().max(200).nullable().optional(),
|
|
267
568
|
cpu_load_1min: z.number().nullable().optional(),
|
|
268
569
|
cpu_cores: z.number().nullable().optional(),
|
|
269
570
|
mem_total_gb: z.number().nullable().optional(),
|
|
@@ -281,6 +582,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
281
582
|
uptime_seconds: z.number().nullable().optional(),
|
|
282
583
|
in_flight_count: z.number().nullable().optional(),
|
|
283
584
|
}).optional().describe("Per-agent process telemetry reported by agent-node"),
|
|
585
|
+
external_schedules: z.object({
|
|
586
|
+
observed_at: z.string().datetime({ offset: true }).max(64),
|
|
587
|
+
schedules: z.array(z.object({
|
|
588
|
+
id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/),
|
|
589
|
+
name: z.string().min(1).max(200),
|
|
590
|
+
kind: z.enum(["cron", "systemd", "tmux", "playwright", "custom"]),
|
|
591
|
+
frequency: z.string().min(1).max(120),
|
|
592
|
+
last_run_at: z.string().datetime({ offset: true }).max(64).nullable(),
|
|
593
|
+
last_status: z.enum(["success", "failed", "running", "unknown"]),
|
|
594
|
+
last_error: z.string().max(500).nullable(),
|
|
595
|
+
next_run_at: z.string().datetime({ offset: true }).max(64).nullable(),
|
|
596
|
+
log_ref: z.string().min(1).max(255).nullable(),
|
|
597
|
+
enabled: z.boolean(),
|
|
598
|
+
// RFC-036 — only agent-node managed cron markers advertise write
|
|
599
|
+
// capability. Legacy/other kinds omit these fields and stay read-only.
|
|
600
|
+
editable: z.boolean().optional(),
|
|
601
|
+
revision: z.number().int().min(0).optional(),
|
|
602
|
+
}).strict()).max(64),
|
|
603
|
+
error: z.enum(["invalid_manifest", "unsafe_manifest", "read_failed"]).optional(),
|
|
604
|
+
}).strict().optional().describe("Bounded node-reported external schedule snapshot; never includes host paths or commands"),
|
|
284
605
|
// RFC-024 B6 — masked snapshot of the node's effective config
|
|
285
606
|
// (model + 6 dashboard-editable flags). Secrets ARE NOT in this
|
|
286
607
|
// shape (env._envRef stays on host); the dashboard reads this
|
|
@@ -291,16 +612,120 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
291
612
|
// can grey out remote-restart for them.
|
|
292
613
|
config_snapshot: z.object({
|
|
293
614
|
model: z.string().max(200).optional().nullable(),
|
|
294
|
-
flags: z.record(z.unknown()).optional(),
|
|
615
|
+
flags: z.record(z.string(), z.unknown()).optional(),
|
|
295
616
|
config_revision: z.number().int().min(0).optional(),
|
|
296
617
|
config_update_capable: z.boolean().optional(),
|
|
618
|
+
peer_reply_inbox_capable: z.literal(true).optional(),
|
|
619
|
+
side_thread_capability: z.object({
|
|
620
|
+
supported: z.boolean(),
|
|
621
|
+
runtime: z.string().max(64),
|
|
622
|
+
runtimeVersion: z.string().max(64),
|
|
623
|
+
topology: z.string().max(64),
|
|
624
|
+
evidenceRevision: z.string().max(100),
|
|
625
|
+
mode: z.literal("native-exact-fork").optional(),
|
|
626
|
+
exactBoundary: z.object({ through: z.boolean(), before: z.boolean() }).strict().optional(),
|
|
627
|
+
reason: z.enum(["runtime", "version", "topology", "experimental-api", "exact-boundary"]).optional(),
|
|
628
|
+
}).strict().optional(),
|
|
297
629
|
// RFC-026 P2 / #338 — daemon role surfaced for hub /api/nodes
|
|
298
630
|
// discovery (#337 extracts this field). "host_supervisor" =
|
|
299
631
|
// anet daemon. Default-stripping zod would drop this otherwise.
|
|
300
632
|
role: z.string().max(64).optional().nullable(),
|
|
633
|
+
// RFC-026 §9.3 / #338 PR3 — daemon self-declare nested under
|
|
634
|
+
// `daemon_capabilities` (canonical shape per existing hub reads
|
|
635
|
+
// at tools.ts:2010/2075 — PR1/PR2 placed these top-level, hub
|
|
636
|
+
// never saw them, max_concurrent_children stayed default + the
|
|
637
|
+
// allowlists stayed unenforced. PR3 nit ① per 通信龙).
|
|
638
|
+
// Soft caps avoid abuse via attacker daemon.
|
|
639
|
+
// 🔴 这个对象**故意不是 `.strict()`** —— 能力自述必须向前兼容:
|
|
640
|
+
// 旧 hub 见到新 daemon 报的新能力,应当忽略那一格,而不是拒掉整份 report
|
|
641
|
+
// 把节点踢下线。代价是新键在 hub 升级前会被**静默丢弃**(所以新字段仍然
|
|
642
|
+
// 应当 hub 先合)。同一份 schema 里 side_thread_capability / external_schedules
|
|
643
|
+
// 是 `.strict()` 的,后果不同 —— 见 `host:` 上方那段方框注释。
|
|
644
|
+
daemon_capabilities: z.object({
|
|
645
|
+
runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
|
|
646
|
+
allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
|
|
647
|
+
max_concurrent_children: z.number().int().min(1).max(1000).optional(),
|
|
648
|
+
// #1353 —— daemon **当下**能不能创建节点。与 runtimes_supported 不是一回事:
|
|
649
|
+
// 后者是声明(我支持哪些 runtime),这个是实际能力。一个 daemon 可以声明支持
|
|
650
|
+
// 三种 runtime,同时因为 ANET_BIN pin 解析不出来而一种也创建不了。
|
|
651
|
+
//
|
|
652
|
+
// 🔴 为什么 hub 需要看见它:pin 只存在于 /etc/anet-daemon/path.conf 或
|
|
653
|
+
// 显式开启的环境变量里,**重启不带环境就会丢**。丢了之后 daemon 照常注册、
|
|
654
|
+
// 在线、收 doorbell,hub 返回 ok:true + request_id —— 而节点永远不出现。
|
|
655
|
+
// 在此之前 hub 完全看不出区别,Dashboard 的「选服务器」还会把它列为可选。
|
|
656
|
+
can_create_nodes: z.boolean().optional(),
|
|
657
|
+
// 只收**代码**,不收原始报错文本 —— 上游 unsafePathHelp() 的消息里带完整机器路径,
|
|
658
|
+
// 而这个字段会走到 Dashboard。enum 而不是 string:一个自由文本字段等于给
|
|
659
|
+
// 「把路径塞进来」留了口子。
|
|
660
|
+
// #1353 —— 四类失败,修法完全不同:
|
|
661
|
+
// identity 重装 anet / unset ANET_BIN_ABS
|
|
662
|
+
// source 写 /etc/anet-daemon/path.conf(**要 sudo**)
|
|
663
|
+
// shape 换成 realpath
|
|
664
|
+
// permission **一行 chmod go-w**
|
|
665
|
+
// 混成一条会让人修错方向 —— 2026-08-28 我一天里撞到其中两类,看到的是同一条错误。
|
|
666
|
+
//
|
|
667
|
+
// 🔴 `anet_bin_pin_unresolved` **必须留着**:已经在跑的 agent-node@2.5.0-preview.40
|
|
668
|
+
// 发的就是这个值。zod 对象里任何一个字段验证失败会让**整个 report_status 被拒**,
|
|
669
|
+
// 不只是丢掉这一格 —— 那会让所有 .40 daemon 在 hub 上变成失联。
|
|
670
|
+
// 删一个 enum 值的代价,远大于多留一个。
|
|
671
|
+
// 🔴 仍然是 enum 不是 string:自由文本字段就是给机器路径留的口子,
|
|
672
|
+
// 而上游 e.message 里带的正是完整路径。
|
|
673
|
+
create_nodes_blocked_reason: z.enum([
|
|
674
|
+
"anet_bin_pin_unresolved", // 兼容:.40 及更早
|
|
675
|
+
"anet_bin_identity",
|
|
676
|
+
"anet_bin_source",
|
|
677
|
+
"anet_bin_shape",
|
|
678
|
+
"anet_bin_permission",
|
|
679
|
+
"anet_bin_unknown",
|
|
680
|
+
]).optional(),
|
|
681
|
+
// #1545 —— 上面那一格**是什么时候测出来的**。
|
|
682
|
+
//
|
|
683
|
+
// 🔴 版本号一律带包名 + 完整版本:仓里三个包各自独立编号,裸的 `preview.NN`
|
|
684
|
+
// 同时是三个不同的时间点。此处曾写成「preview.67」——
|
|
685
|
+
// 那是 agent-network 的号,agent-node 根本没有 .67(npm 404,最高 .56)。
|
|
686
|
+
// 🔴 为什么必须有:**agent-node ≤ 2.5.0-preview.54** 的 `daemonCreateCapability()`
|
|
687
|
+
// 用一个进程级缓存(`_createCapCache`)只算一次。开机时 pin 坏 ⇒ 之后
|
|
688
|
+
// 永远上报 blocked(哪怕运维已经写好 path.conf);开机时好 ⇒ 之后把二进制
|
|
689
|
+
// chmod 掉也**永远上报 ready**。后者是朝「没问题」方向说谎。
|
|
690
|
+
// 而 hub 这边,一个 3 秒前测的 blocked 和一个三周前测的 blocked,
|
|
691
|
+
// 在 `last_seen_at` 上长得一模一样 —— 心跳是新的,那一格不是。
|
|
692
|
+
//
|
|
693
|
+
// 🔴 为什么是「多久以前」(时长)而不是绝对时间戳:这个值来自**节点自己的钟**。
|
|
694
|
+
// 时钟偏移下,绝对时间戳会算出一个既可能"永远新鲜"也可能"来自 1970"的年龄,
|
|
695
|
+
// 而且错的方向不可预测。时长对偏移免疫 —— hub 用自己的钟在收到时换算。
|
|
696
|
+
//
|
|
697
|
+
// 🔴 为什么不加 `.min()/.max()/.int()`:**这几个约束都会让整条 report_status 被拒**
|
|
698
|
+
// (zod 对象里任何一个已知字段验证失败 = 整份被拒,不是丢掉这一格),
|
|
699
|
+
// 而这一格是纯诊断信息,不值得拿一台节点的在线状态去换。
|
|
700
|
+
// 收得宽,**在读取处消毒**(见 /api/host-supervisors)。
|
|
701
|
+
// 这条不是我新立的:同一个 schema 上方 `anet_bin_pin_unresolved` 那条注释
|
|
702
|
+
// 记的就是同一次教训 —— 删一个 enum 值会让所有 .40 daemon 失联。
|
|
703
|
+
//
|
|
704
|
+
// 🔴 `.catch(null)` 不是装饰:光写 `z.number()` **仍然会拒 NaN**
|
|
705
|
+
// (实测:`z.number().nullable().optional()` 对 NaN 抛),而那意味着
|
|
706
|
+
// 一次算错的时长能让整台节点在 hub 上失联 —— 正是 #1225 里 host.ip
|
|
707
|
+
// 那个形状。`.catch(null)` 让这一格**在任何输入下都不可能拒掉整份 report**:
|
|
708
|
+
// 合法数字透传,其余一律变 null(读取侧当作「没报」)。
|
|
709
|
+
// 类型仍然是 number 而不是 unknown —— 别给一个 attacker daemon
|
|
710
|
+
// 留下"往快照里塞任意大对象"的口子(同 schema 上方 soft cap 的立场)。
|
|
711
|
+
//
|
|
712
|
+
// 不上报这一格 ≠ 0。旧 daemon 压根不发,读的人必须能把
|
|
713
|
+
// 「刚测的」「很久以前测的」「不知道」分成三件事说。
|
|
714
|
+
create_capability_observed_ms_ago: z.number().nullable().catch(null).optional(),
|
|
715
|
+
}).optional(),
|
|
301
716
|
}).optional().describe("RFC-024 — masked node config snapshot"),
|
|
717
|
+
// app#225 follow-up — the reporting process answers the `rules_file`
|
|
718
|
+
// doorbell (agent-node, and the claude-code channel server node-server).
|
|
719
|
+
// Top-level instead of config_snapshot: claude-code sessions usually have
|
|
720
|
+
// no `nodes` row, and config_snapshot is persisted per node_id. Only a
|
|
721
|
+
// node token bound to this same alias can set it; see the UPDATE below.
|
|
722
|
+
rules_file_capable: z.literal(true).optional(),
|
|
723
|
+
// Node skills view — same doorbell, ops skills_list / skill_read.
|
|
724
|
+
skills_capable: z.literal(true).optional(),
|
|
725
|
+
// Project folder view — same doorbell, ops files_list / file_read.
|
|
726
|
+
files_capable: z.literal(true).optional(),
|
|
302
727
|
},
|
|
303
|
-
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 }) => {
|
|
728
|
+
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, rules_file_capable: rulesFileCapable, skills_capable: skillsCapable, files_capable: filesCapable }) => {
|
|
304
729
|
const effectiveNetId = getNetworkId(netId);
|
|
305
730
|
const sessionNetId = effectiveNetId ?? "default";
|
|
306
731
|
if (!callerTokenIsNetwork || !enforceNetworkId) {
|
|
@@ -338,6 +763,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
338
763
|
};
|
|
339
764
|
}
|
|
340
765
|
}
|
|
766
|
+
// #203 identity guard — network tokens must not silently rebind their
|
|
767
|
+
// own name via report_status. Without this, a runtime whose ALIAS
|
|
768
|
+
// drifted (env leak / wrong --alias / CurrentAliasResolver seeded from
|
|
769
|
+
// the wrong node_id) could rewrite api_tokens.name and cause every
|
|
770
|
+
// subsequent send_task from this token to be attributed to the drifted
|
|
771
|
+
// alias — the observed #203 symptom (grokB's send arriving as
|
|
772
|
+
// from=grokA). Only the legit rename path (rename.ts) may cross the
|
|
773
|
+
// token→alias binding. Symmetric to fromIdentityMismatchReply on the
|
|
774
|
+
// send side (test198).
|
|
775
|
+
if (callerTokenIsNetwork && callerAlias && !canonical.renamed && effectiveAlias !== callerAlias) {
|
|
776
|
+
return {
|
|
777
|
+
content: [{
|
|
778
|
+
type: "text" as const,
|
|
779
|
+
text: JSON.stringify({
|
|
780
|
+
ok: false,
|
|
781
|
+
error: "alias_identity_mismatch",
|
|
782
|
+
message: "report_status alias does not match the token-bound node alias; use anet node rename to change identity",
|
|
783
|
+
token_alias: callerAlias,
|
|
784
|
+
reported_alias: effectiveAlias,
|
|
785
|
+
}),
|
|
786
|
+
}],
|
|
787
|
+
};
|
|
788
|
+
}
|
|
341
789
|
console.log(`[${ts()}] ${effectiveAlias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
|
|
342
790
|
if (callerTokenIsNetwork && callerTokenId) {
|
|
343
791
|
try {
|
|
@@ -362,6 +810,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
362
810
|
const processCpuPct = typeof proc?.cpu_pct === "number" ? proc.cpu_pct : null;
|
|
363
811
|
const processUptimeSeconds = typeof proc?.uptime_seconds === "number" ? proc.uptime_seconds : null;
|
|
364
812
|
const processInFlightCount = typeof proc?.in_flight_count === "number" ? proc.in_flight_count : null;
|
|
813
|
+
const externalSchedulesJson = externalSchedules === undefined ? null : JSON.stringify(externalSchedules);
|
|
814
|
+
const trustedCurrentSnapshot = node_id
|
|
815
|
+
? trustedConfigSnapshotForNode(cfgSnap ?? null, callerTokenId ?? null, node_id)
|
|
816
|
+
: null;
|
|
817
|
+
const peerReplyInboxCapable = !!(
|
|
818
|
+
trustedCurrentSnapshot
|
|
819
|
+
&& typeof trustedCurrentSnapshot === "object"
|
|
820
|
+
&& (trustedCurrentSnapshot as Record<string, unknown>).peer_reply_inbox_capable === true
|
|
821
|
+
);
|
|
365
822
|
const statusHostTelemetry = host ? {
|
|
366
823
|
hostname: hostHostname,
|
|
367
824
|
ip: hostIp,
|
|
@@ -384,10 +841,30 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
384
841
|
|
|
385
842
|
db.transaction(() => {
|
|
386
843
|
// Only delete same-alias sessions within the same network
|
|
844
|
+
// 同一 alias 可以有两个上报者轮流说话(agent-node 自己 `sdk-<node>` +
|
|
845
|
+
// grok 共存里 TUI 的 MCP `grok-cli-<node>`)。下面这条 DELETE 会把「另一个
|
|
846
|
+
// resume_id」的那行整行删掉,再 INSERT 一行新的 —— ON CONFLICT 里那一串
|
|
847
|
+
// COALESCE 在这条路径上一次都不会触发,于是上报里没带的列(version / agent /
|
|
848
|
+
// hostname / 六个遥测字段 / registered_at …)全部变 NULL。2026-09-04 DEV 真机:
|
|
849
|
+
// grok-v1 working 时 version=.64,7 秒后 idle 就成了 NULL,registered_at 被改成
|
|
850
|
+
// 那一秒。先把要被删的那行读出来,INSERT 之后把仍为 NULL 的列从它接手 —— 让「换 resume_id」
|
|
851
|
+
// 和「同 resume_id 更新」对描述性列的语义一致:上报了就覆盖,没上报就保留。
|
|
852
|
+
// 状态类列(status / task / output / progress / score)不接手,那是本次上报的事实。
|
|
853
|
+
const handover = db.get<Record<string, unknown>>(
|
|
854
|
+
`SELECT tmux_name, server, ip, hostname, agent, project_dir, version, node_id, session_id, config_path,
|
|
855
|
+
channels, model, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb,
|
|
856
|
+
disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct,
|
|
857
|
+
process_uptime_seconds, process_in_flight_count, external_schedules, registered_at
|
|
858
|
+
FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3
|
|
859
|
+
ORDER BY updated_at DESC LIMIT 1`,
|
|
860
|
+
effectiveAlias, resume_id, sessionNetId,
|
|
861
|
+
) ?? null;
|
|
862
|
+
const keep = <T,>(fresh: T | null | undefined, column: string): T | null =>
|
|
863
|
+
(fresh ?? (handover?.[column] as T | null | undefined) ?? null);
|
|
387
864
|
db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [effectiveAlias, resume_id, sessionNetId]);
|
|
388
865
|
db.run(
|
|
389
|
-
`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)
|
|
390
|
-
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'))
|
|
866
|
+
`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)
|
|
867
|
+
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'))
|
|
391
868
|
ON CONFLICT(resume_id) DO UPDATE SET
|
|
392
869
|
alias = COALESCE(?2, sessions.alias), tmux_name = COALESCE(?3, sessions.tmux_name),
|
|
393
870
|
server = COALESCE(?4, sessions.server), ip = COALESCE(?5, sessions.ip),
|
|
@@ -412,9 +889,54 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
412
889
|
process_cpu_pct = COALESCE(?31, sessions.process_cpu_pct),
|
|
413
890
|
process_uptime_seconds = COALESCE(?32, sessions.process_uptime_seconds),
|
|
414
891
|
process_in_flight_count = COALESCE(?33, sessions.process_in_flight_count),
|
|
892
|
+
external_schedules = COALESCE(?34, sessions.external_schedules),
|
|
893
|
+
peer_reply_inbox_capable = ?35,
|
|
415
894
|
last_seen_at = datetime('now'), updated_at = datetime('now')`,
|
|
416
|
-
[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]
|
|
895
|
+
[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]
|
|
417
896
|
);
|
|
897
|
+
if (handover) {
|
|
898
|
+
// 换 resume_id 那条路径上 ON CONFLICT 不会触发;INSERT 完再把没上报(仍为 NULL)的
|
|
899
|
+
// 描述性列从被替换的那行接手。INSERT 语句与参数列表保持原样 —— test698 的变异
|
|
900
|
+
// 用 sed 钉着它的字节形状。
|
|
901
|
+
db.run(
|
|
902
|
+
`UPDATE sessions SET
|
|
903
|
+
tmux_name = COALESCE(tmux_name, ?2), server = COALESCE(server, ?3), ip = COALESCE(ip, ?4),
|
|
904
|
+
hostname = COALESCE(hostname, ?5), agent = COALESCE(agent, ?6), project_dir = COALESCE(project_dir, ?7),
|
|
905
|
+
version = COALESCE(version, ?8), node_id = COALESCE(node_id, ?9), session_id = COALESCE(session_id, ?10),
|
|
906
|
+
config_path = COALESCE(config_path, ?11), channels = COALESCE(channels, ?12), model = COALESCE(model, ?13),
|
|
907
|
+
cpu_load_1min = COALESCE(cpu_load_1min, ?14), cpu_cores = COALESCE(cpu_cores, ?15),
|
|
908
|
+
mem_total_gb = COALESCE(mem_total_gb, ?16), mem_used_gb = COALESCE(mem_used_gb, ?17),
|
|
909
|
+
mem_avail_gb = COALESCE(mem_avail_gb, ?18), disk_total_gb = COALESCE(disk_total_gb, ?19),
|
|
910
|
+
disk_used_gb = COALESCE(disk_used_gb, ?20), disk_avail_gb = COALESCE(disk_avail_gb, ?21),
|
|
911
|
+
process_rss_bytes = COALESCE(process_rss_bytes, ?22), process_rss_mb = COALESCE(process_rss_mb, ?23),
|
|
912
|
+
process_cpu_pct = COALESCE(process_cpu_pct, ?24), process_uptime_seconds = COALESCE(process_uptime_seconds, ?25),
|
|
913
|
+
process_in_flight_count = COALESCE(process_in_flight_count, ?26),
|
|
914
|
+
external_schedules = COALESCE(external_schedules, ?27),
|
|
915
|
+
registered_at = COALESCE(?28, registered_at)
|
|
916
|
+
WHERE resume_id = ?1`,
|
|
917
|
+
[resume_id, handover.tmux_name ?? null, handover.server ?? null, handover.ip ?? null, handover.hostname ?? null,
|
|
918
|
+
handover.agent ?? null, handover.project_dir ?? null, handover.version ?? null, handover.node_id ?? null,
|
|
919
|
+
handover.session_id ?? null, handover.config_path ?? null, handover.channels ?? null, handover.model ?? null,
|
|
920
|
+
handover.cpu_load_1min ?? null, handover.cpu_cores ?? null, handover.mem_total_gb ?? null, handover.mem_used_gb ?? null,
|
|
921
|
+
handover.mem_avail_gb ?? null, handover.disk_total_gb ?? null, handover.disk_used_gb ?? null, handover.disk_avail_gb ?? null,
|
|
922
|
+
handover.process_rss_bytes ?? null, handover.process_rss_mb ?? null, handover.process_cpu_pct ?? null,
|
|
923
|
+
handover.process_uptime_seconds ?? null, handover.process_in_flight_count ?? null, handover.external_schedules ?? null,
|
|
924
|
+
handover.registered_at ?? null],
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
// app#225 follow-up — separate statement on purpose: the INSERT above is
|
|
928
|
+
// byte-pinned by test698's mutation harness. Sticky: only ever set to 1,
|
|
929
|
+
// and only by a node token bound to this alias (a user token or another
|
|
930
|
+
// node cannot mark someone else's session as able to serve its files).
|
|
931
|
+
if (rulesFileCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
|
|
932
|
+
db.run("UPDATE sessions SET rules_file_capable = 1 WHERE resume_id = ?1", [resume_id]);
|
|
933
|
+
}
|
|
934
|
+
if (skillsCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
|
|
935
|
+
db.run("UPDATE sessions SET skills_capable = 1 WHERE resume_id = ?1", [resume_id]);
|
|
936
|
+
}
|
|
937
|
+
if (filesCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
|
|
938
|
+
db.run("UPDATE sessions SET files_capable = 1 WHERE resume_id = ?1", [resume_id]);
|
|
939
|
+
}
|
|
418
940
|
if (host || proc) {
|
|
419
941
|
db.run(
|
|
420
942
|
`INSERT INTO agent_telemetry (id, network_id, resume_id, alias, hostname, ip, 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, created_at)
|
|
@@ -463,6 +985,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
463
985
|
upsertNodeWithSec1Guard({
|
|
464
986
|
node_id,
|
|
465
987
|
callerNetworkId: effectiveNetId ?? null,
|
|
988
|
+
callerUserId: enforceUserId ?? null,
|
|
989
|
+
callerTokenId: callerTokenId ?? null,
|
|
466
990
|
node_name: nn || effectiveAlias,
|
|
467
991
|
alias: effectiveAlias,
|
|
468
992
|
runtime: nodeRuntime,
|
|
@@ -554,6 +1078,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
554
1078
|
} else {
|
|
555
1079
|
updatedTaskId = task;
|
|
556
1080
|
}
|
|
1081
|
+
if (updatedTaskId) syncScheduledRunForTask(updatedTaskId, effectiveNetId);
|
|
557
1082
|
});
|
|
558
1083
|
// Log event after transaction
|
|
559
1084
|
if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
|
|
@@ -614,7 +1139,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
614
1139
|
const rows0 = db.get<{ cnt: number }>(countSql, ...countParams);
|
|
615
1140
|
console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
|
|
616
1141
|
const rowsParams: any[] = [alias];
|
|
617
|
-
let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json
|
|
1142
|
+
let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json,
|
|
1143
|
+
CASE WHEN type = 'task' THEN COALESCE(task_id, id) ELSE task_id END AS task_id
|
|
618
1144
|
FROM inbox WHERE session_name = ?1 AND acked = 0`;
|
|
619
1145
|
rowsSql = addReadScope(rowsSql, rowsParams, readScope);
|
|
620
1146
|
rowsSql += ` ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
|
|
@@ -638,13 +1164,32 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
638
1164
|
alias: z.string().min(1).max(200).describe("Session alias"),
|
|
639
1165
|
message_id: z.string().min(1).max(200),
|
|
640
1166
|
response: z.string().max(10000).optional(),
|
|
1167
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
641
1168
|
},
|
|
642
|
-
async ({ alias, message_id, response }) => {
|
|
643
|
-
const effectiveNetId = getNetworkId(
|
|
1169
|
+
async ({ alias, message_id, response, network_id: netId }) => {
|
|
1170
|
+
const effectiveNetId = getNetworkId(netId);
|
|
644
1171
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
645
1172
|
console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
|
|
646
|
-
|
|
647
|
-
|
|
1173
|
+
// New task consumers ACK by the logical tasks.task_id exposed by
|
|
1174
|
+
// get_inbox. Keep exact inbox.id support for legacy consumers and for
|
|
1175
|
+
// non-task messages. Restrict the lookup to pending rows: after a retry
|
|
1176
|
+
// the original row can have id == task_id but is already ACKed, while
|
|
1177
|
+
// the current row has a fresh transport id and the same logical task_id.
|
|
1178
|
+
const inboxTaskParams: any[] = [message_id, alias];
|
|
1179
|
+
let inboxTaskSql = `SELECT id, type, COALESCE(task_id, id) AS task_id
|
|
1180
|
+
FROM inbox
|
|
1181
|
+
WHERE session_name = ?2 AND acked = 0
|
|
1182
|
+
AND (id = ?1 OR (type = 'task' AND task_id = ?1))`;
|
|
1183
|
+
inboxTaskSql = addScope(inboxTaskSql, inboxTaskParams, effectiveNetId);
|
|
1184
|
+
inboxTaskSql += " ORDER BY created_at DESC LIMIT 1";
|
|
1185
|
+
const inboxTask = db.get<{ id: string; type: string; task_id: string }>(inboxTaskSql, ...inboxTaskParams);
|
|
1186
|
+
if (!inboxTask) {
|
|
1187
|
+
return {
|
|
1188
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "message not found or already acknowledged" }) }],
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
const ackParams: any[] = [inboxTask.id, alias];
|
|
1192
|
+
let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2 AND acked = 0";
|
|
648
1193
|
ackSql = addScope(ackSql, ackParams, effectiveNetId);
|
|
649
1194
|
const result = db.run(ackSql, ackParams);
|
|
650
1195
|
if (result.changes === 0) {
|
|
@@ -654,11 +1199,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
654
1199
|
}
|
|
655
1200
|
// V2: sync tasks table — ack_inbox means delivered→acked
|
|
656
1201
|
try {
|
|
657
|
-
|
|
1202
|
+
if (inboxTask?.type !== "task") {
|
|
1203
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }] };
|
|
1204
|
+
}
|
|
1205
|
+
const taskParams: any[] = [inboxTask.task_id];
|
|
658
1206
|
let taskSql = "UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'";
|
|
659
1207
|
taskSql = addScope(taskSql, taskParams, effectiveNetId);
|
|
660
1208
|
const ackResult = db.run(taskSql, taskParams);
|
|
661
|
-
if (ackResult.changes > 0) logTaskEvent(
|
|
1209
|
+
if (ackResult.changes > 0) logTaskEvent(inboxTask.task_id, "delivered", "acked", alias);
|
|
662
1210
|
} catch {}
|
|
663
1211
|
return {
|
|
664
1212
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }],
|
|
@@ -666,22 +1214,208 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
666
1214
|
}
|
|
667
1215
|
);
|
|
668
1216
|
|
|
1217
|
+
// #520 — two monotonic runtime-evidence levels for exact tasks.
|
|
1218
|
+
//
|
|
1219
|
+
// runtime_submitted_at means agent-node handed the body to the vendor
|
|
1220
|
+
// runtime. consumed_at is stronger: an attributable turn-start or first
|
|
1221
|
+
// activity event came back. Merely fetching/acking an inbox row sets neither.
|
|
1222
|
+
// Node identity comes exclusively from the ntok; callers cannot self-report
|
|
1223
|
+
// an alias or node_id. A consumed mark also fills runtime_submitted_at because
|
|
1224
|
+
// that stronger fact logically implies submission.
|
|
1225
|
+
const markTaskRuntimeEvidence = (
|
|
1226
|
+
taskIds: string[],
|
|
1227
|
+
level: "submitted" | "consumed",
|
|
1228
|
+
taskContexts: Array<{ task_id: string; thread_id: string; turn_id: string }> = [],
|
|
1229
|
+
) => {
|
|
1230
|
+
const task_ids = taskIds;
|
|
1231
|
+
if (!callerTokenIsNetwork || !callerAlias || !enforceNetworkId) {
|
|
1232
|
+
return {
|
|
1233
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_required" }) }],
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
// PgAdapter.transaction currently opens a fresh subprocess/connection
|
|
1237
|
+
// per statement. The all-or-nothing batch promise cannot be made there;
|
|
1238
|
+
// refuse evidence rather than publish a partially stamped batch.
|
|
1239
|
+
if (db.dialect !== "sqlite") {
|
|
1240
|
+
return {
|
|
1241
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
1242
|
+
ok: false,
|
|
1243
|
+
error: "task_runtime_evidence_backend_unsupported",
|
|
1244
|
+
}) }],
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
const uniqueTaskIds = [...new Set(task_ids)];
|
|
1249
|
+
if (uniqueTaskIds.length !== task_ids.length) {
|
|
1250
|
+
return {
|
|
1251
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_id" }) }],
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
if (level !== "consumed" && taskContexts.length > 0) {
|
|
1256
|
+
return {
|
|
1257
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "runtime_context_requires_consumed" }) }],
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
const contextByTaskId = new Map(taskContexts.map((context) => [context.task_id, context]));
|
|
1261
|
+
if (contextByTaskId.size !== taskContexts.length) {
|
|
1262
|
+
return {
|
|
1263
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_context" }) }],
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
const foreignContext = taskContexts.find((context) => !uniqueTaskIds.includes(context.task_id));
|
|
1267
|
+
if (foreignContext) {
|
|
1268
|
+
return {
|
|
1269
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task_context_not_requested", task_id: foreignContext.task_id }) }],
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
const placeholders = uniqueTaskIds.map((_, i) => `?${i + 2}`).join(", ");
|
|
1274
|
+
|
|
1275
|
+
// Keep ownership preflight and every stamp in one SQLite transaction.
|
|
1276
|
+
// This closes the cross-process preflight→UPDATE race: no other writer
|
|
1277
|
+
// can reassign a task between the identity decision and the write.
|
|
1278
|
+
const outcome = db.transaction(() => {
|
|
1279
|
+
const canonicalCaller = resolveCanonicalAlias(enforceNetworkId, callerAlias).alias;
|
|
1280
|
+
const callerSession = db.get<{ node_id: string | null }>(
|
|
1281
|
+
`SELECT node_id FROM sessions
|
|
1282
|
+
WHERE network_id = ?1 AND alias = ?2
|
|
1283
|
+
ORDER BY updated_at DESC LIMIT 1`,
|
|
1284
|
+
enforceNetworkId,
|
|
1285
|
+
canonicalCaller,
|
|
1286
|
+
);
|
|
1287
|
+
const rows = db.all<{ task_id: string; to_node_id: string | null; to_name: string; thread_id: string | null; turn_id: string | null }>(
|
|
1288
|
+
`SELECT task_id, to_node_id, to_name, thread_id, turn_id FROM tasks
|
|
1289
|
+
WHERE network_id = ?1 AND task_id IN (${placeholders})`,
|
|
1290
|
+
enforceNetworkId,
|
|
1291
|
+
...uniqueTaskIds,
|
|
1292
|
+
);
|
|
1293
|
+
const owned = new Map(rows.map((row) => [row.task_id, row]));
|
|
1294
|
+
const rejectedTaskId = uniqueTaskIds.find((taskId) => {
|
|
1295
|
+
const row = owned.get(taskId);
|
|
1296
|
+
if (!row) return true;
|
|
1297
|
+
// Prefer immutable node identity whenever the task has one. Direct
|
|
1298
|
+
// and legacy token-bound sessions legitimately have NULL node_id;
|
|
1299
|
+
// only that shape may fall back to the canonical token alias.
|
|
1300
|
+
if (row.to_node_id) return row.to_node_id !== callerSession?.node_id;
|
|
1301
|
+
return resolveCanonicalAlias(enforceNetworkId, row.to_name).alias !== canonicalCaller;
|
|
1302
|
+
});
|
|
1303
|
+
if (rejectedTaskId) {
|
|
1304
|
+
return { ok: false as const, error: "task_not_owned", task_id: rejectedTaskId };
|
|
1305
|
+
}
|
|
1306
|
+
const conflictingContext = uniqueTaskIds.find((taskId) => {
|
|
1307
|
+
const context = contextByTaskId.get(taskId);
|
|
1308
|
+
if (!context) return false;
|
|
1309
|
+
const row = owned.get(taskId)!;
|
|
1310
|
+
return (row.thread_id !== null && row.thread_id !== context.thread_id)
|
|
1311
|
+
|| (row.turn_id !== null && row.turn_id !== context.turn_id);
|
|
1312
|
+
});
|
|
1313
|
+
if (conflictingContext) {
|
|
1314
|
+
return { ok: false as const, error: "task_runtime_context_conflict", task_id: conflictingContext };
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
for (const taskId of uniqueTaskIds) {
|
|
1318
|
+
const row = owned.get(taskId)!;
|
|
1319
|
+
const ownershipSql = row.to_node_id
|
|
1320
|
+
? "to_node_id = ?3"
|
|
1321
|
+
: "to_node_id IS NULL AND to_name = ?3";
|
|
1322
|
+
const ownerValue = row.to_node_id ?? row.to_name;
|
|
1323
|
+
let updateResult;
|
|
1324
|
+
if (level === "consumed") {
|
|
1325
|
+
const context = contextByTaskId.get(taskId);
|
|
1326
|
+
updateResult = db.run(
|
|
1327
|
+
`UPDATE tasks SET
|
|
1328
|
+
runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now')),
|
|
1329
|
+
consumed_at = COALESCE(consumed_at, datetime('now')),
|
|
1330
|
+
thread_id = COALESCE(thread_id, ?4),
|
|
1331
|
+
turn_id = COALESCE(turn_id, ?5)
|
|
1332
|
+
WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
|
|
1333
|
+
[enforceNetworkId, taskId, ownerValue, context?.thread_id ?? null, context?.turn_id ?? null],
|
|
1334
|
+
);
|
|
1335
|
+
} else {
|
|
1336
|
+
updateResult = db.run(
|
|
1337
|
+
`UPDATE tasks SET runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now'))
|
|
1338
|
+
WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
|
|
1339
|
+
[enforceNetworkId, taskId, ownerValue],
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
if (updateResult.changes !== 1) {
|
|
1343
|
+
// A zero-row write after a successful preflight is an invariant
|
|
1344
|
+
// failure, never a successful evidence report.
|
|
1345
|
+
throw new Error(`task_runtime_evidence_write_race:${taskId}`);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
const evidenceRows = db.all<{
|
|
1349
|
+
task_id: string;
|
|
1350
|
+
runtime_submitted_at: string;
|
|
1351
|
+
consumed_at: string | null;
|
|
1352
|
+
thread_id: string | null;
|
|
1353
|
+
turn_id: string | null;
|
|
1354
|
+
}>(
|
|
1355
|
+
`SELECT task_id, runtime_submitted_at, consumed_at, thread_id, turn_id FROM tasks
|
|
1356
|
+
WHERE network_id = ?1 AND task_id IN (${placeholders})`,
|
|
1357
|
+
enforceNetworkId,
|
|
1358
|
+
...uniqueTaskIds,
|
|
1359
|
+
);
|
|
1360
|
+
return { ok: true as const, tasks: evidenceRows };
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
return {
|
|
1364
|
+
content: [{
|
|
1365
|
+
type: "text" as const,
|
|
1366
|
+
text: JSON.stringify(outcome),
|
|
1367
|
+
}],
|
|
1368
|
+
};
|
|
1369
|
+
};
|
|
1370
|
+
|
|
1371
|
+
const taskRuntimeEvidenceSchema = {
|
|
1372
|
+
task_ids: z.array(z.string().min(1).max(200)).min(1).max(100),
|
|
1373
|
+
task_contexts: z.array(z.object({
|
|
1374
|
+
task_id: z.string().min(1).max(200),
|
|
1375
|
+
thread_id: z.string().min(1).max(500),
|
|
1376
|
+
turn_id: z.string().min(1).max(500),
|
|
1377
|
+
}).strict()).max(100).optional(),
|
|
1378
|
+
};
|
|
1379
|
+
|
|
1380
|
+
server.tool(
|
|
1381
|
+
"mark_tasks_runtime_submitted",
|
|
1382
|
+
"Internal agent-node signal: exact task bodies were submitted to this token-bound node's vendor runtime.",
|
|
1383
|
+
taskRuntimeEvidenceSchema,
|
|
1384
|
+
async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "submitted", task_contexts),
|
|
1385
|
+
);
|
|
1386
|
+
|
|
1387
|
+
server.tool(
|
|
1388
|
+
"mark_tasks_consumed",
|
|
1389
|
+
"Internal agent-node signal: exact tasks produced attributable turn-start/activity evidence in this token-bound node's runtime.",
|
|
1390
|
+
taskRuntimeEvidenceSchema,
|
|
1391
|
+
async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "consumed", task_contexts),
|
|
1392
|
+
);
|
|
1393
|
+
|
|
669
1394
|
// ═══════════════════════════════════════════
|
|
670
1395
|
// Hub Tools (5)
|
|
671
1396
|
// ═══════════════════════════════════════════
|
|
672
1397
|
|
|
673
1398
|
server.tool(
|
|
674
1399
|
"get_all_status",
|
|
675
|
-
"Get status of all sessions. Hub uses this for the patrol loop."
|
|
1400
|
+
"Get status of all sessions. Hub uses this for the patrol loop. " +
|
|
1401
|
+
"Pass filter_alias (comma-separated) when you only care about specific " +
|
|
1402
|
+
"nodes — the unfiltered result is one row per session with 31 columns and " +
|
|
1403
|
+
"is large enough on a real fleet that callers cannot read it.",
|
|
676
1404
|
{
|
|
677
1405
|
filter_status: z.string().max(50).optional(),
|
|
678
1406
|
filter_server: z.string().max(200).optional(),
|
|
1407
|
+
// 2026-08-17: on a 222-session hub the unfiltered response is ~259 KB, past
|
|
1408
|
+
// what an MCP client can take in one result — so the caller that wanted the
|
|
1409
|
+
// status of THREE nodes could not get it from this tool at all. The patrol
|
|
1410
|
+
// loop still wants everything, hence optional rather than required.
|
|
1411
|
+
filter_alias: z.string().max(2000).optional()
|
|
1412
|
+
.describe("One alias, or several separated by commas. Exact matches only."),
|
|
679
1413
|
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
680
1414
|
},
|
|
681
|
-
async ({ filter_status, filter_server, network_id: netId }) => {
|
|
1415
|
+
async ({ filter_status, filter_server, filter_alias, network_id: netId }) => {
|
|
682
1416
|
const readScope = resolveReadScope(netId);
|
|
683
1417
|
if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
684
|
-
console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${readScope.networkId ? " net=" + readScope.networkId.slice(0, 12) : ""}`);
|
|
1418
|
+
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) : ""}`);
|
|
685
1419
|
|
|
686
1420
|
// Round-2/4 review ③: stale-marking moved to startStaleSessionSweeper()
|
|
687
1421
|
// (background timer, ~60s cadence). Read path no longer fires UPDATE.
|
|
@@ -690,20 +1424,42 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
690
1424
|
sql = addReadScope(sql, params, readScope);
|
|
691
1425
|
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
692
1426
|
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
1427
|
+
const aliasFilter = parseAliasFilter(filter_alias);
|
|
1428
|
+
const aliases = aliasFilter.aliases;
|
|
1429
|
+
if (aliasFilter.sql) {
|
|
1430
|
+
sql += aliasFilter.sql;
|
|
1431
|
+
params.push(...aliases);
|
|
1432
|
+
}
|
|
693
1433
|
sql += " ORDER BY updated_at DESC";
|
|
694
1434
|
const sessions = db.all(sql, ...params);
|
|
695
1435
|
|
|
1436
|
+
// `summary` has always counted every session in the read scope, ignoring
|
|
1437
|
+
// filter_status / filter_server — and now filter_alias. That is fine for
|
|
1438
|
+
// the patrol loop, but a caller who asked about three aliases and gets
|
|
1439
|
+
// back three rows plus "idle: 96" can easily read the 96 as being about
|
|
1440
|
+
// their three. A count that does not say what it counted invites exactly
|
|
1441
|
+
// that. So the response now says so, rather than the semantics changing
|
|
1442
|
+
// under existing callers.
|
|
696
1443
|
const summaryParams: any[] = [];
|
|
697
1444
|
let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
|
|
698
1445
|
summarySql = addReadScope(summarySql, summaryParams, readScope);
|
|
699
1446
|
summarySql += " GROUP BY status";
|
|
700
1447
|
const summary = db.all(summarySql, ...summaryParams);
|
|
701
1448
|
|
|
1449
|
+
const filtered = !!(filter_status || filter_server || aliases.length > 0);
|
|
702
1450
|
return {
|
|
703
1451
|
content: [
|
|
704
1452
|
{
|
|
705
1453
|
type: "text" as const,
|
|
706
|
-
text: JSON.stringify({
|
|
1454
|
+
text: JSON.stringify({
|
|
1455
|
+
ok: true,
|
|
1456
|
+
sessions,
|
|
1457
|
+
summary,
|
|
1458
|
+
summary_scope: filtered
|
|
1459
|
+
? "every session in the read scope — NOT narrowed by the filters applied to `sessions`"
|
|
1460
|
+
: "every session in the read scope",
|
|
1461
|
+
sessions_returned: sessions.length,
|
|
1462
|
+
}),
|
|
707
1463
|
},
|
|
708
1464
|
],
|
|
709
1465
|
};
|
|
@@ -761,7 +1517,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
761
1517
|
},
|
|
762
1518
|
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);
|
|
763
1519
|
const effectiveNetId = getNetworkId(netId);
|
|
764
|
-
const
|
|
1520
|
+
const authOrigin: TaskAuthOrigin = callerTokenIsNetwork
|
|
1521
|
+
? "node"
|
|
1522
|
+
: enforceUserId
|
|
1523
|
+
? "user"
|
|
1524
|
+
: "legacy";
|
|
1525
|
+
const metaJson = normalizeMetaJson(stampTaskAuthOrigin(meta, authOrigin));
|
|
765
1526
|
|
|
766
1527
|
// Role check FIRST — round5 follow-up (通信牛 oracle catch):
|
|
767
1528
|
// the explicit-parent verification below distinguishes
|
|
@@ -836,6 +1597,45 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
836
1597
|
const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
|
|
837
1598
|
if (target.state === "not_found") return deliveryTargetReply(target)!;
|
|
838
1599
|
|
|
1600
|
+
// Dashboard sends carry a random client_request_id inside meta. Derive
|
|
1601
|
+
// the task primary key from authenticated scope + that request id so a
|
|
1602
|
+
// lost HTTP response can be retried safely, even after a hub restart.
|
|
1603
|
+
// The existing row must match the full request; key reuse with changed
|
|
1604
|
+
// content/target fails closed instead of silently returning another task.
|
|
1605
|
+
const clientRequestId = clientRequestIdFromMeta(meta);
|
|
1606
|
+
const id = clientRequestId
|
|
1607
|
+
? idempotentTaskId(effectiveNetId ?? null, from_session, clientRequestId)
|
|
1608
|
+
: uuidv4();
|
|
1609
|
+
if (clientRequestId) {
|
|
1610
|
+
const existing = db.get<StoredIdempotentTask & { to_node_id: string | null }>(
|
|
1611
|
+
"SELECT task_id, from_name, to_node_id, to_name, priority, content, network_id, meta_json, status FROM tasks WHERE task_id = ?1",
|
|
1612
|
+
[id],
|
|
1613
|
+
);
|
|
1614
|
+
if (existing) {
|
|
1615
|
+
if (!idempotentTaskMatches(existing, {
|
|
1616
|
+
fromName: from_session, toName: targetAlias, priority, content: task,
|
|
1617
|
+
networkId: effectiveNetId ?? null, metaJson,
|
|
1618
|
+
})) {
|
|
1619
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
1620
|
+
ok: false, error: "idempotency_conflict",
|
|
1621
|
+
message: "client_request_id was already used with a different task payload",
|
|
1622
|
+
}) }] };
|
|
1623
|
+
}
|
|
1624
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
1625
|
+
ok: true, message_id: existing.task_id, task_id: existing.task_id,
|
|
1626
|
+
task_status: existing.status, idempotent_replay: true,
|
|
1627
|
+
// A replay acknowledges the already-persisted dispatch. Use that
|
|
1628
|
+
// row rather than today's session record so alias reuse or a
|
|
1629
|
+
// restarted process cannot rewrite historical recipient identity.
|
|
1630
|
+
actual_to: {
|
|
1631
|
+
alias: existing.to_name,
|
|
1632
|
+
to_node_id: existing.to_node_id ?? null,
|
|
1633
|
+
network_id: existing.network_id ?? null,
|
|
1634
|
+
},
|
|
1635
|
+
}) }] };
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
839
1639
|
// #212 dedup guardrail. If this exact (from, to, content) has already
|
|
840
1640
|
// been delivered within COMMHUB_SEND_DEDUP_WINDOW_MS (default 5 min)
|
|
841
1641
|
// we refuse the call and surface a structured `duplicate_send`
|
|
@@ -856,18 +1656,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
856
1656
|
return { content: [{ type: "text" as const, text: JSON.stringify(payload) }] };
|
|
857
1657
|
}
|
|
858
1658
|
|
|
1659
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 1/6).
|
|
1660
|
+
{
|
|
1661
|
+
const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
|
|
1662
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
1663
|
+
}
|
|
1664
|
+
|
|
859
1665
|
console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
|
|
860
|
-
const id = uuidv4();
|
|
861
1666
|
const fromNodeId = resolveNodeIdForAlias(from_session, effectiveNetId);
|
|
862
1667
|
const targetNodeId = target.session?.node_id ?? null;
|
|
1668
|
+
const resolvedActualTo = actualTo(target, effectiveNetId);
|
|
863
1669
|
// 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
|
|
864
1670
|
// dashboard 在派任务一刻就反映出"任务已下发",不再等 agent 的
|
|
865
1671
|
// report_status 心跳;status 字段交给 agent,避免与 working/idle
|
|
866
1672
|
// 报告冲突)。
|
|
867
1673
|
db.transaction(() => {
|
|
868
1674
|
db.run(
|
|
869
|
-
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, context, from_session, requires_response, network_id, meta_json)
|
|
870
|
-
VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, ?7, 'reply', ?8, ?9)`,
|
|
1675
|
+
`INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, context, from_session, requires_response, network_id, meta_json)
|
|
1676
|
+
VALUES (?1, ?1, ?2, ?3, 'task', ?4, ?5, ?6, ?7, 'reply', ?8, ?9)`,
|
|
871
1677
|
[id, targetAlias, targetNodeId, priority, task, context ?? null, from_session, effectiveNetId ?? null, metaJson]
|
|
872
1678
|
);
|
|
873
1679
|
db.run(
|
|
@@ -879,6 +1685,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
879
1685
|
let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
|
|
880
1686
|
touchSql = addScope(touchSql, touchParams, effectiveNetId);
|
|
881
1687
|
db.run(touchSql, touchParams);
|
|
1688
|
+
// #1548 —— 能派活的发送方不可能真的 blocked;此处出 blocked → idle。
|
|
1689
|
+
releaseBlockedSession(from_session, effectiveNetId);
|
|
882
1690
|
});
|
|
883
1691
|
logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${targetAlias} (parent=${parentTaskId.slice(0,8)})` : `→ ${targetAlias}`);
|
|
884
1692
|
// Only stamp the dedup index after the inbox/tasks transaction
|
|
@@ -897,9 +1705,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
897
1705
|
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
898
1706
|
pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
|
|
899
1707
|
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1708
|
+
// 🔴 不要用 last_seen_at 的时间戳去猜「有没有人在听」——SSE 订阅者注册表
|
|
1709
|
+
// 就是那件事本身。pushEvent 在没有订阅者时已经是 no-op,所以这道闸不提供
|
|
1710
|
+
// 任何保护,只会把**连着但最近没心跳**的会话排除掉;而全网「停发心跳/状态类
|
|
1711
|
+
// 消息」的省额度规则让安静恰恰是常态。上面那段注释早就写着 "Push
|
|
1712
|
+
// unconditionally",同一类缺陷上次也是打在 Dashboard 任务上(f015d9d6:
|
|
1713
|
+
// 任务标了 delivered,会话却从未收到推送)。
|
|
1714
|
+
pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
|
|
1715
|
+
const actuallyDelivered = hasSubscribers(targetAlias, effectiveNetId);
|
|
1716
|
+
// #461 network observer summary — unconditional (task row exists
|
|
1717
|
+
// even when the target is offline/queued), metadata only.
|
|
1718
|
+
pushNetworkObserverEvent(effectiveNetId, { type: "new_task", task_id: id, from: from_session, to: targetAlias, status: actuallyDelivered ? "delivered" : "queued", priority });
|
|
903
1719
|
|
|
904
1720
|
if (target.state === "offline") {
|
|
905
1721
|
return {
|
|
@@ -914,6 +1730,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
914
1730
|
task_id: id,
|
|
915
1731
|
message_id: id,
|
|
916
1732
|
session_status: target.session.status ?? "offline",
|
|
1733
|
+
actual_to: resolvedActualTo,
|
|
917
1734
|
...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
|
|
918
1735
|
}),
|
|
919
1736
|
}],
|
|
@@ -927,6 +1744,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
927
1744
|
text: JSON.stringify({
|
|
928
1745
|
ok: true,
|
|
929
1746
|
message_id: id,
|
|
1747
|
+
actual_to: resolvedActualTo,
|
|
930
1748
|
...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
|
|
931
1749
|
session_status: target.session?.status ?? "unknown",
|
|
932
1750
|
}),
|
|
@@ -943,14 +1761,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
943
1761
|
alias: z.string().min(1).max(200).describe("Target session alias"),
|
|
944
1762
|
message: z.string().min(1).max(10000).describe("Message content"),
|
|
945
1763
|
from_session: z.string().max(200).optional(),
|
|
1764
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
946
1765
|
},
|
|
947
|
-
async ({ alias, message, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
948
|
-
const effectiveNetId = getNetworkId(
|
|
1766
|
+
async ({ alias, message, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1767
|
+
const effectiveNetId = getNetworkId(netId);
|
|
949
1768
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
950
1769
|
const canonical = resolveCanonicalAlias(effectiveNetId, alias);
|
|
951
1770
|
const targetAlias = canonical.alias;
|
|
952
1771
|
const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
|
|
953
1772
|
if (target.state === "not_found") return deliveryTargetReply(target)!;
|
|
1773
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 2/6).
|
|
1774
|
+
{
|
|
1775
|
+
const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
|
|
1776
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
1777
|
+
}
|
|
954
1778
|
console.log(`[${ts()}] ${from_session} → send_message → ${targetAlias}: ${message.slice(0, 60)}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
|
|
955
1779
|
const id = uuidv4();
|
|
956
1780
|
db.run(
|
|
@@ -959,9 +1783,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
959
1783
|
[id, targetAlias, target.session?.node_id ?? null, message, from_session, effectiveNetId ?? null]
|
|
960
1784
|
);
|
|
961
1785
|
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
}
|
|
1786
|
+
// 同 send_task:可达性用订阅者注册表判,不用心跳时间戳猜。
|
|
1787
|
+
pushEvent(targetAlias, { type: "new_message", inbox_count: pendingInboxCount(targetAlias, effectiveNetId), from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
|
|
965
1788
|
|
|
966
1789
|
const offlineReply = deliveryTargetReply(target, { message_id: id });
|
|
967
1790
|
if (offlineReply) {
|
|
@@ -989,96 +1812,270 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
989
1812
|
}
|
|
990
1813
|
);
|
|
991
1814
|
|
|
992
|
-
// ── V2
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1815
|
+
// ── V2/V3 reply primitives ──
|
|
1816
|
+
const replyToolSchema = {
|
|
1817
|
+
// #1085 — alias is OPTIONAL: when omitted (or empty) and `in_reply_to`
|
|
1818
|
+
// is set, the hub derives the target from the original task's
|
|
1819
|
+
// `from_name` (it already SELECTs that task by in_reply_to below).
|
|
1820
|
+
// The SDK-runtime bridge (agent-node/src/commhub-mcp.ts) exposes
|
|
1821
|
+
// send_reply as (task_id, text, status) with NO alias, so before this
|
|
1822
|
+
// it always sent alias=undefined → -32602 "expected string, received
|
|
1823
|
+
// undefined at alias". The target was already knowable from the task,
|
|
1824
|
+
// so requiring the caller to pass it was a redundant-param trap.
|
|
1825
|
+
alias: z.string().min(1).max(200).optional().describe("Target session alias — optional; derived from in_reply_to's task sender when omitted"),
|
|
998
1826
|
text: z.string().min(1).max(10000).describe("Reply content"),
|
|
999
1827
|
in_reply_to: z.string().max(200).optional().describe("Original task/message ID"),
|
|
1000
1828
|
status: z.enum(["replied", "failed", "cancelled"]).optional().default("replied").describe("Task outcome"),
|
|
1001
1829
|
from_session: z.string().max(200).optional(),
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1830
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1831
|
+
// #507 — top-level attachments (parity with REST /api/task L506). MCP Zod
|
|
1832
|
+
// otherwise silently strips unknown fields, so a caller passing
|
|
1833
|
+
// `attachments` before this schema entry existed would see `ok:true`
|
|
1834
|
+
// and never learn the attachments were dropped. Validated by
|
|
1835
|
+
// validateAttachments (uploads.ts) — the same helper the REST path uses.
|
|
1836
|
+
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."),
|
|
1837
|
+
// #507 — optional structured metadata (parity with send_task L837). If
|
|
1838
|
+
// both `attachments` (top-level) and `meta.attachments` are supplied,
|
|
1839
|
+
// top-level wins (same rule as REST /api/task L2101).
|
|
1840
|
+
meta: z.any().optional().describe("Optional structured reply metadata, e.g. { attachments: [...] }."),
|
|
1841
|
+
};
|
|
1842
|
+
const handleReply = async (args: any, peerCapabilityRequired: boolean) => {
|
|
1843
|
+
const { alias, text, in_reply_to, status: replyStatus = "replied", from_session: _fromIn, network_id: netId, attachments, meta } = args;
|
|
1844
|
+
const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1845
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1005
1846
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1847
|
+
if (peerCapabilityRequired && !in_reply_to) {
|
|
1848
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "peer_reply_task_required" }) }] };
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// #507 — validate attachments BEFORE any DB write. Rejects malformed
|
|
1852
|
+
// input (bad file_id, >20 items, size > cap, non-object item, wrong
|
|
1853
|
+
// type field) with an explicit error the caller can act on. Empty /
|
|
1854
|
+
// absent attachments return { ok: true, attachments: [] } — the
|
|
1855
|
+
// reverse-(e) invariant (no attachments → behavior unchanged) is
|
|
1856
|
+
// pinned by tests that assert byte-identical response shape
|
|
1857
|
+
// before/after this validation runs.
|
|
1858
|
+
const attachmentsResult = validateAttachments(attachments ?? (meta && typeof meta === "object" ? (meta as any).attachments : undefined));
|
|
1859
|
+
if (!attachmentsResult.ok) {
|
|
1860
|
+
return {
|
|
1861
|
+
content: [{
|
|
1862
|
+
type: "text" as const,
|
|
1863
|
+
text: JSON.stringify({
|
|
1864
|
+
ok: false,
|
|
1865
|
+
error: "bad_attachments",
|
|
1866
|
+
message: attachmentsResult.error,
|
|
1867
|
+
}),
|
|
1868
|
+
}],
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
const mergedMeta = attachmentsResult.attachments.length
|
|
1872
|
+
? { ...(meta && typeof meta === "object" ? meta : {}), attachments: attachmentsResult.attachments }
|
|
1873
|
+
: meta;
|
|
1874
|
+
const metaJson = normalizeMetaJson(mergedMeta);
|
|
1875
|
+
|
|
1876
|
+
// #1085 — derive the reply target when the caller omitted `alias`.
|
|
1877
|
+
// The original task's `from_name` IS the reply target; the hub knows
|
|
1878
|
+
// it from `in_reply_to`. Without this, an SDK-runtime send_reply
|
|
1879
|
+
// (schema has no alias) died with -32602 before reaching this handler.
|
|
1880
|
+
let effectiveAlias: string | undefined =
|
|
1881
|
+
(typeof alias === "string" && alias.trim()) ? alias : undefined;
|
|
1882
|
+
if (!effectiveAlias) {
|
|
1883
|
+
if (!in_reply_to) {
|
|
1884
|
+
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" }) }] };
|
|
1027
1885
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
error: "reply_task_terminal",
|
|
1035
|
-
message: `cannot apply reply: task is already terminal (${taskBefore.status})`,
|
|
1036
|
-
in_reply_to,
|
|
1037
|
-
task_status: taskBefore.status,
|
|
1038
|
-
reply_queued: false,
|
|
1039
|
-
}),
|
|
1040
|
-
}],
|
|
1041
|
-
};
|
|
1886
|
+
const derivParams: any[] = [in_reply_to];
|
|
1887
|
+
let derivSql = "SELECT from_name FROM tasks WHERE task_id = ?1";
|
|
1888
|
+
derivSql = addScope(derivSql, derivParams, effectiveNetId);
|
|
1889
|
+
const derived = db.get<{ from_name: string }>(derivSql, ...derivParams);
|
|
1890
|
+
if (!derived?.from_name) {
|
|
1891
|
+
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})` }) }] };
|
|
1042
1892
|
}
|
|
1893
|
+
effectiveAlias = derived.from_name;
|
|
1043
1894
|
}
|
|
1044
|
-
|
|
1045
|
-
const
|
|
1895
|
+
|
|
1896
|
+
const canonicalReplyTarget = resolveCanonicalAlias(effectiveNetId, effectiveAlias);
|
|
1897
|
+
const replyTargetAlias = canonicalReplyTarget.alias;
|
|
1898
|
+
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}]` : ""}`);
|
|
1899
|
+
const id = uuidv4();
|
|
1900
|
+
const replyTargetNodeId = resolveNodeIdForAlias(replyTargetAlias, effectiveNetId);
|
|
1901
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 3/6).
|
|
1902
|
+
{
|
|
1903
|
+
const lc = assertNodeActive(replyTargetAlias, effectiveNetId ?? null);
|
|
1904
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
1905
|
+
}
|
|
1906
|
+
const replyOutcome = db.transaction(() => {
|
|
1907
|
+
type ReplyTask = {
|
|
1908
|
+
status: string;
|
|
1909
|
+
from_node_id: string | null;
|
|
1910
|
+
from_name: string;
|
|
1911
|
+
to_name: string;
|
|
1912
|
+
to_node_id: string | null;
|
|
1913
|
+
};
|
|
1914
|
+
let taskBefore: ReplyTask | null = null;
|
|
1915
|
+
let callerNodeId: string | null = null;
|
|
1916
|
+
if (in_reply_to) {
|
|
1917
|
+
const taskParams: any[] = [in_reply_to];
|
|
1918
|
+
let taskSql = "SELECT status, from_node_id, from_name, to_name, to_node_id FROM tasks WHERE task_id = ?1";
|
|
1919
|
+
taskSql = addScope(taskSql, taskParams, effectiveNetId);
|
|
1920
|
+
taskBefore = db.get<ReplyTask>(taskSql, ...taskParams) ?? null;
|
|
1921
|
+
if (!taskBefore) return { ok: false as const, error: "reply_task_not_found" as const };
|
|
1922
|
+
if (!["created", "delivered", "acked", "running"].includes(taskBefore.status)) {
|
|
1923
|
+
return { ok: false as const, error: "reply_task_terminal" as const, taskStatus: taskBefore.status };
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// V3 atomic peer replies are capability-negotiated. Both identity
|
|
1927
|
+
// bindings and the recipient capability are re-read inside this
|
|
1928
|
+
// transaction before any inbox/task/run write. Legacy/unbound rows
|
|
1929
|
+
// fail toward compatibility send_reply, which terminalizes the
|
|
1930
|
+
// original task instead of creating a second response-requiring row.
|
|
1931
|
+
if (peerCapabilityRequired) {
|
|
1932
|
+
if (!taskBefore.from_node_id) {
|
|
1933
|
+
// Origin type is an intrinsic property of the task and must be
|
|
1934
|
+
// decided before caller capability. A Dashboard/human task still
|
|
1935
|
+
// needs the established send_reply terminalization path when the
|
|
1936
|
+
// replying node uses a legacy/unbound token.
|
|
1937
|
+
return { ok: false as const, error: "peer_reply_origin_not_node" as const };
|
|
1938
|
+
}
|
|
1939
|
+
if (!callerTokenIsNetwork || !callerTokenId || !enforceNetworkId) {
|
|
1940
|
+
return { ok: false as const, error: "peer_reply_node_token_required" as const };
|
|
1941
|
+
}
|
|
1942
|
+
const token = db.get<{ bound_node_id: string | null }>(
|
|
1943
|
+
"SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
|
|
1944
|
+
callerTokenId,
|
|
1945
|
+
enforceNetworkId,
|
|
1946
|
+
);
|
|
1947
|
+
if (!token?.bound_node_id || !taskBefore.to_node_id) {
|
|
1948
|
+
return { ok: false as const, error: "peer_reply_unsupported" as const };
|
|
1949
|
+
}
|
|
1950
|
+
if (token.bound_node_id !== taskBefore.to_node_id) {
|
|
1951
|
+
return { ok: false as const, error: "reply_task_not_owned" as const };
|
|
1952
|
+
}
|
|
1953
|
+
callerNodeId = token.bound_node_id;
|
|
1954
|
+
const canonicalTarget = replyTargetAlias;
|
|
1955
|
+
const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, taskBefore.from_name).alias;
|
|
1956
|
+
if (canonicalTarget !== canonicalOrigin) {
|
|
1957
|
+
return { ok: false as const, error: "reply_target_mismatch" as const };
|
|
1958
|
+
}
|
|
1959
|
+
const recipient = db.get<{ peer_reply_inbox_capable: number }>(
|
|
1960
|
+
`SELECT peer_reply_inbox_capable FROM sessions
|
|
1961
|
+
WHERE node_id = ?1 AND network_id = ?2 AND alias = ?3
|
|
1962
|
+
ORDER BY updated_at DESC LIMIT 1`,
|
|
1963
|
+
taskBefore.from_node_id,
|
|
1964
|
+
enforceNetworkId,
|
|
1965
|
+
canonicalOrigin,
|
|
1966
|
+
);
|
|
1967
|
+
if (recipient?.peer_reply_inbox_capable !== 1) {
|
|
1968
|
+
return { ok: false as const, error: "peer_reply_unsupported" as const };
|
|
1969
|
+
}
|
|
1970
|
+
} else if (callerTokenIsNetwork && enforceNetworkId) {
|
|
1971
|
+
// Compatibility send_reply is still an exact task transition for
|
|
1972
|
+
// node callers. Bind it to the token-authenticated assignee alias
|
|
1973
|
+
// and immutable original sender. Alias binding survives a
|
|
1974
|
+
// legitimate node-id rotation without letting another node close
|
|
1975
|
+
// the task.
|
|
1976
|
+
const canonicalCaller = resolveCanonicalAlias(enforceNetworkId, from_session).alias;
|
|
1977
|
+
const canonicalAssignee = resolveCanonicalAlias(enforceNetworkId, taskBefore.to_name).alias;
|
|
1978
|
+
if (canonicalCaller !== canonicalAssignee) {
|
|
1979
|
+
return { ok: false as const, error: "reply_task_not_owned" as const };
|
|
1980
|
+
}
|
|
1981
|
+
const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, taskBefore.from_name).alias;
|
|
1982
|
+
if (replyTargetAlias !== canonicalOrigin) {
|
|
1983
|
+
return { ok: false as const, error: "reply_target_mismatch" as const };
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// #507 — write meta_json on inbox insert (parity with send_task L952).
|
|
1990
|
+
// Prior to this the attachments field on a send_reply call was
|
|
1991
|
+
// silently stripped by MCP Zod, leaving `ok:true` with no persisted
|
|
1992
|
+
// meta.
|
|
1046
1993
|
db.run(
|
|
1047
|
-
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
|
|
1048
|
-
VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7)`,
|
|
1049
|
-
[id,
|
|
1994
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id, meta_json)
|
|
1995
|
+
VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7, ?8)`,
|
|
1996
|
+
[id, replyTargetAlias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null, metaJson]
|
|
1050
1997
|
);
|
|
1051
1998
|
|
|
1052
1999
|
// 更新 tasks 表
|
|
1053
2000
|
if (in_reply_to) {
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
2001
|
+
// #507 — persist meta_json onto the tasks row too, so
|
|
2002
|
+
// dashboard's task view sees the reply's attachments alongside
|
|
2003
|
+
// the reply text (parity with send_task L957/959 which writes
|
|
2004
|
+
// meta_json into both inbox and tasks). The COALESCE guards
|
|
2005
|
+
// against clobbering pre-existing meta_json when this reply
|
|
2006
|
+
// brings no attachments (metaJson === null → keep the existing
|
|
2007
|
+
// task meta_json unchanged). Reverse-(e) invariant.
|
|
2008
|
+
// #1823 —— 回复的附件写到 tasks.meta_json.reply_attachments,**不碰** meta_json.attachments:
|
|
2009
|
+
// 那个键是提问者随任务带的附件,客户端把它画在提问者的气泡里。以前这里用回复的 metaJson
|
|
2010
|
+
// 整体 COALESCE 替换,于是 agent 回的图出现在提问者气泡下面、提问者自己带的附件被覆盖
|
|
2011
|
+
// (Vincent 2026-09-06 实测「好像没成功发送给我」)。inbox 行(收件方视角)照旧写 metaJson。
|
|
2012
|
+
const replyTaskMetaJson = (() => {
|
|
2013
|
+
if (!attachmentsResult.attachments.length) return null;
|
|
2014
|
+
const rowParams: any[] = [in_reply_to];
|
|
2015
|
+
let rowSql = "SELECT meta_json FROM tasks WHERE task_id = ?1";
|
|
2016
|
+
rowSql = addScope(rowSql, rowParams, effectiveNetId);
|
|
2017
|
+
const existing = db.get<{ meta_json: string | null }>(rowSql, ...rowParams)?.meta_json ?? null;
|
|
2018
|
+
let base: Record<string, unknown> = {};
|
|
2019
|
+
if (existing) { try { const parsed = JSON.parse(existing); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) base = parsed; } catch {} }
|
|
2020
|
+
return JSON.stringify({ ...base, reply_attachments: attachmentsResult.attachments });
|
|
2021
|
+
})();
|
|
2022
|
+
const updateParams: any[] = [replyStatus, text, replyTaskMetaJson, in_reply_to];
|
|
2023
|
+
let updateSql = `UPDATE tasks
|
|
2024
|
+
SET status = ?1,
|
|
2025
|
+
result = ?2,
|
|
2026
|
+
completed_at = datetime('now'),
|
|
2027
|
+
meta_json = COALESCE(?3, meta_json)
|
|
2028
|
+
WHERE task_id = ?4 AND status IN ('created', 'delivered', 'acked', 'running')`;
|
|
2029
|
+
if (peerCapabilityRequired && callerTokenIsNetwork && taskBefore) {
|
|
2030
|
+
if (taskBefore.to_node_id) {
|
|
2031
|
+
updateParams.push(callerNodeId);
|
|
2032
|
+
updateSql += ` AND to_node_id = ?${updateParams.length}`;
|
|
2033
|
+
} else {
|
|
2034
|
+
updateParams.push(taskBefore.to_name);
|
|
2035
|
+
updateSql += ` AND to_node_id IS NULL AND to_name = ?${updateParams.length}`;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
1057
2038
|
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
1058
2039
|
const result = db.run(updateSql, updateParams);
|
|
1059
2040
|
if (result.changes === 0) {
|
|
1060
|
-
|
|
1061
|
-
return false;
|
|
2041
|
+
throw new Error(`reply_atomic_cas_failed:${in_reply_to}`);
|
|
1062
2042
|
}
|
|
1063
|
-
|
|
2043
|
+
syncScheduledRunForTask(in_reply_to, effectiveNetId);
|
|
2044
|
+
// #1548 —— 终态回复是最强的活性证据:一个还标着 blocked 的发送方在此回到 idle。
|
|
2045
|
+
// 只出 blocked;working 可能真在忙别的任务,不碰。
|
|
2046
|
+
releaseBlockedSession(from_session, effectiveNetId);
|
|
2047
|
+
return { ok: true as const, replyLogged: true };
|
|
1064
2048
|
}
|
|
1065
|
-
return false;
|
|
2049
|
+
return { ok: true as const, replyLogged: false };
|
|
1066
2050
|
});
|
|
1067
2051
|
|
|
1068
|
-
if (
|
|
2052
|
+
if (!replyOutcome.ok) {
|
|
2053
|
+
const taskStatus = "taskStatus" in replyOutcome ? replyOutcome.taskStatus : undefined;
|
|
2054
|
+
const messages: Record<string, string> = {
|
|
2055
|
+
reply_task_not_found: `cannot apply reply: task not found (${in_reply_to})`,
|
|
2056
|
+
reply_task_terminal: `cannot apply reply: task is already terminal (${taskStatus})`,
|
|
2057
|
+
peer_reply_node_token_required: "atomic peer reply requires a node token",
|
|
2058
|
+
peer_reply_unsupported: "recipient or caller does not support atomic peer replies",
|
|
2059
|
+
peer_reply_origin_not_node: "original task sender is not a node; use send_reply",
|
|
2060
|
+
reply_node_identity_unbound: "cannot apply reply: node identity is not token-bound",
|
|
2061
|
+
reply_task_not_owned: "cannot apply reply: task is not owned by this node",
|
|
2062
|
+
reply_target_mismatch: "cannot apply reply: target is not the original task sender",
|
|
2063
|
+
};
|
|
1069
2064
|
return {
|
|
1070
2065
|
content: [{
|
|
1071
2066
|
type: "text" as const,
|
|
1072
2067
|
text: JSON.stringify({
|
|
1073
2068
|
ok: false,
|
|
1074
|
-
error:
|
|
1075
|
-
message:
|
|
2069
|
+
error: replyOutcome.error,
|
|
2070
|
+
message: messages[replyOutcome.error],
|
|
1076
2071
|
in_reply_to,
|
|
2072
|
+
...(taskStatus ? { task_status: taskStatus } : {}),
|
|
1077
2073
|
reply_queued: false,
|
|
1078
2074
|
}),
|
|
1079
2075
|
}],
|
|
1080
2076
|
};
|
|
1081
2077
|
}
|
|
2078
|
+
const replyLogged = replyOutcome.replyLogged;
|
|
1082
2079
|
|
|
1083
2080
|
// Log event after commit (outside transaction)
|
|
1084
2081
|
if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
|
|
@@ -1115,16 +2112,71 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1115
2112
|
}
|
|
1116
2113
|
}
|
|
1117
2114
|
|
|
1118
|
-
const session = scopedSessionStatus(
|
|
1119
|
-
pushEvent(
|
|
2115
|
+
const session = scopedSessionStatus(replyTargetAlias, effectiveNetId);
|
|
2116
|
+
pushEvent(replyTargetAlias, { type: "new_reply", inbox_count: pendingInboxCount(replyTargetAlias, effectiveNetId), from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
|
|
2117
|
+
// #461 network observer summary — ids + routing only, no reply text.
|
|
2118
|
+
pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to ?? null, message_id: id, from: from_session, to: replyTargetAlias, status: replyStatus });
|
|
2119
|
+
|
|
2120
|
+
// #498 compatibility tripwire. Legacy send_reply is intentionally kept
|
|
2121
|
+
// for old agents and Dashboard callers during rollout, but it is not the
|
|
2122
|
+
// capability-negotiated atomic peer primitive. Warn only for an actual
|
|
2123
|
+
// agent target; Dashboard hub/api replies remain quiet. New agents call
|
|
2124
|
+
// send_peer_reply and never see this warning.
|
|
2125
|
+
const targetIsAgent = replyTargetNodeId !== null
|
|
2126
|
+
&& replyTargetAlias !== "hub"
|
|
2127
|
+
&& replyTargetAlias !== "api";
|
|
2128
|
+
const warning = !peerCapabilityRequired && targetIsAgent
|
|
2129
|
+
? `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.)`
|
|
2130
|
+
: undefined;
|
|
2131
|
+
|
|
2132
|
+
// #507 — echo attachments READ BACK FROM DB (lead 2b5f6634): the point
|
|
2133
|
+
// of the echo is to prove attachments actually landed in storage, not
|
|
2134
|
+
// to reflect the in-memory variable we tried to write. Reading
|
|
2135
|
+
// `attachmentsResult.attachments` here would still show `ok:true` +
|
|
2136
|
+
// full echo if the UPDATE failed with `changes=0` (e.g. task moved to
|
|
2137
|
+
// terminal between the pre-check and the transaction). SELECT from
|
|
2138
|
+
// inbox.meta_json (always written, uses the id we just generated) so
|
|
2139
|
+
// the echo means "these attachments are on disk now". Absent field
|
|
2140
|
+
// when the caller sent no attachments — reverse-(e) invariant: a
|
|
2141
|
+
// no-attachment call returns the exact same response shape as before
|
|
2142
|
+
// this PR. Persisted-attachments field is only added when the caller
|
|
2143
|
+
// actually asked for attachments.
|
|
2144
|
+
let attachmentsSaved: unknown[] | null = null;
|
|
2145
|
+
if (attachmentsResult.attachments.length > 0) {
|
|
2146
|
+
const persistedRow = db.get<{ meta_json: string | null }>(
|
|
2147
|
+
"SELECT meta_json FROM inbox WHERE id = ?1", [id]
|
|
2148
|
+
);
|
|
2149
|
+
const persistedMeta = parseMetaJson(persistedRow?.meta_json ?? null);
|
|
2150
|
+
attachmentsSaved = persistedMeta && typeof persistedMeta === "object" && Array.isArray((persistedMeta as any).attachments)
|
|
2151
|
+
? (persistedMeta as any).attachments
|
|
2152
|
+
: [];
|
|
2153
|
+
}
|
|
1120
2154
|
|
|
1121
2155
|
return {
|
|
1122
2156
|
content: [{
|
|
1123
2157
|
type: "text" as const,
|
|
1124
|
-
text: JSON.stringify({
|
|
2158
|
+
text: JSON.stringify({
|
|
2159
|
+
ok: true,
|
|
2160
|
+
message_id: id,
|
|
2161
|
+
session_status: session?.status ?? "unknown",
|
|
2162
|
+
...(warning ? { warning } : {}),
|
|
2163
|
+
...(attachmentsSaved !== null ? { attachments_saved: attachmentsSaved } : {}),
|
|
2164
|
+
}),
|
|
1125
2165
|
}],
|
|
1126
2166
|
};
|
|
1127
|
-
}
|
|
2167
|
+
};
|
|
2168
|
+
|
|
2169
|
+
server.tool(
|
|
2170
|
+
"send_reply",
|
|
2171
|
+
"Reply to a Dashboard/UI-originated task. Also serves as the terminal legacy fallback when atomic peer reply capability is unavailable.",
|
|
2172
|
+
replyToolSchema,
|
|
2173
|
+
(args) => handleReply(args, false),
|
|
2174
|
+
);
|
|
2175
|
+
server.tool(
|
|
2176
|
+
"send_peer_reply",
|
|
2177
|
+
"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.",
|
|
2178
|
+
replyToolSchema,
|
|
2179
|
+
(args) => handleReply(args, true),
|
|
1128
2180
|
);
|
|
1129
2181
|
|
|
1130
2182
|
// ── V2: send_ack (不入 inbox,仅更新状态) ──
|
|
@@ -1134,9 +2186,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1134
2186
|
{
|
|
1135
2187
|
task_id: z.string().min(1).max(200).describe("Task ID to acknowledge"),
|
|
1136
2188
|
from_session: z.string().max(200).optional(),
|
|
2189
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1137
2190
|
},
|
|
1138
|
-
async ({ task_id, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1139
|
-
const effectiveNetId = getNetworkId(
|
|
2191
|
+
async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
2192
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1140
2193
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1141
2194
|
console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
|
|
1142
2195
|
const updateParams: any[] = [task_id];
|
|
@@ -1160,9 +2213,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1160
2213
|
{
|
|
1161
2214
|
task_id: z.string().min(1).max(200).describe("Task ID to retry"),
|
|
1162
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)"),
|
|
1163
2217
|
},
|
|
1164
|
-
async ({ task_id, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1165
|
-
const effectiveNetId = getNetworkId(
|
|
2218
|
+
async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
2219
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1166
2220
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1167
2221
|
console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
|
|
1168
2222
|
// Find the original task
|
|
@@ -1176,6 +2230,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1176
2230
|
if (!["failed", "expired", "cancelled"].includes(task.status)) {
|
|
1177
2231
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task status is ${task.status}, not retryable` }) }] };
|
|
1178
2232
|
}
|
|
2233
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 4/6).
|
|
2234
|
+
{
|
|
2235
|
+
const lc = assertNodeActive(task.to_name, effectiveNetId ?? task.network_id ?? null);
|
|
2236
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
2237
|
+
}
|
|
1179
2238
|
db.transaction(() => {
|
|
1180
2239
|
// Reset task status
|
|
1181
2240
|
const updateParams: any[] = [task_id];
|
|
@@ -1183,17 +2242,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1183
2242
|
WHERE task_id = ?1`;
|
|
1184
2243
|
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
1185
2244
|
db.run(updateSql, updateParams);
|
|
2245
|
+
syncScheduledRunForTask(task_id, effectiveNetId ?? task.network_id ?? null);
|
|
1186
2246
|
// Re-queue in inbox with new ID (original ID may already exist)
|
|
1187
2247
|
const retryInboxId = uuidv4();
|
|
1188
2248
|
db.run(
|
|
1189
|
-
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id)
|
|
1190
|
-
VALUES (?1, ?2, ?3, 'task', ?
|
|
1191
|
-
[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]
|
|
2249
|
+
`INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id)
|
|
2250
|
+
VALUES (?1, ?2, ?3, ?4, 'task', ?5, ?6, ?7, 'reply', ?8)`,
|
|
2251
|
+
[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]
|
|
1192
2252
|
);
|
|
1193
2253
|
});
|
|
1194
2254
|
logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
|
|
1195
2255
|
// SSE push (unconditional — channel is keyed by alias, not network)
|
|
1196
|
-
pushEvent(task.to_name, { type: "new_task", inbox_count:
|
|
2256
|
+
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);
|
|
2257
|
+
pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: task.to_name, status: "delivered", priority: task.priority });
|
|
1197
2258
|
return {
|
|
1198
2259
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
|
|
1199
2260
|
};
|
|
@@ -1233,19 +2294,64 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1233
2294
|
from_name: z.string().max(200).optional().describe("Filter by sender"),
|
|
1234
2295
|
from_node_id: z.string().max(200).optional().describe("Filter by immutable sender node_id"),
|
|
1235
2296
|
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
2297
|
+
before_created_at: z.string().max(64).optional().describe("Pagination cursor timestamp"),
|
|
2298
|
+
before_task_id: z.string().max(200).optional().describe("Pagination cursor task id"),
|
|
2299
|
+
durable_cursor: z.boolean().optional().describe("Require immutable node-scoped cursor protocol"),
|
|
2300
|
+
durable_terminal_cursor: z.boolean().optional().describe("Read terminal outbound journal in monotonic order"),
|
|
2301
|
+
after_terminal_seq: z.number().int().min(0).optional().describe("Exclusive terminal journal watermark"),
|
|
1236
2302
|
limit: z.number().min(1).max(100).optional().default(20),
|
|
1237
2303
|
},
|
|
1238
|
-
async ({ alias, status, from_name, from_node_id, network_id: netId, limit }) => {
|
|
2304
|
+
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 }) => {
|
|
1239
2305
|
const readScope = resolveReadScope(netId);
|
|
1240
2306
|
if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
1241
|
-
|
|
2307
|
+
if (durable_cursor) {
|
|
2308
|
+
if (!callerTokenIsNetwork) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_required" }) }] };
|
|
2309
|
+
const boundNodeId = callerTokenId && enforceNetworkId
|
|
2310
|
+
? db.get<{ bound_node_id: string | null }>(
|
|
2311
|
+
"SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
|
|
2312
|
+
callerTokenId,
|
|
2313
|
+
enforceNetworkId,
|
|
2314
|
+
)?.bound_node_id ?? null
|
|
2315
|
+
: null;
|
|
2316
|
+
if (!boundNodeId || from_node_id !== boundNodeId) {
|
|
2317
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "from_node_id_identity_mismatch" }) }] };
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
if (durable_terminal_cursor) {
|
|
2321
|
+
if (!durable_cursor) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "durable_cursor_required" }) }] };
|
|
2322
|
+
const params: any[] = [enforceNetworkId, from_node_id, after_terminal_seq ?? 0, limit];
|
|
2323
|
+
const tasks = db.all(
|
|
2324
|
+
`SELECT t.task_id, t.from_node_id, t.from_name, t.to_node_id, t.to_name, t.priority,
|
|
2325
|
+
t.status, t.content, t.result, t.created_at, t.runtime_submitted_at,
|
|
2326
|
+
t.consumed_at, t.thread_id, t.turn_id, t.completed_at, e.terminal_seq
|
|
2327
|
+
FROM task_terminal_events e
|
|
2328
|
+
JOIN tasks t ON t.task_id = e.task_id
|
|
2329
|
+
WHERE COALESCE(e.network_id, 'default') = COALESCE(?1, 'default')
|
|
2330
|
+
AND e.from_node_id = ?2 AND e.terminal_seq > ?3
|
|
2331
|
+
ORDER BY e.terminal_seq ASC LIMIT ?4`,
|
|
2332
|
+
...params,
|
|
2333
|
+
);
|
|
2334
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
2335
|
+
ok: true,
|
|
2336
|
+
capability: "list_tasks.immutable-terminal-sequence.v2",
|
|
2337
|
+
tasks,
|
|
2338
|
+
count: tasks.length,
|
|
2339
|
+
next_terminal_seq: tasks.length ? tasks[tasks.length - 1]?.terminal_seq : (after_terminal_seq ?? 0),
|
|
2340
|
+
has_more: tasks.length === limit,
|
|
2341
|
+
}) }] };
|
|
2342
|
+
}
|
|
2343
|
+
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";
|
|
1242
2344
|
const params: any[] = [];
|
|
1243
2345
|
sql = addReadScope(sql, params, readScope);
|
|
1244
2346
|
if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
|
|
1245
2347
|
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
1246
2348
|
if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
|
|
1247
2349
|
if (from_node_id) { sql += ` AND from_node_id = ?${params.length + 1}`; params.push(from_node_id); }
|
|
1248
|
-
|
|
2350
|
+
if (before_created_at && before_task_id) {
|
|
2351
|
+
sql += ` AND (created_at < ?${params.length + 1} OR (created_at = ?${params.length + 1} AND task_id < ?${params.length + 2}))`;
|
|
2352
|
+
params.push(before_created_at, before_task_id);
|
|
2353
|
+
}
|
|
2354
|
+
sql += ` ORDER BY created_at DESC, task_id DESC LIMIT ?${params.length + 1}`;
|
|
1249
2355
|
params.push(limit);
|
|
1250
2356
|
const tasks = db.all(sql, ...params);
|
|
1251
2357
|
|
|
@@ -1259,7 +2365,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1259
2365
|
return {
|
|
1260
2366
|
content: [{
|
|
1261
2367
|
type: "text" as const,
|
|
1262
|
-
text: JSON.stringify({
|
|
2368
|
+
text: JSON.stringify({
|
|
2369
|
+
ok: true,
|
|
2370
|
+
...(durable_cursor ? { capability: "list_tasks.immutable-node-cursor.v1" } : {}),
|
|
2371
|
+
tasks,
|
|
2372
|
+
count: tasks.length,
|
|
2373
|
+
next_cursor: tasks.length === limit ? {
|
|
2374
|
+
before_created_at: tasks[tasks.length - 1]?.created_at,
|
|
2375
|
+
before_task_id: tasks[tasks.length - 1]?.task_id,
|
|
2376
|
+
} : null,
|
|
2377
|
+
stats,
|
|
2378
|
+
}),
|
|
1263
2379
|
}],
|
|
1264
2380
|
};
|
|
1265
2381
|
}
|
|
@@ -1273,24 +2389,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1273
2389
|
task_id: z.string().min(1).max(200).describe("Task ID to cancel"),
|
|
1274
2390
|
reason: z.string().max(1000).optional().describe("Cancellation reason"),
|
|
1275
2391
|
from_session: z.string().max(200).optional(),
|
|
2392
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1276
2393
|
},
|
|
1277
|
-
async ({ task_id, reason, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1278
|
-
const effectiveNetId = getNetworkId(
|
|
2394
|
+
async ({ task_id, reason, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
2395
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1279
2396
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1280
2397
|
console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
|
|
1281
2398
|
const updateParams: any[] = [reason || "cancelled by " + from_session, task_id];
|
|
1282
2399
|
let updateSql = `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
|
|
1283
2400
|
WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`;
|
|
1284
2401
|
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
1285
|
-
const result = db.
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
2402
|
+
const result = db.transaction(() => {
|
|
2403
|
+
const updated = db.run(updateSql, updateParams);
|
|
2404
|
+
// Also ack the inbox entry to prevent agent from picking it up.
|
|
2405
|
+
if (updated.changes > 0) {
|
|
2406
|
+
const inboxParams: any[] = [task_id];
|
|
2407
|
+
let inboxSql = "UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0";
|
|
2408
|
+
inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
|
|
2409
|
+
db.run(inboxSql, inboxParams);
|
|
2410
|
+
syncScheduledRunForTask(task_id, effectiveNetId);
|
|
2411
|
+
}
|
|
2412
|
+
return updated;
|
|
2413
|
+
});
|
|
2414
|
+
if (result.changes > 0) logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
|
|
1294
2415
|
return {
|
|
1295
2416
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: result.changes > 0, task_id, cancelled: result.changes > 0 }) }],
|
|
1296
2417
|
};
|
|
@@ -1305,9 +2426,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1305
2426
|
task_id: z.string().min(1).max(200).describe("Task ID to reassign"),
|
|
1306
2427
|
new_alias: z.string().min(1).max(200).describe("Target agent alias"),
|
|
1307
2428
|
from_session: z.string().max(200).optional(),
|
|
2429
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1308
2430
|
},
|
|
1309
|
-
async ({ task_id, new_alias, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1310
|
-
const effectiveNetId = getNetworkId(
|
|
2431
|
+
async ({ task_id, new_alias, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
2432
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1311
2433
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1312
2434
|
console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
|
|
1313
2435
|
const taskParams: any[] = [task_id];
|
|
@@ -1323,10 +2445,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1323
2445
|
const reassignedAlias = canonical.alias;
|
|
1324
2446
|
const target = resolveDeliveryTarget(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
|
|
1325
2447
|
const newNodeId = target.state === "not_found" ? null : (target.session?.node_id ?? null);
|
|
2448
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 5/6).
|
|
2449
|
+
{
|
|
2450
|
+
const lc = assertNodeActive(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
|
|
2451
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
2452
|
+
}
|
|
1326
2453
|
db.transaction(() => {
|
|
1327
2454
|
// Ack old inbox to prevent original agent from picking it up
|
|
1328
2455
|
const inboxParams: any[] = [task_id];
|
|
1329
|
-
let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
|
|
2456
|
+
let inboxSql = "UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0";
|
|
1330
2457
|
inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
|
|
1331
2458
|
db.run(inboxSql, inboxParams);
|
|
1332
2459
|
|
|
@@ -1336,18 +2463,176 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1336
2463
|
db.run(updateSql, updateParams);
|
|
1337
2464
|
|
|
1338
2465
|
const newInboxId = uuidv4();
|
|
1339
|
-
db.run("INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id) VALUES (?1, ?2, ?3, 'task', ?
|
|
1340
|
-
[newInboxId, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
|
|
2466
|
+
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)",
|
|
2467
|
+
[newInboxId, task_id, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
|
|
1341
2468
|
});
|
|
1342
2469
|
logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${reassignedAlias}`);
|
|
1343
|
-
pushEvent(reassignedAlias, { type: "new_task", inbox_count:
|
|
2470
|
+
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);
|
|
2471
|
+
pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: reassignedAlias, status: "delivered", priority: task.priority });
|
|
1344
2472
|
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 } : {}) }) }] };
|
|
1345
2473
|
}
|
|
1346
2474
|
);
|
|
1347
2475
|
|
|
1348
2476
|
server.tool(
|
|
1349
|
-
"
|
|
1350
|
-
"
|
|
2477
|
+
"send_desktop_message",
|
|
2478
|
+
"Push a message to a user's active Desktop/web clients in one network. Targets user identity, not node alias.",
|
|
2479
|
+
{
|
|
2480
|
+
to_user_id: z.string().min(1).max(200).optional().describe("Target user_id. Mutually checked with to_username when both are supplied."),
|
|
2481
|
+
to_username: z.string().min(1).max(50).optional().describe("Target username. Mutually checked with to_user_id when both are supplied."),
|
|
2482
|
+
title: z.string().max(200).optional(),
|
|
2483
|
+
message: z.string().min(1).max(10000),
|
|
2484
|
+
severity: z.enum(["info", "success", "warning", "error"]).optional().default("info"),
|
|
2485
|
+
kind: z.string().min(1).max(80).optional().default("agent_message"),
|
|
2486
|
+
meta: z.record(z.string(), z.unknown()).optional(),
|
|
2487
|
+
from_session: z.string().max(200).optional(),
|
|
2488
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens; ntok stays bound to its network)."),
|
|
2489
|
+
},
|
|
2490
|
+
async ({ to_user_id, to_username, title, message, severity, kind, meta, from_session: _fromIn, network_id: netId }) => {
|
|
2491
|
+
const fromMismatch = fromIdentityMismatchReply(_fromIn);
|
|
2492
|
+
if (fromMismatch) return fromMismatch;
|
|
2493
|
+
const from_session = defaultFrom(_fromIn);
|
|
2494
|
+
const effectiveNetId = getNetworkId(netId);
|
|
2495
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
2496
|
+
if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
|
|
2497
|
+
|
|
2498
|
+
if (!to_user_id && !to_username) {
|
|
2499
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_required", message: "to_user_id or to_username is required" }) }] };
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
const byId = to_user_id
|
|
2503
|
+
? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE user_id = ?1", to_user_id)
|
|
2504
|
+
: null;
|
|
2505
|
+
const byName = to_username
|
|
2506
|
+
? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE username = ?1", to_username)
|
|
2507
|
+
: null;
|
|
2508
|
+
|
|
2509
|
+
if (to_user_id && !byId) {
|
|
2510
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_user_id" }) }] };
|
|
2511
|
+
}
|
|
2512
|
+
if (to_username && !byName) {
|
|
2513
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_username" }) }] };
|
|
2514
|
+
}
|
|
2515
|
+
if (byId && byName && byId.user_id !== byName.user_id) {
|
|
2516
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_mismatch", to_user_id, to_username }) }] };
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
const target = byId ?? byName;
|
|
2520
|
+
if (!target) {
|
|
2521
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found" }) }] };
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
const targetRole = getUserNetworkRole(target.user_id, effectiveNetId);
|
|
2525
|
+
if (!targetRole) {
|
|
2526
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_in_network", network_id: effectiveNetId }) }] };
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
const messageId = `dm_${uuidv4()}`;
|
|
2530
|
+
const event = {
|
|
2531
|
+
type: "desktop_message",
|
|
2532
|
+
message_id: messageId,
|
|
2533
|
+
kind,
|
|
2534
|
+
from: from_session,
|
|
2535
|
+
title: title || null,
|
|
2536
|
+
message,
|
|
2537
|
+
severity,
|
|
2538
|
+
created_at: new Date().toISOString(),
|
|
2539
|
+
...(meta && Object.keys(meta).length ? { meta } : {}),
|
|
2540
|
+
};
|
|
2541
|
+
|
|
2542
|
+
// #1459 ① P2 —— 审计与收件持久化必须同生共死:只落其中一半,就会出现
|
|
2543
|
+
// 「审计说发过、而用户永远收不到」或者反过来。放进同一个事务。
|
|
2544
|
+
db.transaction(() => {
|
|
2545
|
+
db.run(
|
|
2546
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
2547
|
+
VALUES (?1, ?2, 'send_desktop_message', 'user', ?3, ?4, ?5)`,
|
|
2548
|
+
[
|
|
2549
|
+
enforceUserId || null,
|
|
2550
|
+
callerAlias || null,
|
|
2551
|
+
target.user_id,
|
|
2552
|
+
JSON.stringify({
|
|
2553
|
+
message_id: messageId,
|
|
2554
|
+
to_username: target.username,
|
|
2555
|
+
from: from_session,
|
|
2556
|
+
severity,
|
|
2557
|
+
kind,
|
|
2558
|
+
title: title || null,
|
|
2559
|
+
meta_keys: meta && typeof meta === "object" ? Object.keys(meta) : [],
|
|
2560
|
+
}),
|
|
2561
|
+
effectiveNetId,
|
|
2562
|
+
],
|
|
2563
|
+
);
|
|
2564
|
+
// 🔴 `ON CONFLICT(message_id) DO NOTHING`,而不是 `INSERT OR IGNORE`:
|
|
2565
|
+
// 两者对"重投同一个 message_id"效果相同(幂等,不产生重复消息),
|
|
2566
|
+
// 但 OR IGNORE 会**连同其它约束错误一起吞掉** —— 我在实现本段时就
|
|
2567
|
+
// 撞上了:`kind` 为 NULL 触发 NOT NULL,OR IGNORE 静默丢弃整行,
|
|
2568
|
+
// 而工具照样返回 `persisted: true`。**一条消息凭空消失且报告成功**,
|
|
2569
|
+
// 正是本 issue 要消灭的那个形状。
|
|
2570
|
+
// DO NOTHING 只针对主键冲突,别的约束照常抛出、事务回滚。
|
|
2571
|
+
//
|
|
2572
|
+
// 另:普通 INSERT 会让重试抛主键冲突(无害重试变 500);
|
|
2573
|
+
// OR REPLACE 会覆盖旧行、把用户已 ack 的状态冲掉。都不取。
|
|
2574
|
+
db.run(
|
|
2575
|
+
`INSERT INTO user_inbox
|
|
2576
|
+
(message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json)
|
|
2577
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
2578
|
+
ON CONFLICT(message_id) DO NOTHING`,
|
|
2579
|
+
[
|
|
2580
|
+
messageId,
|
|
2581
|
+
effectiveNetId ?? null,
|
|
2582
|
+
target.user_id,
|
|
2583
|
+
from_session,
|
|
2584
|
+
// 🔴 不依赖 zod 的 .default():直接调用 handler 的调用方(测试、
|
|
2585
|
+
// 未来的内部调用)拿不到 zod 默认值,会把 NULL 写进 NOT NULL 列。
|
|
2586
|
+
// 列上虽有 DEFAULT,但**显式传 NULL 会覆盖列默认值**。
|
|
2587
|
+
kind ?? "agent_message",
|
|
2588
|
+
title || null,
|
|
2589
|
+
message,
|
|
2590
|
+
severity ?? "info",
|
|
2591
|
+
// 存储侧走与 inbox 一致的跨主机脱敏;读取侧另有一道 token 形状
|
|
2592
|
+
// 脱敏(规格 ③ redact-at-read),两层分别防不同的东西。
|
|
2593
|
+
meta && typeof meta === "object" ? normalizeMetaJson(meta) : null,
|
|
2594
|
+
],
|
|
2595
|
+
);
|
|
2596
|
+
});
|
|
2597
|
+
// #1459 / #1563 — pushUserEvent 在没有订阅者时静默 return。
|
|
2598
|
+
// 🔴 这段注释原先写「本工具既不写 inbox、也没有 user 级回读路径 ⇒ dashboard
|
|
2599
|
+
// 关着时消息永久没了」。**那半句已经不成立** —— 上面 20 行就在
|
|
2600
|
+
// `INSERT INTO user_inbox`,且 `/api/messages?scope=user` 能回读
|
|
2601
|
+
// (desktop-message-seam.test.ts 有真实 send → 真实 GET 的端到端断言)。
|
|
2602
|
+
// 留着它会把 debug 未读数的人直接带向错误的一层。
|
|
2603
|
+
//
|
|
2604
|
+
// 这里先把**丢失变可见**:`ok` 仍表示「请求被接受、审计已落库」,
|
|
2605
|
+
// 新增 `delivered` 表示「此刻真的有人收到」。判据取自订阅者注册表,
|
|
2606
|
+
// 不是猜的。持久化 + 重连补投是另一条(按 user_id 寻址的新收件表,#1459)。
|
|
2607
|
+
//
|
|
2608
|
+
// 🔴 `reason` 报的是**观测到的事实**(此刻没有活订阅者),不是它的后果。
|
|
2609
|
+
// 今天「没有活订阅者」确实等于「丢了」,但持久化落地之后同一个事实会
|
|
2610
|
+
// 变成「已入库、等重连补投」。把值取成 no_live_subscriber 而不是
|
|
2611
|
+
// "lost"/"dropped",就是为了那天不用改写历史含义 —— 届时按需细分
|
|
2612
|
+
// (如 lost vs queued)即可,现在不预先把语义焊死。
|
|
2613
|
+
// 🔴 push 必须在事务**提交之后**:放进事务里的话,活订阅者可能先收到
|
|
2614
|
+
// 事件、而行还没提交;一旦回滚,用户就看到了一条数据库里不存在的消息。
|
|
2615
|
+
const delivered = hasUserSubscribers(effectiveNetId, target.user_id);
|
|
2616
|
+
pushUserEvent(effectiveNetId, target.user_id, event);
|
|
2617
|
+
|
|
2618
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
2619
|
+
ok: true,
|
|
2620
|
+
message_id: messageId,
|
|
2621
|
+
delivered,
|
|
2622
|
+
// #1459 ① P2 —— 有了持久化之后,「此刻没人在听」不再等于「丢了」。
|
|
2623
|
+
// `delivered` 说的是实时投递、`persisted` 说的是它已经入库等重连补投。
|
|
2624
|
+
// `reason` 仍报**观测到的事实**而不是它的后果,所以这个中间态没有说错话。
|
|
2625
|
+
persisted: true,
|
|
2626
|
+
...(delivered ? {} : { reason: "no_live_subscriber" }),
|
|
2627
|
+
delivered_to_user_id: target.user_id,
|
|
2628
|
+
network_id: effectiveNetId,
|
|
2629
|
+
}) }] };
|
|
2630
|
+
},
|
|
2631
|
+
);
|
|
2632
|
+
|
|
2633
|
+
server.tool(
|
|
2634
|
+
"broadcast",
|
|
2635
|
+
"Send a message to multiple sessions.",
|
|
1351
2636
|
{
|
|
1352
2637
|
message: z.string().min(1).max(10000),
|
|
1353
2638
|
filter_server: z.string().max(200).optional(),
|
|
@@ -1366,26 +2651,39 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1366
2651
|
|
|
1367
2652
|
const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
|
|
1368
2653
|
const ids: string[] = [];
|
|
2654
|
+
// #1440 ① — the push loop must walk the recipients that actually
|
|
2655
|
+
// got an inbox row, not every candidate target. Walking `targets`
|
|
2656
|
+
// announced a broadcast to nodes the lifecycle guard had just
|
|
2657
|
+
// skipped, i.e. nodes with nothing to fetch.
|
|
2658
|
+
const delivered: Array<{ alias: string; netId: string | null }> = [];
|
|
1369
2659
|
|
|
1370
2660
|
for (const t of targets) {
|
|
2661
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 6/6).
|
|
2662
|
+
// Broadcast skips non-active recipients silently rather than
|
|
2663
|
+
// failing the entire send — broadcast semantics are best-effort
|
|
2664
|
+
// per-recipient and a stopped node simply gets nothing.
|
|
2665
|
+
const netId = effectiveNetId ?? t.network_id ?? null;
|
|
2666
|
+
const lc = assertNodeActive(t.alias, netId);
|
|
2667
|
+
if (!lc.ok) continue;
|
|
1371
2668
|
const id = uuidv4();
|
|
1372
2669
|
db.run(
|
|
1373
2670
|
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
|
|
1374
2671
|
VALUES (?1, ?2, ?3, 'broadcast', 'normal', ?4, 'hub', ?5)`,
|
|
1375
|
-
[id, t.alias, t.node_id ?? null, message,
|
|
2672
|
+
[id, t.alias, t.node_id ?? null, message, netId]
|
|
1376
2673
|
);
|
|
1377
2674
|
ids.push(id);
|
|
2675
|
+
delivered.push({ alias: t.alias, netId });
|
|
1378
2676
|
}
|
|
1379
2677
|
|
|
1380
|
-
for (const
|
|
1381
|
-
pushEvent(
|
|
2678
|
+
for (const d of delivered) {
|
|
2679
|
+
pushEvent(d.alias, { type: "broadcast", inbox_count: pendingInboxCount(d.alias, d.netId) }, d.netId);
|
|
1382
2680
|
}
|
|
1383
2681
|
|
|
1384
2682
|
return {
|
|
1385
2683
|
content: [
|
|
1386
2684
|
{
|
|
1387
2685
|
type: "text" as const,
|
|
1388
|
-
text: JSON.stringify({ ok: true, recipients:
|
|
2686
|
+
text: JSON.stringify({ ok: true, recipients: ids.length, message_ids: ids }),
|
|
1389
2687
|
},
|
|
1390
2688
|
],
|
|
1391
2689
|
};
|
|
@@ -1478,15 +2776,34 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1478
2776
|
"update_node_config",
|
|
1479
2777
|
"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.",
|
|
1480
2778
|
{
|
|
1481
|
-
|
|
2779
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
1482
2780
|
base_revision: z.number().int().min(0).describe("Current revision per the dashboard's last GET — 409 if hub's current revision differs."),
|
|
1483
2781
|
patch: z.object({
|
|
1484
2782
|
model: z.string().max(200).optional(),
|
|
1485
|
-
flags: z.record(z.unknown()).optional(),
|
|
2783
|
+
flags: z.record(z.string(), z.unknown()).optional(),
|
|
2784
|
+
// #260 P5 — channel enable/disable. Restart-tier field (agent-node
|
|
2785
|
+
// reads config.channels once at boot to fork per-channel workers,
|
|
2786
|
+
// so the swap takes effect via process restart). Not
|
|
2787
|
+
// SECURITY_SENSITIVE — this is a lifecycle op, gated by SEC-1
|
|
2788
|
+
// (network scope) only.
|
|
2789
|
+
//
|
|
2790
|
+
// Deliberately `z.array(z.unknown()).max(16)` (mirrors flags's
|
|
2791
|
+
// `z.record(z.string(), z.unknown())`): trust nothing at the wire boundary,
|
|
2792
|
+
// narrow the same way `narrowChannelsPatch` narrows raw untrusted
|
|
2793
|
+
// JSON (typeof + allowlist + dedup + case-fold). A strict
|
|
2794
|
+
// `z.array(z.string())` would fail-fast on a single non-string
|
|
2795
|
+
// entry, but the wire contract wants junk silently dropped so a
|
|
2796
|
+
// dashboard fat-finger doesn't turn into a 400 the user has to
|
|
2797
|
+
// interpret. validatePatch then re-rejects if the caller bypassed
|
|
2798
|
+
// narrowing.
|
|
2799
|
+
channels: z.array(z.unknown()).max(16).optional(),
|
|
1486
2800
|
}).describe("Fields to update. Empty patch → no-op (use restart_node for that)."),
|
|
1487
2801
|
network_id: z.string().max(200).optional(),
|
|
1488
2802
|
},
|
|
1489
|
-
async ({ node_id
|
|
2803
|
+
async ({ node_id, child_node_id, base_revision: baseRev, patch, network_id: clientNetId }) => {
|
|
2804
|
+
const idArg = resolveNodeIdArg({ node_id, child_node_id });
|
|
2805
|
+
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
2806
|
+
const nodeId = idArg.node_id;
|
|
1490
2807
|
const effectiveNetId = getNetworkId(clientNetId);
|
|
1491
2808
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
1492
2809
|
|
|
@@ -1500,6 +2817,50 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1500
2817
|
|
|
1501
2818
|
const model = typeof patch.model === "string" ? patch.model : undefined;
|
|
1502
2819
|
const flags = (patch.flags && typeof patch.flags === "object") ? patch.flags as Record<string, unknown> : {};
|
|
2820
|
+
// Narrow untrusted `channels` at the boundary (typeof + allowlist +
|
|
2821
|
+
// dedup + case-fold), and distinguish two very different cases
|
|
2822
|
+
// that both narrow to `[]`:
|
|
2823
|
+
// (a) `patch.channels === []` (explicit "disable all editable
|
|
2824
|
+
// channels"). Downstream must proceed and write channels=[]
|
|
2825
|
+
// so the node's next restart forks no workers.
|
|
2826
|
+
// (b) `patch.channels === ["commhub"]` or similar — the caller
|
|
2827
|
+
// sent items but every single one was invalid (dashboard PR
|
|
2828
|
+
// #31 still ships commhub, and a typo like "telegarm" would
|
|
2829
|
+
// hit the same path). Downstream MUST NOT treat this as
|
|
2830
|
+
// (a) — silently converting to disable-all would nuke the
|
|
2831
|
+
// user's existing telegram/feishu workers.
|
|
2832
|
+
//
|
|
2833
|
+
// The reject error is `channels_all_invalid` so the dashboard
|
|
2834
|
+
// can surface the mismatch instead of showing a false success.
|
|
2835
|
+
let channels: string[] | undefined = undefined;
|
|
2836
|
+
if (patch.channels !== undefined) {
|
|
2837
|
+
if (!Array.isArray(patch.channels)) {
|
|
2838
|
+
// Zod already permits arrays only; belt+braces if it drifts.
|
|
2839
|
+
return {
|
|
2840
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_patch", field: "channels", reason: "must be an array" }) }],
|
|
2841
|
+
};
|
|
2842
|
+
}
|
|
2843
|
+
const narrowed = narrowChannelsPatch(patch.channels) ?? [];
|
|
2844
|
+
if (patch.channels.length === 0) {
|
|
2845
|
+
channels = []; // (a) explicit disable-all
|
|
2846
|
+
} else if (narrowed.length === 0) {
|
|
2847
|
+
// (b) every entry was invalid — refuse to write. Distinct
|
|
2848
|
+
// error so the dashboard can surface the failure.
|
|
2849
|
+
return {
|
|
2850
|
+
content: [{
|
|
2851
|
+
type: "text" as const,
|
|
2852
|
+
text: JSON.stringify({
|
|
2853
|
+
ok: false,
|
|
2854
|
+
error: "channels_all_invalid",
|
|
2855
|
+
requested: patch.channels,
|
|
2856
|
+
message: "every requested channel is unknown or unsupported; use an explicit empty array to disable-all",
|
|
2857
|
+
}),
|
|
2858
|
+
}],
|
|
2859
|
+
};
|
|
2860
|
+
} else {
|
|
2861
|
+
channels = narrowed;
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
1503
2864
|
|
|
1504
2865
|
// SEC-2 (final policy 2026-06-28) — security-sensitive flags
|
|
1505
2866
|
// (permissionMode / dangerouslySkipPermissions / teammateMode)
|
|
@@ -1526,7 +2887,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1526
2887
|
};
|
|
1527
2888
|
}
|
|
1528
2889
|
|
|
1529
|
-
const validationFail = validatePatch(model, flags);
|
|
2890
|
+
const validationFail = validatePatch(model, flags, channels);
|
|
1530
2891
|
if (validationFail) {
|
|
1531
2892
|
return {
|
|
1532
2893
|
content: [{
|
|
@@ -1604,8 +2965,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1604
2965
|
|
|
1605
2966
|
// Compute apply_mode + persist + push doorbell.
|
|
1606
2967
|
const updateId = `cu_${uuidv4()}`;
|
|
1607
|
-
const applyMode = computeApplyMode(model, flags);
|
|
1608
|
-
const patchJson = JSON.stringify({
|
|
2968
|
+
const applyMode = computeApplyMode(model, flags, channels);
|
|
2969
|
+
const patchJson = JSON.stringify({
|
|
2970
|
+
...(model !== undefined ? { model } : {}),
|
|
2971
|
+
flags,
|
|
2972
|
+
...(channels !== undefined ? { channels } : {}),
|
|
2973
|
+
});
|
|
1609
2974
|
const networkId = node.network_id || "default";
|
|
1610
2975
|
db.run(
|
|
1611
2976
|
`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)`,
|
|
@@ -1623,6 +2988,51 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1623
2988
|
},
|
|
1624
2989
|
);
|
|
1625
2990
|
|
|
2991
|
+
// One alias can own several `nodes` rows in a network: re-creating a node
|
|
2992
|
+
// under the same alias leaves the old row behind, and `db.get` on
|
|
2993
|
+
// `WHERE alias = ? AND network_id = ?` then returns whichever row SQLite
|
|
2994
|
+
// happens to yield first. A field report showed the result: the client
|
|
2995
|
+
// queued a rules-file read for the live node, the node pulled with its own
|
|
2996
|
+
// token, the hub resolved its alias to the stale row, found nothing pending
|
|
2997
|
+
// and returned `request: null` until the read timed out.
|
|
2998
|
+
//
|
|
2999
|
+
// resolveNodeByAlias: prefer the row the alias's session points at (sessions
|
|
3000
|
+
// are UNIQUE per network+alias and carry the node_id that last reported),
|
|
3001
|
+
// then the newest row. Used where only an alias is known (a client targeting
|
|
3002
|
+
// by alias; unbound legacy node tokens).
|
|
3003
|
+
const resolveNodeByAlias = (alias: string, networkId: string) =>
|
|
3004
|
+
db.get<{ node_id: string; alias: string; network_id: string | null }>(
|
|
3005
|
+
`SELECT n.node_id, n.alias, n.network_id FROM nodes n
|
|
3006
|
+
LEFT JOIN sessions s ON s.alias = n.alias AND s.network_id = n.network_id AND s.node_id = n.node_id
|
|
3007
|
+
WHERE n.alias = ?1 AND n.network_id = ?2
|
|
3008
|
+
ORDER BY (s.node_id IS NOT NULL) DESC, n.created_at DESC, n.rowid DESC
|
|
3009
|
+
LIMIT 1`,
|
|
3010
|
+
alias,
|
|
3011
|
+
networkId,
|
|
3012
|
+
) ?? null;
|
|
3013
|
+
// resolveCallerNode: the node a network token pulls/acks for. A token bound
|
|
3014
|
+
// to a node resolves to exactly that node (and never to another row that
|
|
3015
|
+
// shares its alias); only unbound legacy tokens fall back to the alias.
|
|
3016
|
+
// Callers have already required callerTokenIsNetwork + enforceNetworkId +
|
|
3017
|
+
// callerAlias.
|
|
3018
|
+
const resolveCallerNode = (): { node_id: string; network_id: string | null } | null => {
|
|
3019
|
+
const bound = callerTokenId
|
|
3020
|
+
? db.get<{ bound_node_id: string | null }>(
|
|
3021
|
+
"SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
|
|
3022
|
+
callerTokenId,
|
|
3023
|
+
enforceNetworkId,
|
|
3024
|
+
)?.bound_node_id ?? null
|
|
3025
|
+
: null;
|
|
3026
|
+
if (bound) {
|
|
3027
|
+
return db.get<{ node_id: string; network_id: string | null }>(
|
|
3028
|
+
"SELECT node_id, network_id FROM nodes WHERE node_id = ?1 AND network_id = ?2",
|
|
3029
|
+
bound,
|
|
3030
|
+
enforceNetworkId,
|
|
3031
|
+
) ?? null;
|
|
3032
|
+
}
|
|
3033
|
+
return resolveNodeByAlias(callerAlias!, enforceNetworkId!);
|
|
3034
|
+
};
|
|
3035
|
+
|
|
1626
3036
|
server.tool(
|
|
1627
3037
|
"get_config_update",
|
|
1628
3038
|
"Node pulls its pending config update (called from agent-node when SSE config_update doorbell arrives). RFC-024.",
|
|
@@ -1646,11 +3056,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1646
3056
|
// Unconditional network_id filter (was previously conditional on
|
|
1647
3057
|
// enforceNetworkId being set; the new ntok guard above guarantees
|
|
1648
3058
|
// it's non-null so the filter is always applied).
|
|
1649
|
-
const node =
|
|
1650
|
-
"SELECT node_id, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
1651
|
-
callerAlias,
|
|
1652
|
-
enforceNetworkId,
|
|
1653
|
-
);
|
|
3059
|
+
const node = resolveCallerNode();
|
|
1654
3060
|
if (!node) {
|
|
1655
3061
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update: null }) }] };
|
|
1656
3062
|
}
|
|
@@ -1703,11 +3109,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1703
3109
|
// Cross-tenant guard: the ack-er must own the update being acked.
|
|
1704
3110
|
// Resolve node by caller's alias under the enforced network.
|
|
1705
3111
|
// Network filter is unconditional (guard above guarantees non-null).
|
|
1706
|
-
const node =
|
|
1707
|
-
"SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
1708
|
-
callerAlias,
|
|
1709
|
-
enforceNetworkId,
|
|
1710
|
-
);
|
|
3112
|
+
const node = resolveCallerNode();
|
|
1711
3113
|
if (!node) {
|
|
1712
3114
|
// Silently ignore stale ack — return ok so the node doesn't retry forever.
|
|
1713
3115
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "alias_unknown" }) }] };
|
|
@@ -1743,14 +3145,392 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1743
3145
|
},
|
|
1744
3146
|
);
|
|
1745
3147
|
|
|
3148
|
+
// ── app#225 — 节点规则文件(AGENTS.md / CLAUDE.md)远程读写 ──
|
|
3149
|
+
//
|
|
3150
|
+
// 形状照抄 RFC-024 config-apply:客户端调 read_node_rules_file /
|
|
3151
|
+
// write_node_rules_file 落一行 node_rules_requests + 门铃 {type:"rules_file"};
|
|
3152
|
+
// 节点收到门铃后 get_rules_file_request 拉(和 get_config_update 同一道
|
|
3153
|
+
// F-A 闸:网络 token + alias),做完 ack_rules_file_request 回报;客户端
|
|
3154
|
+
// 轮询 get_rules_file_result。
|
|
3155
|
+
//
|
|
3156
|
+
// 🔴 安全(#225 验收第 5 条):整条链路**没有路径参数**。文件名由节点按
|
|
3157
|
+
// 自己的 RUNTIME 决定(claude → CLAUDE.md,其余 → AGENTS.md),目录固定是
|
|
3158
|
+
// 节点进程的 cwd(agent-node/src/runtime/rules-file.ts)。hub 只传 op +
|
|
3159
|
+
// content,客户端连文件名都指定不了,所以不存在「借路径参数写任意文件」。
|
|
3160
|
+
// 读和写都要 canWrite:规则文件是节点行为的一部分,读它等于读节点配置。
|
|
3161
|
+
const RULES_FILE_MAX_BYTES = 256 * 1024;
|
|
3162
|
+
const RULES_REQUEST_STALE_MS = 60_000;
|
|
3163
|
+
|
|
3164
|
+
// Node skills view — skills_list / skill_read ride the same queue + doorbell.
|
|
3165
|
+
// `content` carries the skill NAME for skill_read (never a path; the node
|
|
3166
|
+
// validates it again against [A-Za-z0-9._-] and resolves it under its own
|
|
3167
|
+
// runtime's skills roots — node-skills.ts).
|
|
3168
|
+
const SKILL_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
3169
|
+
const isSkillsOp = (op: string) => op === "skills_list" || op === "skill_read";
|
|
3170
|
+
|
|
3171
|
+
// Project folder view — files_list / file_read ride the same queue + doorbell.
|
|
3172
|
+
// `content` carries a path RELATIVE to the node's work dir. The hub refuses
|
|
3173
|
+
// absolute paths, `..` segments, backslashes and NUL before any row exists;
|
|
3174
|
+
// the node re-validates, realpath-contains the result in its work dir and
|
|
3175
|
+
// refuses secret-looking files (node-files.ts). Stricter than skills on the
|
|
3176
|
+
// caller: a node token may not browse another node's disk, and only the token
|
|
3177
|
+
// that asked can read the answer.
|
|
3178
|
+
const NODE_FILE_PATH_MAX = 1024;
|
|
3179
|
+
const NODE_FILES_RESULT_MAX_CHARS = 1024 * 1024;
|
|
3180
|
+
const isFilesOp = (op: string) => op === "files_list" || op === "file_read";
|
|
3181
|
+
const normalizeNodeRelPath = (raw: unknown): string | null => {
|
|
3182
|
+
if (raw === undefined || raw === null) return "";
|
|
3183
|
+
if (typeof raw !== "string" || raw.length > NODE_FILE_PATH_MAX) return null;
|
|
3184
|
+
if (raw.includes("\0") || raw.includes("\\")) return null;
|
|
3185
|
+
if (raw.startsWith("/") || raw.startsWith("~") || /^[A-Za-z]:/.test(raw)) return null;
|
|
3186
|
+
const segs = raw.split("/").filter((s) => s !== "" && s !== ".");
|
|
3187
|
+
if (segs.some((s) => s === "..")) return null;
|
|
3188
|
+
return segs.join("/");
|
|
3189
|
+
};
|
|
3190
|
+
|
|
3191
|
+
const enqueueRulesFileRequest = (
|
|
3192
|
+
op: "read" | "write" | "skills_list" | "skill_read" | "files_list" | "file_read",
|
|
3193
|
+
a: { node_id?: string; child_node_id?: string; alias?: string; network_id?: string; content?: string },
|
|
3194
|
+
) => {
|
|
3195
|
+
const effectiveNetId = getNetworkId(a.network_id);
|
|
3196
|
+
if (isFilesOp(op)) {
|
|
3197
|
+
if (callerTokenIsNetwork) {
|
|
3198
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_cannot_browse_files", message: "a node token cannot browse another node's project folder; use a user login" }) }] };
|
|
3199
|
+
}
|
|
3200
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, `${op} node files`);
|
|
3201
|
+
const rel = normalizeNodeRelPath(a.content);
|
|
3202
|
+
if (rel === null || (op === "file_read" && rel === "")) {
|
|
3203
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_path", reason: "path must be relative to the node work dir: no leading / or ~, no .. segment, no backslash or NUL, at most 1024 chars" }) }] };
|
|
3204
|
+
}
|
|
3205
|
+
a = { ...a, content: rel };
|
|
3206
|
+
}
|
|
3207
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, isSkillsOp(op) ? `${op} node skills` : `${op} rules file`);
|
|
3208
|
+
if (op === "skill_read" && (typeof a.content !== "string" || !SKILL_NAME_RE.test(a.content) || a.content === "." || a.content === "..")) {
|
|
3209
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_skill_name", reason: "name must match [A-Za-z0-9._-]{1,64} and not be . or .." }) }] };
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
// Target: a `nodes` row by node_id (original path), or — app#225 follow-up —
|
|
3213
|
+
// an alias. An alias resolves to its `nodes` row when there is one; otherwise
|
|
3214
|
+
// to a live session in the caller's network that advertised
|
|
3215
|
+
// rules_file_capable (claude-code sessions mostly have no `nodes` row). The
|
|
3216
|
+
// queue key for such a session is `session:<alias>`, network-scoped.
|
|
3217
|
+
let node: { node_id: string; alias: string; network_id: string | null } | null = null;
|
|
3218
|
+
if (a.node_id || a.child_node_id || !a.alias) {
|
|
3219
|
+
const idArg = resolveNodeIdArg({ node_id: a.node_id, child_node_id: a.child_node_id });
|
|
3220
|
+
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
3221
|
+
const nodeId = idArg.node_id;
|
|
3222
|
+
const resolved = resolveTargetNode(nodeId, effectiveNetId);
|
|
3223
|
+
if (!resolved.row) {
|
|
3224
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: nodeId }) }] };
|
|
3225
|
+
}
|
|
3226
|
+
if (!resolved.sec1Ok) {
|
|
3227
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node", message: "node belongs to another network" }) }] };
|
|
3228
|
+
}
|
|
3229
|
+
node = resolved.row as any;
|
|
3230
|
+
} else {
|
|
3231
|
+
const scopeNet = effectiveNetId || "default";
|
|
3232
|
+
const byAlias = resolveNodeByAlias(a.alias, scopeNet);
|
|
3233
|
+
if (byAlias) {
|
|
3234
|
+
node = byAlias;
|
|
3235
|
+
} else {
|
|
3236
|
+
const session = db.get<{ alias: string; network_id: string | null }>(
|
|
3237
|
+
isFilesOp(op)
|
|
3238
|
+
? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND files_capable = 1 ORDER BY updated_at DESC LIMIT 1"
|
|
3239
|
+
: isSkillsOp(op)
|
|
3240
|
+
? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND skills_capable = 1 ORDER BY updated_at DESC LIMIT 1"
|
|
3241
|
+
: "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND rules_file_capable = 1 ORDER BY updated_at DESC LIMIT 1",
|
|
3242
|
+
a.alias,
|
|
3243
|
+
scopeNet,
|
|
3244
|
+
);
|
|
3245
|
+
if (!session) {
|
|
3246
|
+
if (isFilesOp(op)) {
|
|
3247
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "files_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve its project folder (its channel server may be too old)" }) }] };
|
|
3248
|
+
}
|
|
3249
|
+
return isSkillsOp(op)
|
|
3250
|
+
? { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "skills_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve its skills (its channel server may be too old)" }) }] }
|
|
3251
|
+
: { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "rules_file_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve rules files (its channel server may be too old)" }) }] };
|
|
3252
|
+
}
|
|
3253
|
+
node = { node_id: `session:${session.alias}`, alias: session.alias, network_id: session.network_id };
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
const nodeId = node.node_id;
|
|
3257
|
+
if (op === "write") {
|
|
3258
|
+
if (typeof a.content !== "string") {
|
|
3259
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_content", reason: "content must be a string" }) }] };
|
|
3260
|
+
}
|
|
3261
|
+
const bytes = Buffer.byteLength(a.content, "utf8");
|
|
3262
|
+
if (bytes > RULES_FILE_MAX_BYTES) {
|
|
3263
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "content_too_large", bytes, max_bytes: RULES_FILE_MAX_BYTES }) }] };
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
// 单飞 + 陈旧回收,同 update_node_config 的 F-B:节点掉线时旧行永远
|
|
3268
|
+
// 非终态,不回收就把这个节点锁死。
|
|
3269
|
+
// Separate single-flight lanes: the client opens the rules file and the
|
|
3270
|
+
// skills list together; one must not block the other.
|
|
3271
|
+
const inFlight = db.get<{ request_id: string; created_at: number; pulled_at: number | null }>(
|
|
3272
|
+
isFilesOp(op)
|
|
3273
|
+
? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('files_list', 'file_read') ORDER BY created_at DESC LIMIT 1"
|
|
3274
|
+
: isSkillsOp(op)
|
|
3275
|
+
? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('skills_list', 'skill_read') ORDER BY created_at DESC LIMIT 1"
|
|
3276
|
+
: "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('read', 'write') ORDER BY created_at DESC LIMIT 1",
|
|
3277
|
+
nodeId,
|
|
3278
|
+
);
|
|
3279
|
+
if (inFlight) {
|
|
3280
|
+
const age = Date.now() - (inFlight.pulled_at ?? inFlight.created_at);
|
|
3281
|
+
if (age <= RULES_REQUEST_STALE_MS) {
|
|
3282
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_in_flight", existing_request_id: inFlight.request_id, age_ms: age }) }] };
|
|
3283
|
+
}
|
|
3284
|
+
db.run(
|
|
3285
|
+
"UPDATE node_rules_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3",
|
|
3286
|
+
[Date.now(), `superseded after ${age}ms (> ${RULES_REQUEST_STALE_MS}ms) — node did not answer`, inFlight.request_id],
|
|
3287
|
+
);
|
|
3288
|
+
}
|
|
3289
|
+
|
|
3290
|
+
// Opportunistic content retention (bounded, indexed) — node-request-retention.ts.
|
|
3291
|
+
sweepNodeRequestContent();
|
|
3292
|
+
const requestId = `rf_${uuidv4()}`;
|
|
3293
|
+
const networkId = node.network_id || "default";
|
|
3294
|
+
db.run(
|
|
3295
|
+
`INSERT INTO node_rules_requests (request_id, node_id, network_id, op, content, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)`,
|
|
3296
|
+
[requestId, nodeId, networkId, op, op === "write" || op === "skill_read" || isFilesOp(op) ? a.content! : null, Date.now(), callerTokenId || "unknown"],
|
|
3297
|
+
);
|
|
3298
|
+
pushEvent(node.alias, { type: "rules_file", request_id: requestId }, networkId);
|
|
3299
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, op }) }] };
|
|
3300
|
+
};
|
|
3301
|
+
|
|
3302
|
+
server.tool(
|
|
3303
|
+
"read_node_rules_file",
|
|
3304
|
+
"Ask a node to send back its rules file (CLAUDE.md for claude nodes, AGENTS.md otherwise) from its working directory. No path argument by design. Poll get_rules_file_result with the returned request_id. app#225.",
|
|
3305
|
+
{
|
|
3306
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3307
|
+
alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported rules_file_capable (claude-code sessions)."),
|
|
3308
|
+
network_id: z.string().max(200).optional(),
|
|
3309
|
+
},
|
|
3310
|
+
async ({ node_id, child_node_id, alias, network_id }) => enqueueRulesFileRequest("read", { node_id, child_node_id, alias, network_id }),
|
|
3311
|
+
);
|
|
3312
|
+
|
|
3313
|
+
server.tool(
|
|
3314
|
+
"write_node_rules_file",
|
|
3315
|
+
"Ask a node to overwrite its rules file (CLAUDE.md for claude nodes, AGENTS.md otherwise) in its working directory with `content`. No path argument by design. Poll get_rules_file_result with the returned request_id. app#225.",
|
|
3316
|
+
{
|
|
3317
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3318
|
+
alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported rules_file_capable (claude-code sessions)."),
|
|
3319
|
+
content: z.string().max(RULES_FILE_MAX_BYTES).describe("Full new file content (UTF-8). The node writes it atomically."),
|
|
3320
|
+
network_id: z.string().max(200).optional(),
|
|
3321
|
+
},
|
|
3322
|
+
async ({ node_id, child_node_id, alias, content, network_id }) => enqueueRulesFileRequest("write", { node_id, child_node_id, alias, content, network_id }),
|
|
3323
|
+
);
|
|
3324
|
+
|
|
3325
|
+
server.tool(
|
|
3326
|
+
"list_node_skills",
|
|
3327
|
+
"Ask a node to list the skills its runtime loads (name, scope project|user|system, display path, frontmatter description). Read-only; no path argument. Poll get_rules_file_result — content is JSON {skills:[…]}.",
|
|
3328
|
+
{
|
|
3329
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3330
|
+
alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported skills_capable."),
|
|
3331
|
+
network_id: z.string().max(200).optional(),
|
|
3332
|
+
},
|
|
3333
|
+
async ({ node_id, child_node_id, alias, network_id }) => enqueueRulesFileRequest("skills_list", { node_id, child_node_id, alias, network_id }),
|
|
3334
|
+
);
|
|
3335
|
+
|
|
3336
|
+
server.tool(
|
|
3337
|
+
"read_node_skill",
|
|
3338
|
+
"Ask a node for one skill's SKILL.md by name. Read-only; the node resolves the name under its own runtime's skills roots — no path argument. Poll get_rules_file_result — content is JSON {name, scope, path_rel, description, content}.",
|
|
3339
|
+
{
|
|
3340
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3341
|
+
alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported skills_capable."),
|
|
3342
|
+
name: z.string().min(1).max(64).describe("Skill directory name, [A-Za-z0-9._-]."),
|
|
3343
|
+
network_id: z.string().max(200).optional(),
|
|
3344
|
+
},
|
|
3345
|
+
async ({ node_id, child_node_id, alias, name, network_id }) => enqueueRulesFileRequest("skill_read", { node_id, child_node_id, alias, content: name, network_id }),
|
|
3346
|
+
);
|
|
3347
|
+
|
|
3348
|
+
server.tool(
|
|
3349
|
+
"list_node_files",
|
|
3350
|
+
"Ask a node to list one directory level of its work dir (project folder view, read-only). `path` is relative to the work dir (default: the root); absolute paths and .. are refused. Poll get_rules_file_result — content is JSON {path, entries:[{name,type,size?,mtime?,hidden_reason?,no_descend?}], truncated, total}. User logins only.",
|
|
3351
|
+
{
|
|
3352
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3353
|
+
alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported files_capable."),
|
|
3354
|
+
path: z.string().max(NODE_FILE_PATH_MAX).optional().describe("Directory relative to the node's work dir; omit or \"\" for the root."),
|
|
3355
|
+
network_id: z.string().max(200).optional(),
|
|
3356
|
+
},
|
|
3357
|
+
async ({ node_id, child_node_id, alias, path: relPath, network_id }) => enqueueRulesFileRequest("files_list", { node_id, child_node_id, alias, content: relPath ?? "", network_id }),
|
|
3358
|
+
);
|
|
3359
|
+
|
|
3360
|
+
server.tool(
|
|
3361
|
+
"read_node_file",
|
|
3362
|
+
"Ask a node for one text file under its work dir (project folder view, read-only, 256 KiB cap; binary / too large → size only; secret-looking files → name only). `path` is relative to the work dir. Poll get_rules_file_result — content is JSON {path, name, kind, size?, mtime?, content?, hidden_reason?}. User logins only.",
|
|
3363
|
+
{
|
|
3364
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3365
|
+
alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported files_capable."),
|
|
3366
|
+
path: z.string().min(1).max(NODE_FILE_PATH_MAX).describe("File path relative to the node's work dir."),
|
|
3367
|
+
network_id: z.string().max(200).optional(),
|
|
3368
|
+
},
|
|
3369
|
+
async ({ node_id, child_node_id, alias, path: relPath, network_id }) => enqueueRulesFileRequest("file_read", { node_id, child_node_id, alias, content: relPath, network_id }),
|
|
3370
|
+
);
|
|
3371
|
+
|
|
3372
|
+
server.tool(
|
|
3373
|
+
"get_rules_file_request",
|
|
3374
|
+
"Node pulls its oldest pending rules-file request (called from agent-node when the SSE rules_file doorbell arrives). app#225.",
|
|
3375
|
+
{},
|
|
3376
|
+
async () => {
|
|
3377
|
+
// 同 get_config_update 的 F-A 闸:必须是网络 token + alias。
|
|
3378
|
+
if (!callerTokenIsNetwork || !enforceNetworkId) {
|
|
3379
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
|
|
3380
|
+
}
|
|
3381
|
+
if (!callerAlias) {
|
|
3382
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
|
|
3383
|
+
}
|
|
3384
|
+
const node = resolveCallerNode();
|
|
3385
|
+
// app#225 follow-up — no `nodes` row: this is a session-only target
|
|
3386
|
+
// (claude-code). Its queue key is `session:<alias>`, always paired with the
|
|
3387
|
+
// caller's network so a same-named alias in another network never matches.
|
|
3388
|
+
const queueKey: string = node ? node.node_id : `session:${callerAlias}`;
|
|
3389
|
+
const req = db.get<any>(
|
|
3390
|
+
"SELECT request_id, op, content FROM node_rules_requests WHERE node_id = ?1 AND network_id = ?2 AND status = 'pending' ORDER BY created_at ASC LIMIT 1",
|
|
3391
|
+
queueKey,
|
|
3392
|
+
enforceNetworkId,
|
|
3393
|
+
);
|
|
3394
|
+
if (!req) {
|
|
3395
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request: null }) }] };
|
|
3396
|
+
}
|
|
3397
|
+
db.run(
|
|
3398
|
+
"UPDATE node_rules_requests SET status = 'in_progress', pulled_at = ?1 WHERE request_id = ?2 AND status = 'pending'",
|
|
3399
|
+
[Date.now(), req.request_id],
|
|
3400
|
+
);
|
|
3401
|
+
return {
|
|
3402
|
+
content: [{
|
|
3403
|
+
type: "text" as const,
|
|
3404
|
+
text: JSON.stringify({
|
|
3405
|
+
ok: true,
|
|
3406
|
+
request: {
|
|
3407
|
+
request_id: req.request_id,
|
|
3408
|
+
op: req.op,
|
|
3409
|
+
...(req.op === "write" || req.op === "skill_read" || isFilesOp(req.op) ? { content: req.content ?? "" } : {}),
|
|
3410
|
+
},
|
|
3411
|
+
}),
|
|
3412
|
+
}],
|
|
3413
|
+
};
|
|
3414
|
+
},
|
|
3415
|
+
);
|
|
3416
|
+
|
|
3417
|
+
server.tool(
|
|
3418
|
+
"ack_rules_file_request",
|
|
3419
|
+
"Node reports the outcome of a rules-file request — done (with content for reads) or failed (with error). app#225.",
|
|
3420
|
+
{
|
|
3421
|
+
request_id: z.string().min(1).max(200),
|
|
3422
|
+
status: z.enum(["done", "failed"]),
|
|
3423
|
+
file_name: z.string().max(64).optional(),
|
|
3424
|
+
exists: z.boolean().optional(),
|
|
3425
|
+
// Files results (JSON listing / a 256 KiB file, escaped) may exceed the rules
|
|
3426
|
+
// cap; the per-op cap is enforced below once the row's op is known.
|
|
3427
|
+
content: z.string().max(NODE_FILES_RESULT_MAX_CHARS).optional(),
|
|
3428
|
+
error: z.string().max(2000).optional(),
|
|
3429
|
+
},
|
|
3430
|
+
async ({ request_id: requestId, status, file_name: fileName, exists, content, error: ackError }) => {
|
|
3431
|
+
if (!callerTokenIsNetwork || !enforceNetworkId) {
|
|
3432
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
|
|
3433
|
+
}
|
|
3434
|
+
if (!callerAlias) {
|
|
3435
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
|
|
3436
|
+
}
|
|
3437
|
+
const node = resolveCallerNode();
|
|
3438
|
+
const queueKey: string = node ? node.node_id : `session:${callerAlias}`;
|
|
3439
|
+
const req = db.get<any>(
|
|
3440
|
+
"SELECT request_id, node_id, network_id, status, op FROM node_rules_requests WHERE request_id = ?1",
|
|
3441
|
+
requestId,
|
|
3442
|
+
);
|
|
3443
|
+
// 跨租户闸:只能 ack 自己的请求;别人的当不存在处理。session 键另外核网络。
|
|
3444
|
+
if (!req || req.node_id !== queueKey || req.network_id !== enforceNetworkId) {
|
|
3445
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "unknown_or_foreign_request" }) }] };
|
|
3446
|
+
}
|
|
3447
|
+
if (req.status === "done" || req.status === "failed" || req.status === "timeout") {
|
|
3448
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "already_terminal", current_status: req.status }) }] };
|
|
3449
|
+
}
|
|
3450
|
+
if (typeof content === "string" && !isFilesOp(req.op) && content.length > RULES_FILE_MAX_BYTES) {
|
|
3451
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "content_too_large", max: RULES_FILE_MAX_BYTES }) }] };
|
|
3452
|
+
}
|
|
3453
|
+
db.run(
|
|
3454
|
+
`UPDATE node_rules_requests SET status = ?1, acked_at = ?2, file_name = ?3, file_exists = ?4, result_content = ?5, error = ?6 WHERE request_id = ?7`,
|
|
3455
|
+
[
|
|
3456
|
+
status,
|
|
3457
|
+
Date.now(),
|
|
3458
|
+
fileName ?? null,
|
|
3459
|
+
typeof exists === "boolean" ? (exists ? 1 : 0) : null,
|
|
3460
|
+
status === "done" ? (content ?? null) : null,
|
|
3461
|
+
status === "failed" ? (ackError || "node reported failure without a reason") : null,
|
|
3462
|
+
requestId,
|
|
3463
|
+
],
|
|
3464
|
+
);
|
|
3465
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, status }) }] };
|
|
3466
|
+
},
|
|
3467
|
+
);
|
|
3468
|
+
|
|
3469
|
+
server.tool(
|
|
3470
|
+
"get_rules_file_result",
|
|
3471
|
+
"Poll the outcome of a read_node_rules_file / write_node_rules_file request. Returns status pending|in_progress|done|failed|timeout, the file name the node used, and (for reads) the content. app#225.",
|
|
3472
|
+
{ request_id: z.string().min(1).max(200), network_id: z.string().max(200).optional() },
|
|
3473
|
+
async ({ request_id: requestId, network_id: clientNetId }) => {
|
|
3474
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
3475
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "read rules file result");
|
|
3476
|
+
const row = db.get<any>(
|
|
3477
|
+
"SELECT request_id, node_id, network_id, op, status, file_name, file_exists, result_content, error, created_at, pulled_at, acked_at, created_by_token FROM node_rules_requests WHERE request_id = ?1",
|
|
3478
|
+
requestId,
|
|
3479
|
+
);
|
|
3480
|
+
// SEC-1:结果行的网络必须等于调用方作用域,否则当不存在。
|
|
3481
|
+
// Project folder results: only the (user) token that asked may read them.
|
|
3482
|
+
const foreignFilesRow = !!row && isFilesOp(row.op) && (callerTokenIsNetwork || row.created_by_token !== (callerTokenId || "unknown"));
|
|
3483
|
+
if (!row || row.network_id !== (effectiveNetId || "default") || foreignFilesRow) {
|
|
3484
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found", request_id: requestId }) }] };
|
|
3485
|
+
}
|
|
3486
|
+
let status: string = row.status;
|
|
3487
|
+
const ageMs = Date.now() - (row.pulled_at ?? row.created_at);
|
|
3488
|
+
if ((status === "pending" || status === "in_progress") && ageMs > RULES_REQUEST_STALE_MS) {
|
|
3489
|
+
status = "timeout";
|
|
3490
|
+
db.run(
|
|
3491
|
+
"UPDATE node_rules_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3 AND status IN ('pending', 'in_progress')",
|
|
3492
|
+
[Date.now(), `node did not answer within ${RULES_REQUEST_STALE_MS}ms (offline, or running an agent-node without app#225 support)`, requestId],
|
|
3493
|
+
);
|
|
3494
|
+
row.error = `node did not answer within ${RULES_REQUEST_STALE_MS}ms (offline, or running an agent-node without app#225 support)`;
|
|
3495
|
+
}
|
|
3496
|
+
// Privacy: node file bytes are handed out for a short grace window after the
|
|
3497
|
+
// first terminal read, then purged (node-request-retention.ts). Only reached
|
|
3498
|
+
// after the SEC-1 scope check above — a foreign caller can't stamp/purge.
|
|
3499
|
+
const terminal = status === "done" || status === "failed" || status === "timeout";
|
|
3500
|
+
const purged = terminal ? noteTerminalResultRead(requestId).purged : false;
|
|
3501
|
+
return {
|
|
3502
|
+
content: [{
|
|
3503
|
+
type: "text" as const,
|
|
3504
|
+
text: JSON.stringify({
|
|
3505
|
+
ok: true,
|
|
3506
|
+
request_id: row.request_id,
|
|
3507
|
+
node_id: row.node_id,
|
|
3508
|
+
op: row.op,
|
|
3509
|
+
status,
|
|
3510
|
+
file_name: row.file_name ?? null,
|
|
3511
|
+
exists: row.file_exists === null || row.file_exists === undefined ? null : row.file_exists === 1,
|
|
3512
|
+
...(status === "done" && row.op !== "write" && !purged ? { content: row.result_content ?? "" } : {}),
|
|
3513
|
+
...(purged ? { content_purged: true } : {}),
|
|
3514
|
+
error: row.error ?? null,
|
|
3515
|
+
age_ms: ageMs,
|
|
3516
|
+
}),
|
|
3517
|
+
}],
|
|
3518
|
+
};
|
|
3519
|
+
},
|
|
3520
|
+
);
|
|
3521
|
+
|
|
3522
|
+
|
|
1746
3523
|
server.tool(
|
|
1747
3524
|
"restart_node",
|
|
1748
3525
|
"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).",
|
|
1749
3526
|
{
|
|
1750
|
-
|
|
3527
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
1751
3528
|
network_id: z.string().max(200).optional(),
|
|
1752
3529
|
},
|
|
1753
|
-
async ({ node_id
|
|
3530
|
+
async ({ node_id, child_node_id, network_id: clientNetId }) => {
|
|
3531
|
+
const idArg = resolveNodeIdArg({ node_id, child_node_id });
|
|
3532
|
+
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
3533
|
+
const nodeId = idArg.node_id;
|
|
1754
3534
|
const effectiveNetId = getNetworkId(clientNetId);
|
|
1755
3535
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
1756
3536
|
|
|
@@ -1782,10 +3562,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1782
3562
|
}
|
|
1783
3563
|
const updateId = `cu_${uuidv4()}`;
|
|
1784
3564
|
const networkId = node.network_id || "default";
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
3565
|
+
// RFC-027 PR1.2a latent fix (#346 ack): restart_node must reset
|
|
3566
|
+
// lifecycle_state to 'active'. Otherwise a node that was previously
|
|
3567
|
+
// stop_node'd → 'stopped' would, after restart, stay marked
|
|
3568
|
+
// 'stopped' in the nodes table → the 6 MCP + 2 REST inbox guards
|
|
3569
|
+
// would refuse every routing attempt → node silently unreachable
|
|
3570
|
+
// (no error to operator, just no traffic). Pair the schema flip
|
|
3571
|
+
// with the config_updates INSERT inside one tx so we don't
|
|
3572
|
+
// half-commit if the dispatch INSERT throws.
|
|
3573
|
+
db.transaction(() => {
|
|
3574
|
+
db.run(
|
|
3575
|
+
`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)`,
|
|
3576
|
+
[updateId, nodeId, networkId, node.config_revision || 0, Date.now(), callerTokenId || "unknown"],
|
|
3577
|
+
);
|
|
3578
|
+
db.run(
|
|
3579
|
+
`UPDATE nodes SET lifecycle_state = 'active' WHERE node_id = ?1`,
|
|
3580
|
+
[nodeId],
|
|
3581
|
+
);
|
|
3582
|
+
});
|
|
1789
3583
|
pushEvent(node.alias, { type: "restart", update_id: updateId }, networkId);
|
|
1790
3584
|
return {
|
|
1791
3585
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update_id: updateId, apply_mode: "restart_only" }) }],
|
|
@@ -1818,6 +3612,143 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1818
3612
|
throw e;
|
|
1819
3613
|
};
|
|
1820
3614
|
|
|
3615
|
+
// RFC-026 §9.2.1 / #338 PR2 — list_host_supervisors.
|
|
3616
|
+
// Surfaces host_supervisor daemons in the caller's network with
|
|
3617
|
+
// online flag + declared capabilities. Replaces the prior `node_daemon_`
|
|
3618
|
+
// prefix heuristic (dashboard /api/anet/node-create) + the role-extract
|
|
3619
|
+
// path on /api/nodes (#337). Member脱敏 strips host_telemetry IP /
|
|
3620
|
+
// cpu / mem (per RFC-026 §6 #3 — daemons can leak internal topology
|
|
3621
|
+
// to non-admin readers). Revoked daemon ntoks filtered out via
|
|
3622
|
+
// join on api_tokens.revoked_at.
|
|
3623
|
+
server.tool(
|
|
3624
|
+
"list_host_supervisors",
|
|
3625
|
+
"List host_supervisor daemon nodes in the caller's network (online status + runtimes_supported + allowed_secret_keys + telemetry; member-脱敏). RFC-026 §9.2.1.",
|
|
3626
|
+
{
|
|
3627
|
+
network_id: z.string().max(200).optional(),
|
|
3628
|
+
},
|
|
3629
|
+
async ({ network_id: clientNetId }) => {
|
|
3630
|
+
// SEC-1 — use resolveReadScope, NOT getNetworkId. getNetworkId is
|
|
3631
|
+
// for write-tool helpers and pairs with `canWrite`; READ tools that
|
|
3632
|
+
// bypass canWrite and trust getNetworkId leak across tenants
|
|
3633
|
+
// (PR2 v1 BLOCKER per 通信龙 audit — utok_ caller with
|
|
3634
|
+
// enforceNetworkId=null could pass any network_id and read daemon
|
|
3635
|
+
// names + allowed_secret_keys for tenants they're not a member of).
|
|
3636
|
+
// resolveReadScope checks network_members for the user + denies
|
|
3637
|
+
// on non-membership, mirroring REST resolveRestNetworkScope.
|
|
3638
|
+
const readScope = resolveReadScope(clientNetId);
|
|
3639
|
+
if (readScope.denied) {
|
|
3640
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
3641
|
+
}
|
|
3642
|
+
// Caller role for 脱敏 decision (admin/owner = full, others = masked).
|
|
3643
|
+
// Use the resolved scope's networkId (the verified one), not the
|
|
3644
|
+
// unchecked clientNetId.
|
|
3645
|
+
const scopedNetId = readScope.networkId ?? null;
|
|
3646
|
+
const callerRole = enforceUserId && scopedNetId
|
|
3647
|
+
? getUserNetworkRole(enforceUserId, scopedNetId)
|
|
3648
|
+
: null;
|
|
3649
|
+
// Network-bound tokens get full telemetry within their bound network
|
|
3650
|
+
// (already gate-locked by enforceNetworkId path of resolveReadScope).
|
|
3651
|
+
const isPrivileged = callerRole === "admin" || callerRole === "owner" || (!enforceUserId);
|
|
3652
|
+
|
|
3653
|
+
// Pull candidate daemons. Active-token EXISTS subquery handles BOTH
|
|
3654
|
+
// revoked daemon ntoks (revoked_at set) AND DELETEd token rows
|
|
3655
|
+
// (the standard revokeToken path deletes the row, not flagging
|
|
3656
|
+
// revoked_at — PR2 v1 SHOULD-FIX per 通信龙 audit). EXISTS naturally
|
|
3657
|
+
// dedupes if a daemon ever had multiple tokens (e.g. rotation; nit ⚪).
|
|
3658
|
+
let sql = `
|
|
3659
|
+
SELECT
|
|
3660
|
+
n.node_id, n.alias, n.hostname, n.network_id,
|
|
3661
|
+
n.runtimes_supported, n.allowed_secret_keys,
|
|
3662
|
+
n.created_at, n.updated_at,
|
|
3663
|
+
s.last_seen_at AS session_last_seen,
|
|
3664
|
+
s.status AS session_status,
|
|
3665
|
+
s.cpu_cores AS session_cpu_cores,
|
|
3666
|
+
s.mem_total_gb AS session_mem_total_gb,
|
|
3667
|
+
s.ip AS session_ip,
|
|
3668
|
+
n.config_snapshot
|
|
3669
|
+
FROM nodes n
|
|
3670
|
+
LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
|
|
3671
|
+
WHERE EXISTS (
|
|
3672
|
+
SELECT 1 FROM api_tokens t
|
|
3673
|
+
WHERE t.network_id = n.network_id
|
|
3674
|
+
AND t.name = 'node:' || n.alias
|
|
3675
|
+
AND t.revoked_at IS NULL
|
|
3676
|
+
)
|
|
3677
|
+
`;
|
|
3678
|
+
const sqlParams: any[] = [];
|
|
3679
|
+
sql = addReadScope(sql, sqlParams, readScope, "n.network_id");
|
|
3680
|
+
sql += ` ORDER BY n.updated_at DESC`;
|
|
3681
|
+
const rows = db.all<Record<string, any>>(sql, ...sqlParams);
|
|
3682
|
+
|
|
3683
|
+
// Filter to role=host_supervisor (read from config_snapshot;
|
|
3684
|
+
// schema-promoted columns runtimes_supported/allowed_secret_keys
|
|
3685
|
+
// are pre-extracted but role isn't a first-class column).
|
|
3686
|
+
const nowMs = Date.now();
|
|
3687
|
+
// 心跳周期是 3 分钟(agent-node/src/cli.ts 的 report_status 定时器),
|
|
3688
|
+
// 窗口必须大于它,否则每次心跳后的 60s~180s 之间必然抖成 offline
|
|
3689
|
+
// (通信评审牛 #1279 独审④)。取 5min,与仓内既有 stale 口径一致。
|
|
3690
|
+
const ONLINE_MS = 5 * 60_000;
|
|
3691
|
+
const daemons = rows
|
|
3692
|
+
.map(r => {
|
|
3693
|
+
let snapRole: string | null = null;
|
|
3694
|
+
if (r.config_snapshot) {
|
|
3695
|
+
try {
|
|
3696
|
+
const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
|
|
3697
|
+
snapRole = typeof parsed?.role === "string" ? parsed.role : null;
|
|
3698
|
+
} catch { /* malformed snapshot — role stays null */ }
|
|
3699
|
+
}
|
|
3700
|
+
return { row: r, role: snapRole };
|
|
3701
|
+
})
|
|
3702
|
+
.filter(({ role }) => role === "host_supervisor")
|
|
3703
|
+
.map(({ row: r }) => {
|
|
3704
|
+
// online = sessions.last_seen_at within ONLINE_MS
|
|
3705
|
+
let online = false;
|
|
3706
|
+
let lastSeenAt: string | null = null;
|
|
3707
|
+
if (r.session_last_seen) {
|
|
3708
|
+
lastSeenAt = r.session_last_seen;
|
|
3709
|
+
const t = parseDbTimestampMs(r.session_last_seen);
|
|
3710
|
+
if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
|
|
3711
|
+
}
|
|
3712
|
+
// Parse self-declare arrays (default to [] for pre-PR2 daemons)
|
|
3713
|
+
let runtimes: string[] = [];
|
|
3714
|
+
let secrets: string[] = [];
|
|
3715
|
+
try {
|
|
3716
|
+
if (r.runtimes_supported) {
|
|
3717
|
+
const parsed = JSON.parse(r.runtimes_supported);
|
|
3718
|
+
runtimes = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
|
|
3719
|
+
}
|
|
3720
|
+
} catch { /* malformed — empty */ }
|
|
3721
|
+
try {
|
|
3722
|
+
if (r.allowed_secret_keys) {
|
|
3723
|
+
const parsed = JSON.parse(r.allowed_secret_keys);
|
|
3724
|
+
secrets = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
|
|
3725
|
+
}
|
|
3726
|
+
} catch { /* malformed — empty */ }
|
|
3727
|
+
// host_telemetry — member脱敏 drops IP/cpu/mem
|
|
3728
|
+
const telemetry: Record<string, unknown> = {
|
|
3729
|
+
alert_level: online ? "green" : "gray",
|
|
3730
|
+
};
|
|
3731
|
+
if (isPrivileged) {
|
|
3732
|
+
telemetry.cpu_cores = r.session_cpu_cores ?? null;
|
|
3733
|
+
telemetry.mem_gb = r.session_mem_total_gb ?? null;
|
|
3734
|
+
telemetry.ip_internal = r.session_ip ?? null;
|
|
3735
|
+
}
|
|
3736
|
+
return {
|
|
3737
|
+
daemon_node_id: r.node_id,
|
|
3738
|
+
alias: r.alias,
|
|
3739
|
+
hostname: r.hostname,
|
|
3740
|
+
online,
|
|
3741
|
+
last_seen_at: lastSeenAt,
|
|
3742
|
+
runtimes_supported: runtimes,
|
|
3743
|
+
allowed_secret_keys: secrets,
|
|
3744
|
+
host_telemetry: telemetry,
|
|
3745
|
+
};
|
|
3746
|
+
});
|
|
3747
|
+
|
|
3748
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, daemons, count: daemons.length }) }] };
|
|
3749
|
+
},
|
|
3750
|
+
);
|
|
3751
|
+
|
|
1821
3752
|
// §2.5 step 1+2 — dashboard-facing tool. Validates spec, mint
|
|
1822
3753
|
// child-ntok, stash env_blob in pendingEnvBlobs Map, write request
|
|
1823
3754
|
// row (metadata only — no env_blob in SQL), pushEvent doorbell.
|
|
@@ -1829,8 +3760,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1829
3760
|
node_spec: z.object({
|
|
1830
3761
|
name: z.string().min(1).max(64),
|
|
1831
3762
|
runtime: z.string().min(1).max(64),
|
|
1832
|
-
model: z.string().min(1).max(100),
|
|
1833
|
-
flags: z.record(z.unknown()).optional(),
|
|
3763
|
+
model: z.string().min(1).max(100).optional().nullable(),
|
|
3764
|
+
flags: z.record(z.string(), z.unknown()).optional(),
|
|
1834
3765
|
env_refs: z.array(z.string().max(64)).optional(),
|
|
1835
3766
|
channels: z.array(z.unknown()).optional(),
|
|
1836
3767
|
}),
|
|
@@ -1859,22 +3790,115 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1859
3790
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
|
|
1860
3791
|
}
|
|
1861
3792
|
|
|
3793
|
+
// #1353 Fix ① — daemon self-declared "I can't create nodes right now"
|
|
3794
|
+
// is now a dispatch-time gate, not just a stored fact. Before this,
|
|
3795
|
+
// daemon reported `daemon_capabilities.can_create_nodes=false` (via
|
|
3796
|
+
// #1371 + #1377), hub stored it in config_snapshot, and then no
|
|
3797
|
+
// consumer read it — dispatch still returned `{ok:true, request_id}`
|
|
3798
|
+
// and pushed an SSE doorbell to the broken daemon. That's the exact
|
|
3799
|
+
// "在线但静默失败" case the issue title reports.
|
|
3800
|
+
//
|
|
3801
|
+
// Vincent's own 2026-08-28 comment on #1353 authorizes this shape:
|
|
3802
|
+
// "daemon 侧确知不可用时,hub 应当**拒绝派发**并返回可操作的错误,
|
|
3803
|
+
// 而不是发一个注定失败的 doorbell".
|
|
3804
|
+
//
|
|
3805
|
+
// 🔴 Pre-#1371 compat: a daemon that has NOT reported
|
|
3806
|
+
// `can_create_nodes` at all leaves the field UNDEFINED in the
|
|
3807
|
+
// snapshot. Do NOT fail-closed on undefined — that would silently
|
|
3808
|
+
// start rejecting every daemon on the old (preview.10 through .55
|
|
3809
|
+
// roughly) shape. Only `=== false` gates; `=== true` and `undefined`
|
|
3810
|
+
// both pass through (new-guard-matches-existing-stance).
|
|
3811
|
+
try {
|
|
3812
|
+
const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
|
|
3813
|
+
const caps = snap?.daemon_capabilities;
|
|
3814
|
+
if (caps && caps.can_create_nodes === false) {
|
|
3815
|
+
const reason: string = (typeof caps.create_nodes_blocked_reason === "string"
|
|
3816
|
+
&& caps.create_nodes_blocked_reason.length > 0)
|
|
3817
|
+
? caps.create_nodes_blocked_reason
|
|
3818
|
+
: "anet_bin_unknown";
|
|
3819
|
+
// #1545 —— 光说 `blocked_reason` 不够:**那个判断是什么时候做出来的**
|
|
3820
|
+
// 决定了下一步完全不同的两件事。
|
|
3821
|
+
// 刚测的 blocked ⇒ 去那台机器按 reason 修
|
|
3822
|
+
// 三周前测的 blocked ⇒ 那台 daemon 是**开机算一次就永久缓存**的旧版本
|
|
3823
|
+
// (agent-node ≤ 2.5.0-preview.54),它可能早就好了 ——
|
|
3824
|
+
// 先重启/升级它,再谈修 pin
|
|
3825
|
+
// 而在拒绝这一刻,用户正好在决定"去修哪台机器"。
|
|
3826
|
+
//
|
|
3827
|
+
// 🔴 年龄未知时给的是**显式 null + 一个说明为什么的枚举**,不是省略这两个键。
|
|
3828
|
+
// 省略读起来像"没有这个问题",而调用方(常常是个 agent)会默认它是刚测的
|
|
3829
|
+
// —— 那是朝「更可信」方向撒谎,比不给更糟。
|
|
3830
|
+
const rawAge = caps.create_capability_observed_ms_ago;
|
|
3831
|
+
const reportedAgeOk = typeof rawAge === "number" && Number.isFinite(rawAge)
|
|
3832
|
+
&& rawAge >= 0 && rawAge <= 365 * 24 * 60 * 60 * 1000;
|
|
3833
|
+
// 🔴 daemon 报的是「测完到**这份 report 发出**之间隔了多久」,不是「到现在」。
|
|
3834
|
+
// 两者差一个心跳间隔(3 分钟),而对一个已经失联的 daemon 差多少都可能。
|
|
3835
|
+
// 直接把它当成"多久以前测的"渲染,会**朝更新鲜的方向**低报 ——
|
|
3836
|
+
// 正是这一格要防的那个方向。所以在这里补上心跳那一段:
|
|
3837
|
+
// 绝对年龄 = (now − last_seen_at) + daemon 报的时长
|
|
3838
|
+
// hub 的钟出绝对时间,daemon 只提供时长(它自己的钟偏移污染不了这个数)。
|
|
3839
|
+
//
|
|
3840
|
+
// 这是冷路径(只有被拒时才走),多一次小查询不影响心跳路径。
|
|
3841
|
+
let heartbeatAgeMs: number | null = null;
|
|
3842
|
+
try {
|
|
3843
|
+
const sess = db.get<{ last_seen_at: string | null }>(
|
|
3844
|
+
"SELECT last_seen_at FROM sessions WHERE node_id = ?1 ORDER BY last_seen_at DESC LIMIT 1",
|
|
3845
|
+
daemon_node_id,
|
|
3846
|
+
);
|
|
3847
|
+
// #1650 — 这一列是 TEXT(`datetime('now')`),UTC 但不带时区标记。
|
|
3848
|
+
// Date.parse 会按**本机时区**解析,误差 = 主机偏移(生产 hub 在 UTC+8
|
|
3849
|
+
// 时这个诊断数字整整偏 8 小时)。走 parseHubTimestamp 显式按 UTC 解。
|
|
3850
|
+
const t = parseHubTimestamp(sess?.last_seen_at);
|
|
3851
|
+
if (t !== null) heartbeatAgeMs = Math.max(0, Date.now() - t);
|
|
3852
|
+
} catch { /* 取不到就是取不到 —— 下面如实说 unknown,不猜 0 */ }
|
|
3853
|
+
|
|
3854
|
+
// 🔴 三种取值,不能塌成两种:
|
|
3855
|
+
// known 两半都有,可以给绝对年龄
|
|
3856
|
+
// unknown_legacy_daemon 那台 daemon 根本没报这一格(旧版本)
|
|
3857
|
+
// unknown_no_heartbeat_time 它报了,但 hub 这边没有可用的心跳时间
|
|
3858
|
+
// ⇒ **补不出绝对年龄**,而不是"年龄是 0"
|
|
3859
|
+
const ageKnown = reportedAgeOk && heartbeatAgeMs !== null;
|
|
3860
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
3861
|
+
ok: false,
|
|
3862
|
+
error: "daemon_cannot_create_nodes",
|
|
3863
|
+
blocked_reason: reason,
|
|
3864
|
+
// 到**现在**为止,这个判断做出来有多久了(毫秒)。null = 补不出来。
|
|
3865
|
+
capability_observed_ms_ago: ageKnown ? heartbeatAgeMs! + (rawAge as number) : null,
|
|
3866
|
+
capability_age: ageKnown
|
|
3867
|
+
? "known"
|
|
3868
|
+
: (reportedAgeOk ? "unknown_no_heartbeat_time" : "unknown_legacy_daemon"),
|
|
3869
|
+
// 🔴 这里**不复制那张 code → 修法命令 的表**。它只有一个作者,在
|
|
3870
|
+
// `@sleep2agi/agent-network` 的 daemon-capability-display —— 抄一份到 hub,
|
|
3871
|
+
// 两份就会各自漂移,而「hub 教的修法」和「CLI 教的修法」不一致时,
|
|
3872
|
+
// 没有任何东西会红。这里只把人指到那一份。
|
|
3873
|
+
// (同 `blocked_reason` 是 z.enum 而不是自由文本的立场:hub 出**代码**,
|
|
3874
|
+
// 渲染留给客户端。)
|
|
3875
|
+
remediation_hint: "run `anet daemon list` where that daemon is configured — it prints the exact, paste-able fix command for this blocked_reason",
|
|
3876
|
+
daemon_node_id,
|
|
3877
|
+
}) }] };
|
|
3878
|
+
}
|
|
3879
|
+
} catch { /* malformed snapshot: permissive, mirrors L3230 fallthrough */ }
|
|
3880
|
+
|
|
1862
3881
|
// Read daemon's host_supervisor capability + allowlist from its
|
|
1863
|
-
// last reported config_snapshot.
|
|
1864
|
-
// (
|
|
1865
|
-
//
|
|
1866
|
-
//
|
|
3882
|
+
// last reported config_snapshot. PR3 (#338) canonical path is
|
|
3883
|
+
// `daemon_capabilities.runtimes_supported` (RFC-026 §9.3).
|
|
3884
|
+
// Pre-PR3 daemons (preview.10 and earlier) place these at the
|
|
3885
|
+
// TOP level of the snapshot rather than nested — those reads
|
|
3886
|
+
// return undefined here and `daemonAllowedRuntimes` stays null
|
|
3887
|
+
// (permissive — no allowlist enforcement); they fall back to
|
|
3888
|
+
// §4.2.2 structural validation only, identical to pre-PR3
|
|
3889
|
+
// behavior. No regression on in-flight daemons.
|
|
1867
3890
|
let daemonAllowList = new Set<string>();
|
|
1868
3891
|
let daemonAllowedRuntimes: string[] | null = null;
|
|
1869
3892
|
try {
|
|
1870
3893
|
const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
|
|
1871
|
-
|
|
1872
|
-
|
|
3894
|
+
const caps = snap?.daemon_capabilities;
|
|
3895
|
+
if (Array.isArray(caps?.allowed_secret_keys)) {
|
|
3896
|
+
daemonAllowList = new Set(caps.allowed_secret_keys);
|
|
1873
3897
|
}
|
|
1874
|
-
if (Array.isArray(
|
|
1875
|
-
daemonAllowedRuntimes =
|
|
3898
|
+
if (Array.isArray(caps?.runtimes_supported)) {
|
|
3899
|
+
daemonAllowedRuntimes = caps.runtimes_supported;
|
|
1876
3900
|
}
|
|
1877
|
-
} catch { /* permissive
|
|
3901
|
+
} catch { /* permissive fallback */ }
|
|
1878
3902
|
|
|
1879
3903
|
// §4.2.2 — structural validation (catches name/runtime/model/
|
|
1880
3904
|
// flag injection at the hub edge). Daemon repeats this; double
|
|
@@ -1980,7 +4004,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1980
4004
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
|
|
1981
4005
|
[
|
|
1982
4006
|
requestId, daemon_node_id, node_spec.name, networkIdForChild,
|
|
1983
|
-
node_spec.runtime, node_spec.model, JSON.stringify(node_spec.flags || {}),
|
|
4007
|
+
node_spec.runtime, node_spec.model ?? null, JSON.stringify(node_spec.flags || {}),
|
|
1984
4008
|
JSON.stringify(envKeys), childTokenId, Date.now(), callerTokenId || "unknown",
|
|
1985
4009
|
],
|
|
1986
4010
|
);
|
|
@@ -1990,9 +4014,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1990
4014
|
// SSE handler resolves the rest via MCP call.
|
|
1991
4015
|
pushEvent(daemon.alias, { type: "create_node", request_id: requestId }, networkIdForChild);
|
|
1992
4016
|
|
|
1993
|
-
// §4.5 audit —
|
|
4017
|
+
// §4.5 audit — request queued and doorbell attempted. pushEvent is
|
|
4018
|
+
// fire-and-forget, so actual SSE delivery is intentionally not claimed.
|
|
1994
4019
|
auditCreateNode({
|
|
1995
|
-
action: "
|
|
4020
|
+
action: "create_node_dispatch_attempted",
|
|
1996
4021
|
user_id: enforceUserId,
|
|
1997
4022
|
network_id: networkIdForChild,
|
|
1998
4023
|
target_id: requestId,
|
|
@@ -2000,7 +4025,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2000
4025
|
daemon_node_id,
|
|
2001
4026
|
child_name: node_spec.name,
|
|
2002
4027
|
runtime: node_spec.runtime,
|
|
2003
|
-
model: node_spec.model,
|
|
4028
|
+
model: node_spec.model ?? null,
|
|
2004
4029
|
flag_keys: Object.keys(node_spec.flags || {}),
|
|
2005
4030
|
env_keys: envKeys,
|
|
2006
4031
|
},
|
|
@@ -2012,6 +4037,78 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2012
4037
|
},
|
|
2013
4038
|
);
|
|
2014
4039
|
|
|
4040
|
+
// #1362 — daemon-side compensation for missed SSE doorbells. pushEvent is
|
|
4041
|
+
// intentionally best-effort; after a daemon connects or reconnects, it pulls
|
|
4042
|
+
// any still-pending requests bound to its own daemon token.
|
|
4043
|
+
server.tool(
|
|
4044
|
+
"list_my_pending_create_requests",
|
|
4045
|
+
"Daemon lists pending create-node requests bound to itself for SSE reconnect compensation. RFC-026/#1362.",
|
|
4046
|
+
{},
|
|
4047
|
+
async () => {
|
|
4048
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
4049
|
+
if (!callerDaemon.ok) {
|
|
4050
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
4051
|
+
}
|
|
4052
|
+
const rows = db.all<{ request_id: string; child_name: string; created_at: number }>(
|
|
4053
|
+
`SELECT request_id, child_name, created_at
|
|
4054
|
+
FROM node_create_requests
|
|
4055
|
+
WHERE daemon_node_id = ?1
|
|
4056
|
+
AND network_id = ?2
|
|
4057
|
+
AND status = 'pending'
|
|
4058
|
+
ORDER BY created_at ASC`,
|
|
4059
|
+
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
4060
|
+
);
|
|
4061
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: rows.length, requests: rows }) }] };
|
|
4062
|
+
},
|
|
4063
|
+
);
|
|
4064
|
+
|
|
4065
|
+
// #1448 — stop/delete/start 门铃的 SSE 重连补偿,镜像 create 的
|
|
4066
|
+
// list_my_pending_create_requests(#1394)。pushEvent(push.ts) 是纯内存
|
|
4067
|
+
// fan-out:派发门铃那刻若 daemon 无 live SSE 订阅者(hub 重启/网络抖/boot
|
|
4068
|
+
// 窗口)就静默丢弃、不入队,于是 node_stop_requests/node_start_requests 的行
|
|
4069
|
+
// 永远停在 pending、节点卡死 stopping/deleting/starting。daemon 每次 SSE
|
|
4070
|
+
// connected 调本工具把漏掉的门铃拉回来重放(reconcilePendingLifecycleRequestsOnConnect)。
|
|
4071
|
+
//
|
|
4072
|
+
// 统一一个工具返回两张表的 pending 行(stop 表用 action 区分 stop/delete),
|
|
4073
|
+
// 省一次往返。鉴权同 create:token-bound daemon 身份(alias 不是安全边界),
|
|
4074
|
+
// 只返回绑定到本 daemon + 本网络的 pending 行。
|
|
4075
|
+
server.tool(
|
|
4076
|
+
"list_my_pending_lifecycle_requests",
|
|
4077
|
+
"Daemon lists pending stop/delete/start requests bound to itself for SSE reconnect compensation. Mirrors list_my_pending_create_requests (#1394/#1448).",
|
|
4078
|
+
{},
|
|
4079
|
+
async () => {
|
|
4080
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
4081
|
+
if (!callerDaemon.ok) {
|
|
4082
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
4083
|
+
}
|
|
4084
|
+
const stop_requests = db.all<{ request_id: string; action: string; child_node_id: string; child_alias: string; created_at: number }>(
|
|
4085
|
+
`SELECT request_id, action, child_node_id, child_alias, created_at
|
|
4086
|
+
FROM node_stop_requests
|
|
4087
|
+
WHERE daemon_node_id = ?1
|
|
4088
|
+
AND network_id = ?2
|
|
4089
|
+
AND status = 'pending'
|
|
4090
|
+
ORDER BY created_at ASC`,
|
|
4091
|
+
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
4092
|
+
);
|
|
4093
|
+
const start_requests = db.all<{ request_id: string; child_node_id: string; child_alias: string; created_at: number }>(
|
|
4094
|
+
`SELECT request_id, child_node_id, child_alias, created_at
|
|
4095
|
+
FROM node_start_requests
|
|
4096
|
+
WHERE daemon_node_id = ?1
|
|
4097
|
+
AND network_id = ?2
|
|
4098
|
+
AND status = 'pending'
|
|
4099
|
+
ORDER BY created_at ASC`,
|
|
4100
|
+
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
4101
|
+
);
|
|
4102
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
4103
|
+
ok: true,
|
|
4104
|
+
stop_count: stop_requests.length,
|
|
4105
|
+
start_count: start_requests.length,
|
|
4106
|
+
stop_requests,
|
|
4107
|
+
start_requests,
|
|
4108
|
+
}) }] };
|
|
4109
|
+
},
|
|
4110
|
+
);
|
|
4111
|
+
|
|
2015
4112
|
// §2.5 step 3 — daemon-facing pull. Returns full spec + env_blob +
|
|
2016
4113
|
// child_ntok in one shot. Map evicted on take (one-shot consume).
|
|
2017
4114
|
//
|
|
@@ -2064,7 +4161,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2064
4161
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "env_blob_unavailable" }) }] };
|
|
2065
4162
|
}
|
|
2066
4163
|
// Hydrate spec from row.
|
|
2067
|
-
const specRow = db.get<{ child_name: string; runtime: string; model: string; flags_json: string }>(
|
|
4164
|
+
const specRow = db.get<{ child_name: string; runtime: string; model: string | null; flags_json: string }>(
|
|
2068
4165
|
`SELECT child_name, runtime, model, flags_json FROM node_create_requests WHERE request_id = ?1`,
|
|
2069
4166
|
request_id,
|
|
2070
4167
|
);
|
|
@@ -2104,14 +4201,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2104
4201
|
// record fork-side info.
|
|
2105
4202
|
server.tool(
|
|
2106
4203
|
"ack_create_request",
|
|
2107
|
-
"Daemon acks a create-node request (called after fork). status='started'
|
|
4204
|
+
"Daemon acks a create-node request (called after fork). status='started' | 'failed' | 'rejected' | 'runtime_capability_check_failed'. RFC-026 §9.3 D2.",
|
|
2108
4205
|
{
|
|
2109
4206
|
request_id: z.string().min(1).max(200),
|
|
2110
|
-
|
|
4207
|
+
// RFC-026 §9.3 D2 — runtime_capability_check_failed signals the
|
|
4208
|
+
// daemon spawned the child OK but it died within FAIL_FAST_MS
|
|
4209
|
+
// (5s in agent-node v2.5.0-preview.11+), indicating a
|
|
4210
|
+
// declaration↔reality gap on this daemon's runtimes_supported.
|
|
4211
|
+
// Treated terminal like 'failed' but fires a distinct audit_log
|
|
4212
|
+
// action so dashboards can highlight "lying daemons" separately
|
|
4213
|
+
// from generic spawn failures.
|
|
4214
|
+
status: z.enum(["started", "failed", "rejected", "runtime_capability_check_failed"]),
|
|
2111
4215
|
error: z.string().max(1000).optional(),
|
|
2112
4216
|
child_pid: z.number().int().optional(),
|
|
4217
|
+
runtime: z.string().max(64).optional(), // populated by daemon when status=runtime_capability_check_failed
|
|
2113
4218
|
},
|
|
2114
|
-
async ({ request_id, status, error: ackError, child_pid: _pid }) => {
|
|
4219
|
+
async ({ request_id, status, error: ackError, child_pid: _pid, runtime: ackRuntime }) => {
|
|
2115
4220
|
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
2116
4221
|
if (!callerDaemon.ok) {
|
|
2117
4222
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
@@ -2137,7 +4242,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2137
4242
|
);
|
|
2138
4243
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "awaiting_register" }) }] };
|
|
2139
4244
|
}
|
|
2140
|
-
// failed / rejected — revoke
|
|
4245
|
+
// failed / rejected / runtime_capability_check_failed — revoke
|
|
4246
|
+
// child-ntok + mark request terminal.
|
|
2141
4247
|
if (row.child_token_id) {
|
|
2142
4248
|
db.run(`UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = ?1 AND revoked_at IS NULL`, [row.child_token_id]);
|
|
2143
4249
|
}
|
|
@@ -2145,10 +4251,817 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2145
4251
|
`UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3 WHERE request_id = ?4 AND status IN ('pending', 'delivered')`,
|
|
2146
4252
|
[status, ackError || null, ackedAt, request_id],
|
|
2147
4253
|
);
|
|
4254
|
+
// RFC-026 §9.3 D2 — surface declaration↔reality gap on a
|
|
4255
|
+
// distinct audit_log action so dashboards / operators can spot
|
|
4256
|
+
// chronically-lying daemons separate from generic spawn-failed.
|
|
4257
|
+
if (status === "runtime_capability_check_failed") {
|
|
4258
|
+
auditCreateNode({
|
|
4259
|
+
action: "daemon_capability_lied",
|
|
4260
|
+
user_id: null,
|
|
4261
|
+
network_id: row.network_id,
|
|
4262
|
+
target_id: request_id,
|
|
4263
|
+
detail: {
|
|
4264
|
+
daemon_node_id: row.daemon_node_id,
|
|
4265
|
+
runtime: ackRuntime || null,
|
|
4266
|
+
error: ackError ? ackError.slice(0, 500) : null,
|
|
4267
|
+
acked_at: ackedAt,
|
|
4268
|
+
},
|
|
4269
|
+
});
|
|
4270
|
+
}
|
|
4271
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
|
|
4272
|
+
},
|
|
4273
|
+
);
|
|
4274
|
+
|
|
4275
|
+
// ─── RFC-027 §2 — stop/delete node lifecycle ───────────────────────
|
|
4276
|
+
//
|
|
4277
|
+
// Two user-facing MCP tools (stop_node, delete_node) + two daemon-
|
|
4278
|
+
// facing tools (get_stop_request, ack_stop_request). State machine
|
|
4279
|
+
// per §2.3 — nodes.lifecycle_state ∈ {active, stopping, stopped,
|
|
4280
|
+
// deleting}. row-gone implies deleted. Security per §4:
|
|
4281
|
+
// §4.1 SEC-1: trust-root SQL join, not getNetworkId
|
|
4282
|
+
// §4.2 D6: delete_node refuses target.role==host_supervisor
|
|
4283
|
+
// §4.3 D4: in-flight inbox default-refuse + force+audit
|
|
4284
|
+
// §4.4 D7: daemon writes backup chmod 700, sweeper真删
|
|
4285
|
+
// §4.5 D8: audit_log in same SQLite tx as state UPDATE
|
|
4286
|
+
//
|
|
4287
|
+
// The dispatcher logic shared by stop_node + delete_node lives in
|
|
4288
|
+
// dispatchStopOrDelete; the two tool handlers thin-wrap it with
|
|
4289
|
+
// their respective action discriminator.
|
|
4290
|
+
type DispatchAction = "stop" | "delete";
|
|
4291
|
+
type DispatchArgs = {
|
|
4292
|
+
action: DispatchAction;
|
|
4293
|
+
child_node_id: string;
|
|
4294
|
+
daemon_node_id: string;
|
|
4295
|
+
force: boolean;
|
|
4296
|
+
grace_seconds: number;
|
|
4297
|
+
delete_config: boolean;
|
|
4298
|
+
confirm_alias?: string;
|
|
4299
|
+
};
|
|
4300
|
+
// RFC-027 PR2 prereq — auto-resolve daemon_node_id from child_node_id.
|
|
4301
|
+
// The dashboard (and most callers of stop_node / delete_node) shouldn't
|
|
4302
|
+
// need to track the daemon→child mapping themselves; the hub has it on
|
|
4303
|
+
// node_create_requests at child creation time. Returns null when no
|
|
4304
|
+
// creation record exists (orphan node row OR pre-RFC-026 node) — caller
|
|
4305
|
+
// must then pass daemon_node_id explicitly. node_id derivation:
|
|
4306
|
+
// `node_${request_id.replace(/^cr_/,"")}` (see create-node-daemon.ts
|
|
4307
|
+
// and PR1 BLOCKER-1 fix), so we reverse it: `cr_${node_id.slice(5)}`.
|
|
4308
|
+
const resolveDaemonForChild = (child_node_id: string): string | null => {
|
|
4309
|
+
if (!child_node_id.startsWith("node_")) return null;
|
|
4310
|
+
const requestId = `cr_${child_node_id.slice(5)}`;
|
|
4311
|
+
const row = db.get<{ daemon_node_id: string }>(
|
|
4312
|
+
`SELECT daemon_node_id FROM node_create_requests WHERE request_id = ?1`,
|
|
4313
|
+
requestId,
|
|
4314
|
+
);
|
|
4315
|
+
return row?.daemon_node_id ?? null;
|
|
4316
|
+
};
|
|
4317
|
+
|
|
4318
|
+
/**
|
|
4319
|
+
* 为什么这里要分两种「解析不到 daemon」(#196 / app 仓)。
|
|
4320
|
+
*
|
|
4321
|
+
* `resolveDaemonForChild` 对两件完全不同的事都返回 `null`:
|
|
4322
|
+
* ① id 不以 `node_` 开头 —— 这个节点**根本不是 daemon 创建的**,
|
|
4323
|
+
* 是有人在某台机器上直接 `anet node start` 起来的。hub 上没有任何
|
|
4324
|
+
* daemon 可以代它执行停止,**这条路径对它在概念上就不成立**。
|
|
4325
|
+
* ② id 以 `node_` 开头,但 `node_create_requests` 里查不到那一行 ——
|
|
4326
|
+
* 它可能确实是 daemon 建的,只是记录缺失,这时「显式传 daemon_node_id」
|
|
4327
|
+
* 是一条真的走得通的路。
|
|
4328
|
+
*
|
|
4329
|
+
* 🔴 原先两种情况打印同一句 `pass daemon_node_id explicitly`。对 ① 来说
|
|
4330
|
+
* **这句建议的前提是假的** —— 没有 daemon 可传,用户按它做只会走进死路。
|
|
4331
|
+
* 2026-08-28 对生产 hub 实测:218 个节点里 **207 个**是 ① 这种。
|
|
4332
|
+
*
|
|
4333
|
+
* 信息在源头就存在(那个 `startsWith("node_")` 判断),只是被丢在了返回值里。
|
|
4334
|
+
*/
|
|
4335
|
+
const explainUnresolvableDaemon = (child_node_id: string) =>
|
|
4336
|
+
child_node_id.startsWith("node_")
|
|
4337
|
+
? {
|
|
4338
|
+
ok: false, error: "daemon_not_resolvable",
|
|
4339
|
+
message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
|
|
4340
|
+
}
|
|
4341
|
+
: {
|
|
4342
|
+
ok: false, error: "not_daemon_managed",
|
|
4343
|
+
message:
|
|
4344
|
+
`node ${child_node_id} was not created by a daemon (it was started by hand with ` +
|
|
4345
|
+
`\`anet node start\` on some machine), so no daemon on the Hub can stop it. ` +
|
|
4346
|
+
`Run \`anet node stop <alias>\` on that machine instead. ` +
|
|
4347
|
+
`Daemon-created children have ids beginning with \`node_\`; this one does not.`,
|
|
4348
|
+
};
|
|
4349
|
+
|
|
4350
|
+
const dispatchStopOrDelete = (args: DispatchArgs, clientNetId?: string | null) => {
|
|
4351
|
+
// §4.1 — SEC-1 trust-root join: resolve target node row WITHIN the
|
|
4352
|
+
// caller's scope. resolveReadScope returns 'denied' if the caller
|
|
4353
|
+
// doesn't belong to clientNetId; for the row-existence test we
|
|
4354
|
+
// re-join with member's networks so an attacker can't probe by
|
|
4355
|
+
// node_id from a network they don't belong to.
|
|
4356
|
+
const scope = resolveReadScope(clientNetId);
|
|
4357
|
+
if (scope.denied) {
|
|
4358
|
+
return { ok: false, error: "forbidden_cross_tenant", message: scope.denied };
|
|
4359
|
+
}
|
|
4360
|
+
const nodeQ: any[] = [args.child_node_id];
|
|
4361
|
+
let nodeSql = `SELECT node_id, alias, network_id, lifecycle_state, config_snapshot, runtimes_supported
|
|
4362
|
+
FROM nodes WHERE node_id = ?1`;
|
|
4363
|
+
nodeSql = addReadScope(nodeSql, nodeQ, scope);
|
|
4364
|
+
const node = db.get<{
|
|
4365
|
+
node_id: string;
|
|
4366
|
+
alias: string;
|
|
4367
|
+
network_id: string;
|
|
4368
|
+
lifecycle_state: string | null;
|
|
4369
|
+
config_snapshot: string | null;
|
|
4370
|
+
}>(nodeSql, ...nodeQ);
|
|
4371
|
+
if (!node) return { ok: false, error: "forbidden_cross_tenant", message: "node not found in your networks" };
|
|
4372
|
+
|
|
4373
|
+
// Caller must hold a write-capable role on the resolved network
|
|
4374
|
+
// (admin/owner/member; viewer denied). canWrite walks getUserNetworkRole.
|
|
4375
|
+
if (!canWrite(node.network_id)) {
|
|
4376
|
+
return { ok: false, error: "permission_denied", message: "viewer role cannot stop/delete nodes" };
|
|
4377
|
+
}
|
|
4378
|
+
|
|
4379
|
+
// §4.2 D6 — delete refuses targets whose role is host_supervisor.
|
|
4380
|
+
// Stop is allowed against any node per RFC table (host_supervisor's
|
|
4381
|
+
// stop is functionally a no-op anyway: the daemon's children_map
|
|
4382
|
+
// only tracks its child nodes, never the daemon itself).
|
|
4383
|
+
//
|
|
4384
|
+
// PR1 SF-4 (#345 review) — fail-CLOSED on role read. The first cut
|
|
4385
|
+
// parsed config_snapshot and treated parse-fail / missing-field as
|
|
4386
|
+
// role=null which slipped through the gate. A daemon row with a
|
|
4387
|
+
// corrupt snapshot would then be deletable via delete_node. Fix:
|
|
4388
|
+
// - read role via two independent paths (snapshot.role + the
|
|
4389
|
+
// indexed `runtimes_supported` column whose presence ≈ daemon)
|
|
4390
|
+
// - if EITHER path indicates host_supervisor → refuse
|
|
4391
|
+
// - if snapshot parsed but role missing AND the node has any
|
|
4392
|
+
// daemon-shape evidence (non-null runtimes_supported column,
|
|
4393
|
+
// present in any node_create_requests as daemon_node_id) →
|
|
4394
|
+
// refuse defensively. The defense-in-depth daemon-side
|
|
4395
|
+
// children_map miss is the second layer; the hub gate must
|
|
4396
|
+
// itself fail-closed when role is ambiguous.
|
|
4397
|
+
if (args.action === "delete") {
|
|
4398
|
+
let role: string | null = null;
|
|
4399
|
+
let snapshotParseFailed = false;
|
|
4400
|
+
try {
|
|
4401
|
+
const snap = node.config_snapshot ? JSON.parse(node.config_snapshot) : null;
|
|
4402
|
+
role = typeof snap?.role === "string" ? snap.role : null;
|
|
4403
|
+
} catch { snapshotParseFailed = true; }
|
|
4404
|
+
const ambiguous = snapshotParseFailed || (role == null);
|
|
4405
|
+
// Independent corroborating check: nodes flagged as daemons by
|
|
4406
|
+
// create_node dispatchers will show up as daemon_node_id in
|
|
4407
|
+
// node_create_requests. Costs one indexed query.
|
|
4408
|
+
let looksLikeDaemon = false;
|
|
4409
|
+
if (ambiguous) {
|
|
4410
|
+
const m = db.get<{ n: number }>(
|
|
4411
|
+
`SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1`,
|
|
4412
|
+
node.node_id,
|
|
4413
|
+
);
|
|
4414
|
+
if ((m?.n ?? 0) > 0) looksLikeDaemon = true;
|
|
4415
|
+
}
|
|
4416
|
+
if (role === "host_supervisor" || (ambiguous && looksLikeDaemon)) {
|
|
4417
|
+
return {
|
|
4418
|
+
ok: false, error: "cannot_delete_daemon_via_delete_node",
|
|
4419
|
+
message: snapshotParseFailed
|
|
4420
|
+
? "node config_snapshot unreadable; node is referenced as a daemon — refuse delete defensively (use delete_daemon path)"
|
|
4421
|
+
: "host_supervisor daemons must be removed via the dedicated delete_daemon path (RFC-027.5)",
|
|
4422
|
+
};
|
|
4423
|
+
}
|
|
4424
|
+
}
|
|
4425
|
+
|
|
4426
|
+
// delete_node confirm_alias gate: dashboard's二次确认 input must
|
|
4427
|
+
// match the actual alias byte-for-byte. Hub rejects mismatched
|
|
4428
|
+
// input even if dashboard UI claims it's disabled.
|
|
4429
|
+
if (args.action === "delete") {
|
|
4430
|
+
if (typeof args.confirm_alias !== "string" || args.confirm_alias !== node.alias) {
|
|
4431
|
+
return { ok: false, error: "confirm_alias_mismatch", message: "confirm_alias must equal the node's alias" };
|
|
4432
|
+
}
|
|
4433
|
+
}
|
|
4434
|
+
|
|
4435
|
+
// Daemon must exist + be in the same network. We do NOT enforce
|
|
4436
|
+
// daemon.role==host_supervisor here: a child whose creator daemon
|
|
4437
|
+
// got demoted should still be stoppable. The daemon will refuse
|
|
4438
|
+
// with noop_not_my_child if children_map doesn't know it.
|
|
4439
|
+
//
|
|
4440
|
+
// PR1.2 e2e catch (BLOCKER): pushEvent keys SSE by clientKey ≈
|
|
4441
|
+
// `${networkId}:${sessionName}`, and agent-node registers its SSE
|
|
4442
|
+
// connection under its ALIAS (not its node_id). The create_node
|
|
4443
|
+
// doorbell at line ~2197 correctly pushes via `daemon.alias`;
|
|
4444
|
+
// stop_node was pushing via `daemon_node_id` so every doorbell
|
|
4445
|
+
// missed every SSE client → daemon never woke up → request_id
|
|
4446
|
+
// stayed 'pending' forever → child never reaped. The 24 unit tests
|
|
4447
|
+
// in stop-delete-node.test.ts all mock pushEvent and inject the
|
|
4448
|
+
// post-doorbell handler call directly, so single-unit coverage
|
|
4449
|
+
// couldn't surface this — exactly the failure class 通信龙 said
|
|
4450
|
+
// the docker e2e gate exists to catch (BLOCKER-1 同源). Load the
|
|
4451
|
+
// alias along with node_id + network_id and dispatch to the alias.
|
|
4452
|
+
const daemon = db.get<{ node_id: string; alias: string; network_id: string }>(
|
|
4453
|
+
`SELECT node_id, alias, network_id FROM nodes WHERE node_id = ?1`, args.daemon_node_id,
|
|
4454
|
+
);
|
|
4455
|
+
if (!daemon) return { ok: false, error: "daemon_not_found" };
|
|
4456
|
+
if (daemon.network_id !== node.network_id) {
|
|
4457
|
+
// Cross-tenant child↔daemon mismatch shouldn't be possible in
|
|
4458
|
+
// normal flow but is a SEC-1 hardening — refuse loud.
|
|
4459
|
+
return { ok: false, error: "daemon_cross_tenant", message: "daemon and child are in different networks" };
|
|
4460
|
+
}
|
|
4461
|
+
|
|
4462
|
+
// #1448 finding-4 — daemon 必须是该 child 的**权威创建者**,镜像 start_node 的
|
|
4463
|
+
// daemon_child_mismatch。此前 stop/delete 接受调用方传入的 daemon_node_id,只校验
|
|
4464
|
+
// 它存在 + 同网,不验它真的创建了这个 child。同网调用方传错 daemon_node_id → 门铃
|
|
4465
|
+
// 路由到错 daemon。🔴 f3(#1453) 合入后更糟:错 daemon 无该 child 的 map entry → 走
|
|
4466
|
+
// 收敛分支 sweep(错机器 pgrep 空) + ack stopped → hub 假收敛 stopped、child 仍在
|
|
4467
|
+
// 正确机器上跑(旧 noop 至少留下可见的卡死态)。网络 scope 只挡跨租户,网内是 footgun。
|
|
4468
|
+
//
|
|
4469
|
+
// 用「确定性不匹配」判据:authority 已知(child 有 create 记录)且不等 → 拒。不对
|
|
4470
|
+
// authority==null(create 记录被裁剪的旧 child)加拒,避免给它们的 stop/delete 引入
|
|
4471
|
+
// 新失败面——handler 的 daemon_node_id??resolveDaemonForChild 已兜住无法解析的情形。
|
|
4472
|
+
const authoritativeDaemonId = resolveDaemonForChild(args.child_node_id);
|
|
4473
|
+
if (authoritativeDaemonId && authoritativeDaemonId !== args.daemon_node_id) {
|
|
4474
|
+
return { ok: false, error: "daemon_child_mismatch", message: "daemon_node_id is not this child's authoritative creator daemon" };
|
|
4475
|
+
}
|
|
4476
|
+
|
|
4477
|
+
// State machine gate.
|
|
4478
|
+
const state = node.lifecycle_state ?? "active";
|
|
4479
|
+
if (args.action === "stop" && state !== "active") {
|
|
4480
|
+
return { ok: false, error: state === "stopping" ? "node_already_stopping" : "node_not_active", current_state: state };
|
|
4481
|
+
}
|
|
4482
|
+
if (args.action === "delete" && state === "deleting") {
|
|
4483
|
+
// #1286 —— stale-deleting 重派出口。
|
|
4484
|
+
//
|
|
4485
|
+
// 在 .40 埋点修复之前,daemon 丢失 ack 会让行**永远**停在 deleting:
|
|
4486
|
+
// 重发 delete 被这里挡住,force=true 也一样 —— 状态机没有出口,
|
|
4487
|
+
// 2026-08-28 实测两行卡死(acc-…-3373 / probe-tl2),API 层面无解。
|
|
4488
|
+
//
|
|
4489
|
+
// 出口的三个条件,缺一不放行:
|
|
4490
|
+
// ① 调用方显式 force=true(不改默认行为 —— 正常并发的重复 delete 仍被拒)
|
|
4491
|
+
// ② 该 child 最近一条 delete 请求不是终态(pending/dispatched)
|
|
4492
|
+
// —— 终态说明 daemon 刚 ack 过,行马上会转移,不该重派
|
|
4493
|
+
// ③ 那条请求已经晾了超过 STALE_DELETING_MS —— 🔴 不是"刚派发就能 force":
|
|
4494
|
+
// 给正常的 doorbell→ack 往返留时间,否则 force 变成竞态放大器。
|
|
4495
|
+
const STALE_DELETING_MS = 5 * 60_000;
|
|
4496
|
+
const last = db.get<{ request_id: string; status: string; created_at: number }>(
|
|
4497
|
+
`SELECT request_id, status, created_at FROM node_stop_requests
|
|
4498
|
+
WHERE child_node_id = ?1 AND action = 'delete'
|
|
4499
|
+
ORDER BY created_at DESC LIMIT 1`, node.node_id,
|
|
4500
|
+
);
|
|
4501
|
+
const lastIsStale = !!last
|
|
4502
|
+
&& (last.status === "pending" || last.status === "dispatched")
|
|
4503
|
+
&& (Date.now() - last.created_at) > STALE_DELETING_MS;
|
|
4504
|
+
if (!(args.force === true && (lastIsStale || !last))) {
|
|
4505
|
+
return {
|
|
4506
|
+
ok: false, error: "node_already_deleting", current_state: state,
|
|
4507
|
+
// 🔴 告诉调用方出口在哪,而不是让他反复撞同一面墙。
|
|
4508
|
+
...(last ? { last_request_id: last.request_id, last_request_status: last.status } : {}),
|
|
4509
|
+
hint: last && (last.status === "pending" || last.status === "dispatched")
|
|
4510
|
+
? `再等一下;若超过 ${STALE_DELETING_MS / 60000} 分钟仍未收敛,带 force=true 重试可重新派发`
|
|
4511
|
+
: "上一条请求已终态,行应即将转移;稍后重查",
|
|
4512
|
+
};
|
|
4513
|
+
}
|
|
4514
|
+
// 放行:走正常派发路径,生成新 request_id。旧请求行保留(审计线索),
|
|
4515
|
+
// daemon 侧对未知 request 的 get_stop_request 会拿到 request_not_found,无副作用。
|
|
4516
|
+
}
|
|
4517
|
+
if (args.action === "delete" && state === "stopping") {
|
|
4518
|
+
// Can't start delete while a stop is mid-flight — race-prone.
|
|
4519
|
+
return { ok: false, error: "node_stopping_in_progress", current_state: state };
|
|
4520
|
+
}
|
|
4521
|
+
|
|
4522
|
+
// §4.3 D4 — in-flight inbox check. Count unacked tasks routed to
|
|
4523
|
+
// this node's alias (inbox routing uses session_name == alias).
|
|
4524
|
+
// Default refuse with the count surfaced; force=true overrides
|
|
4525
|
+
// and triggers the forced_stop_with_in_flight audit row.
|
|
4526
|
+
// SF-5 (#345 review): legacy inbox rows may have network_id=NULL
|
|
4527
|
+
// and would silently miss a network_id=?2 strict equality match.
|
|
4528
|
+
// Use COALESCE so a NULL inbox row is counted against the same
|
|
4529
|
+
// network as the target node (which is the only safe default
|
|
4530
|
+
// — pre-multi-network rows existed in single-network mode).
|
|
4531
|
+
const inFlightRow = db.get<{ n: number }>(
|
|
4532
|
+
`SELECT COUNT(*) AS n FROM inbox WHERE session_name = ?1 AND acked = 0
|
|
4533
|
+
AND COALESCE(network_id, ?2) = ?2`,
|
|
4534
|
+
node.alias, node.network_id,
|
|
4535
|
+
);
|
|
4536
|
+
const inFlight = inFlightRow?.n ?? 0;
|
|
4537
|
+
if (inFlight > 0 && !args.force) {
|
|
4538
|
+
return { ok: false, error: "node_busy_in_flight", in_flight_count: inFlight, hint: "set force=true to override (audit logged)" };
|
|
4539
|
+
}
|
|
4540
|
+
|
|
4541
|
+
// ── all gates passed; create request + transition state + push doorbell, transactionally ──
|
|
4542
|
+
const requestId = generateId("sr");
|
|
4543
|
+
const now = Date.now();
|
|
4544
|
+
const newState = args.action === "stop" ? "stopping" : "deleting";
|
|
4545
|
+
|
|
4546
|
+
// #1448 finding-5 — 记下该 child **当前活着的** ntok 的 token_id,让 ack 精确按
|
|
4547
|
+
// token_id 撤销,而非按 name='node:<alias>' 广撤。后者会在「同 alias 节点在这次
|
|
4548
|
+
// delete 未收敛的窗口内被重建(拿到同名新 token)」时,把新 token 一并误撤。这里
|
|
4549
|
+
// 在派发时刻定住身份;若此刻没有活 token(异常),存 null,ack 退回按 name 撤(旧语义)。
|
|
4550
|
+
const childTokenRow = db.get<{ token_id: string }>(
|
|
4551
|
+
`SELECT token_id FROM api_tokens WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
|
|
4552
|
+
node.network_id, `node:${node.alias}`,
|
|
4553
|
+
);
|
|
4554
|
+
const childTokenId = childTokenRow?.token_id ?? null;
|
|
4555
|
+
|
|
4556
|
+
// §4.5 D8 — lifecycle UPDATE + audit INSERTs + request INSERT atomic.
|
|
4557
|
+
// PR1.1: use db.transaction() (SQLiteAdapter wraps better-sqlite3's
|
|
4558
|
+
// native transaction; PgAdapter ships a real BEGIN/COMMIT/ROLLBACK
|
|
4559
|
+
// shim). Lets us drop the open-coded BEGIN/COMMIT + makes the
|
|
4560
|
+
// SF-2 failure-injection test (mock db.run throw inside callback)
|
|
4561
|
+
// reliable across both backends.
|
|
4562
|
+
try {
|
|
4563
|
+
db.transaction(() => {
|
|
4564
|
+
db.run(
|
|
4565
|
+
`INSERT INTO node_stop_requests
|
|
4566
|
+
(request_id, network_id, daemon_node_id, child_node_id, child_alias, child_token_id, action,
|
|
4567
|
+
delete_config, grace_seconds, force, in_flight_at_dispatch, created_by_token,
|
|
4568
|
+
status, created_at)
|
|
4569
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'pending', ?13)`,
|
|
4570
|
+
[
|
|
4571
|
+
requestId, node.network_id, args.daemon_node_id, node.node_id, node.alias, childTokenId, args.action,
|
|
4572
|
+
args.delete_config ? 1 : 0, args.grace_seconds, args.force ? 1 : 0, inFlight,
|
|
4573
|
+
callerTokenId || "unknown", now,
|
|
4574
|
+
],
|
|
4575
|
+
);
|
|
4576
|
+
db.run(
|
|
4577
|
+
`UPDATE nodes SET lifecycle_state = ?1 WHERE node_id = ?2`,
|
|
4578
|
+
[newState, node.node_id],
|
|
4579
|
+
);
|
|
4580
|
+
// §4.5 D8 — audit dispatch action. Uses the STRICT variant so a
|
|
4581
|
+
// failed audit INSERT propagates and triggers ROLLBACK (PR1 SF-2
|
|
4582
|
+
// review catch — auditCreateNode's swallow-and-warn would have
|
|
4583
|
+
// committed the lifecycle UPDATE without an audit row, leaving
|
|
4584
|
+
// exactly the "deleted but no audit" window §4.5 closes).
|
|
4585
|
+
auditCreateNodeStrict({
|
|
4586
|
+
action: args.action === "stop" ? "stop_node_dispatched" : "delete_node_dispatched",
|
|
4587
|
+
user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
|
|
4588
|
+
detail: {
|
|
4589
|
+
child_node_id: node.node_id, child_alias: node.alias,
|
|
4590
|
+
daemon_node_id: args.daemon_node_id, action: args.action,
|
|
4591
|
+
force: args.force, delete_config: args.delete_config,
|
|
4592
|
+
grace_seconds: args.grace_seconds, in_flight_at_dispatch: inFlight,
|
|
4593
|
+
lifecycle_state_before: state, lifecycle_state_after: newState,
|
|
4594
|
+
ts_request: now,
|
|
4595
|
+
},
|
|
4596
|
+
});
|
|
4597
|
+
if (args.force && inFlight > 0) {
|
|
4598
|
+
auditCreateNodeStrict({
|
|
4599
|
+
action: "forced_stop_with_in_flight",
|
|
4600
|
+
user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
|
|
4601
|
+
detail: { in_flight_count: inFlight, child_alias: node.alias },
|
|
4602
|
+
});
|
|
4603
|
+
}
|
|
4604
|
+
}); // end db.transaction
|
|
4605
|
+
} catch (e: any) {
|
|
4606
|
+
return { ok: false, error: "dispatch_tx_failed", message: e?.message || String(e) };
|
|
4607
|
+
}
|
|
4608
|
+
|
|
4609
|
+
// SSE doorbell — daemon will pull via get_stop_request.
|
|
4610
|
+
// PR1.2 e2e fix: route by daemon.alias (see SELECT above for the
|
|
4611
|
+
// BLOCKER explanation). Mirrors create_node's pushEvent target at
|
|
4612
|
+
// line ~2197.
|
|
4613
|
+
pushEvent(daemon.alias, { type: "stop_node", request_id: requestId }, node.network_id);
|
|
4614
|
+
return {
|
|
4615
|
+
ok: true, request_id: requestId, action: args.action,
|
|
4616
|
+
lifecycle_state: newState, in_flight_at_dispatch: inFlight,
|
|
4617
|
+
};
|
|
4618
|
+
};
|
|
4619
|
+
|
|
4620
|
+
server.tool(
|
|
4621
|
+
"stop_node",
|
|
4622
|
+
"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).",
|
|
4623
|
+
{
|
|
4624
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
4625
|
+
// PR2 prereq: dashboard rarely knows daemon_node_id directly.
|
|
4626
|
+
// When omitted, hub resolves from the original creation record
|
|
4627
|
+
// (node_create_requests.daemon_node_id keyed by this child).
|
|
4628
|
+
daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
|
|
4629
|
+
force: z.boolean().optional().default(false),
|
|
4630
|
+
grace_seconds: z.number().int().min(5).max(60).optional().default(10),
|
|
4631
|
+
network_id: z.string().max(200).optional(),
|
|
4632
|
+
},
|
|
4633
|
+
async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, force, grace_seconds, network_id: clientNetId }) => {
|
|
4634
|
+
const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
|
|
4635
|
+
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
4636
|
+
const child_node_id = idArg.node_id;
|
|
4637
|
+
const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
|
|
4638
|
+
if (!resolved) {
|
|
4639
|
+
return { content: [{ type: "text" as const,
|
|
4640
|
+
text: JSON.stringify(explainUnresolvableDaemon(child_node_id)) }] };
|
|
4641
|
+
}
|
|
4642
|
+
const r = dispatchStopOrDelete(
|
|
4643
|
+
{ action: "stop", child_node_id, daemon_node_id: resolved, force: force ?? false,
|
|
4644
|
+
grace_seconds: grace_seconds ?? 10, delete_config: false },
|
|
4645
|
+
clientNetId,
|
|
4646
|
+
);
|
|
4647
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
|
|
4648
|
+
},
|
|
4649
|
+
);
|
|
4650
|
+
|
|
4651
|
+
server.tool(
|
|
4652
|
+
"delete_node",
|
|
4653
|
+
"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.",
|
|
4654
|
+
{
|
|
4655
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
4656
|
+
daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
|
|
4657
|
+
confirm_alias: z.string().min(1).max(200),
|
|
4658
|
+
force: z.boolean().optional().default(false),
|
|
4659
|
+
grace_seconds: z.number().int().min(5).max(60).optional().default(10),
|
|
4660
|
+
delete_config: z.boolean().optional().default(true),
|
|
4661
|
+
network_id: z.string().max(200).optional(),
|
|
4662
|
+
},
|
|
4663
|
+
async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, confirm_alias, force, grace_seconds, delete_config, network_id: clientNetId }) => {
|
|
4664
|
+
const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
|
|
4665
|
+
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
4666
|
+
const child_node_id = idArg.node_id;
|
|
4667
|
+
const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
|
|
4668
|
+
if (!resolved) {
|
|
4669
|
+
return { content: [{ type: "text" as const,
|
|
4670
|
+
text: JSON.stringify(explainUnresolvableDaemon(child_node_id)) }] };
|
|
4671
|
+
}
|
|
4672
|
+
const r = dispatchStopOrDelete(
|
|
4673
|
+
{ action: "delete", child_node_id, daemon_node_id: resolved, force: force ?? false,
|
|
4674
|
+
grace_seconds: grace_seconds ?? 10, delete_config: delete_config ?? true, confirm_alias },
|
|
4675
|
+
clientNetId,
|
|
4676
|
+
);
|
|
4677
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
|
|
4678
|
+
},
|
|
4679
|
+
);
|
|
4680
|
+
|
|
4681
|
+
server.tool(
|
|
4682
|
+
"get_stop_request",
|
|
4683
|
+
"Daemon pulls a pending stop/delete request (called when SSE stop_node doorbell arrives). RFC-027 §2.4.",
|
|
4684
|
+
{
|
|
4685
|
+
request_id: z.string().min(1).max(200),
|
|
4686
|
+
},
|
|
4687
|
+
async ({ request_id }) => {
|
|
4688
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
4689
|
+
if (!callerDaemon.ok) {
|
|
4690
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
4691
|
+
}
|
|
4692
|
+
const row = db.get<{
|
|
4693
|
+
daemon_node_id: string; status: string; network_id: string;
|
|
4694
|
+
child_node_id: string; child_alias: string; action: string;
|
|
4695
|
+
delete_config: number; grace_seconds: number; force: number;
|
|
4696
|
+
}>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, action,
|
|
4697
|
+
delete_config, grace_seconds, force
|
|
4698
|
+
FROM node_stop_requests WHERE request_id = ?1`, request_id);
|
|
4699
|
+
if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
4700
|
+
if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
|
|
4701
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
4702
|
+
}
|
|
4703
|
+
if (row.network_id !== callerDaemon.networkId) {
|
|
4704
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
|
|
4705
|
+
}
|
|
4706
|
+
// Stamp delivered_at on first pull (idempotent — only if still pending).
|
|
4707
|
+
db.run(
|
|
4708
|
+
`UPDATE node_stop_requests SET status = 'delivered', delivered_at = ?1
|
|
4709
|
+
WHERE request_id = ?2 AND status = 'pending'`,
|
|
4710
|
+
[Date.now(), request_id],
|
|
4711
|
+
);
|
|
4712
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
4713
|
+
ok: true,
|
|
4714
|
+
request_id,
|
|
4715
|
+
child_node_id: row.child_node_id,
|
|
4716
|
+
child_alias: row.child_alias,
|
|
4717
|
+
action: row.action,
|
|
4718
|
+
delete_config: row.delete_config === 1,
|
|
4719
|
+
grace_seconds: row.grace_seconds,
|
|
4720
|
+
force: row.force === 1,
|
|
4721
|
+
}) }] };
|
|
4722
|
+
},
|
|
4723
|
+
);
|
|
4724
|
+
|
|
4725
|
+
server.tool(
|
|
4726
|
+
"ack_stop_request",
|
|
4727
|
+
"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.",
|
|
4728
|
+
{
|
|
4729
|
+
request_id: z.string().min(1).max(200),
|
|
4730
|
+
status: z.enum(["stopped", "stop_failed", "noop_not_my_child"]),
|
|
4731
|
+
exit_signal: z.string().max(16).optional(),
|
|
4732
|
+
backup_path: z.string().max(500).optional(),
|
|
4733
|
+
error: z.string().max(1000).optional(),
|
|
4734
|
+
},
|
|
4735
|
+
async ({ request_id, status, exit_signal, backup_path, error: ackError }) => {
|
|
4736
|
+
// #1358 —— 🔴 在此之前,这个工具的**每一条出口都是静默的**:到达不记、
|
|
4737
|
+
// 三条拒绝不记、成功收尾也不记。而隔壁 create-node 的收尾**是记的**
|
|
4738
|
+
// (`✓ create-node finalize`)。这种不对称最坑人:读日志的人看到 create 有、
|
|
4739
|
+
// delete 没有,第一反应是「delete 没发生」—— 但两者的日志覆盖面本来就不同,
|
|
4740
|
+
// 那条推论没有依据。排查 #1286 时我自己就差点这样读。
|
|
4741
|
+
console.log(`[commhub] ← ack_stop_request request=${request_id} status=${status}${exit_signal ? ` exit_signal=${exit_signal}` : ""}`);
|
|
4742
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
4743
|
+
if (!callerDaemon.ok) {
|
|
4744
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
4745
|
+
}
|
|
4746
|
+
const row = db.get<{
|
|
4747
|
+
daemon_node_id: string; status: string; network_id: string;
|
|
4748
|
+
child_node_id: string; child_alias: string; child_token_id: string | null; action: string;
|
|
4749
|
+
in_flight_at_dispatch: number; force: number;
|
|
4750
|
+
}>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, child_token_id, action,
|
|
4751
|
+
in_flight_at_dispatch, force
|
|
4752
|
+
FROM node_stop_requests WHERE request_id = ?1`, request_id);
|
|
4753
|
+
if (!row) {
|
|
4754
|
+
console.warn(`[commhub] ✕ ack_stop_request rejected: request_not_found request=${request_id}`);
|
|
4755
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
4756
|
+
}
|
|
4757
|
+
if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
|
|
4758
|
+
console.warn(`[commhub] ✕ ack_stop_request rejected: not_your_request request=${request_id} row_daemon=${row.daemon_node_id} caller=${callerDaemon.daemonNodeId}`);
|
|
4759
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
4760
|
+
}
|
|
4761
|
+
if (row.network_id !== callerDaemon.networkId) {
|
|
4762
|
+
console.warn(`[commhub] ✕ ack_stop_request rejected: cross_network_request request=${request_id} row_net=${row.network_id} caller_net=${callerDaemon.networkId}`);
|
|
4763
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
|
|
4764
|
+
}
|
|
4765
|
+
const now = Date.now();
|
|
4766
|
+
|
|
4767
|
+
// §4.5 D8 — lifecycle UPDATE + request UPDATE + audit INSERT
|
|
4768
|
+
// (+ DELETE for delete action) atomic via db.transaction().
|
|
4769
|
+
// PR1.1: replaces hand-rolled BEGIN..COMMIT so PG adapter and
|
|
4770
|
+
// SQLite both get correct rollback semantics from one code path.
|
|
4771
|
+
try {
|
|
4772
|
+
db.transaction(() => {
|
|
4773
|
+
db.run(
|
|
4774
|
+
`UPDATE node_stop_requests SET status = ?1, error = ?2, exit_signal = ?3,
|
|
4775
|
+
backup_path = ?4, acked_at = ?5
|
|
4776
|
+
WHERE request_id = ?6`,
|
|
4777
|
+
[status, ackError || null, exit_signal || null, backup_path || null, now, request_id],
|
|
4778
|
+
);
|
|
4779
|
+
if (status === "stopped" && row.action === "stop") {
|
|
4780
|
+
db.run(`UPDATE nodes SET lifecycle_state = 'stopped' WHERE node_id = ?1`, [row.child_node_id]);
|
|
4781
|
+
auditCreateNodeStrict({
|
|
4782
|
+
action: "stop_node_completed",
|
|
4783
|
+
user_id: null, network_id: row.network_id, target_id: request_id,
|
|
4784
|
+
detail: {
|
|
4785
|
+
child_node_id: row.child_node_id, child_alias: row.child_alias,
|
|
4786
|
+
exit_signal: exit_signal || null, ts_daemon_ack: now,
|
|
4787
|
+
lifecycle_state_after: "stopped",
|
|
4788
|
+
},
|
|
4789
|
+
});
|
|
4790
|
+
} else if (status === "stopped" && row.action === "delete") {
|
|
4791
|
+
// Revoke the child's ntok, then DELETE the nodes row.
|
|
4792
|
+
// #1448 finding-5 — 精确按派发时定住的 child_token_id 撤(镜像
|
|
4793
|
+
// ack_create_request 的 token_id 精撤)。旧行(升级前派发,child_token_id
|
|
4794
|
+
// 为 NULL)fallback 回按 name='node:<alias>' 撤——旧语义,不引入回归。
|
|
4795
|
+
// 按 token_id 撤避免了「同 alias 在本次 delete 窗口内被重建、拿到同名新
|
|
4796
|
+
// token」时把新 token 一并误撤。
|
|
4797
|
+
if (row.child_token_id) {
|
|
4798
|
+
db.run(
|
|
4799
|
+
`UPDATE api_tokens SET revoked_at = datetime('now')
|
|
4800
|
+
WHERE token_id = ?1 AND revoked_at IS NULL`,
|
|
4801
|
+
[row.child_token_id],
|
|
4802
|
+
);
|
|
4803
|
+
} else {
|
|
4804
|
+
db.run(
|
|
4805
|
+
`UPDATE api_tokens SET revoked_at = datetime('now')
|
|
4806
|
+
WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
|
|
4807
|
+
[row.network_id, `node:${row.child_alias}`],
|
|
4808
|
+
);
|
|
4809
|
+
}
|
|
4810
|
+
db.run(`DELETE FROM nodes WHERE node_id = ?1`, [row.child_node_id]);
|
|
4811
|
+
auditCreateNodeStrict({
|
|
4812
|
+
action: "delete_node_completed",
|
|
4813
|
+
user_id: null, network_id: row.network_id, target_id: request_id,
|
|
4814
|
+
detail: {
|
|
4815
|
+
child_node_id: row.child_node_id, child_alias: row.child_alias,
|
|
4816
|
+
exit_signal: exit_signal || null, backup_path: backup_path || null,
|
|
4817
|
+
ts_daemon_ack: now, lifecycle_state_after: "deleted",
|
|
4818
|
+
},
|
|
4819
|
+
});
|
|
4820
|
+
} else if (status === "stop_failed") {
|
|
4821
|
+
db.run(`UPDATE nodes SET lifecycle_state = 'stop_failed' WHERE node_id = ?1`, [row.child_node_id]);
|
|
4822
|
+
// No completion audit; the failure error is on the request row.
|
|
4823
|
+
}
|
|
4824
|
+
// noop_not_my_child: leave lifecycle_state as-is; the hub-side
|
|
4825
|
+
// sweeper / next reconciliation will pick it up. Don't transition.
|
|
4826
|
+
});
|
|
4827
|
+
} catch (e: any) {
|
|
4828
|
+
console.warn(`[commhub] ✕ ack_stop_request finalize_tx_failed request=${request_id} action=${row.action} child=${row.child_alias}: ${e?.message || e}`);
|
|
4829
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "finalize_tx_failed", message: e?.message || String(e) }) }] };
|
|
4830
|
+
}
|
|
4831
|
+
|
|
4832
|
+
// 🔴 关键的是最后那半句「据此做了什么」。#1286 里 hub 的代码路径经核对是
|
|
4833
|
+
// 正确的,缺的正是「它到底走没走那条分支」—— 只说「收到了 ack」回答不了。
|
|
4834
|
+
const did = status === "stopped" && row.action === "delete"
|
|
4835
|
+
? "revoked ntok + DELETE FROM nodes"
|
|
4836
|
+
: status === "stopped" && row.action === "stop"
|
|
4837
|
+
? "lifecycle_state=stopped"
|
|
4838
|
+
: status === "stop_failed"
|
|
4839
|
+
? "lifecycle_state=stop_failed"
|
|
4840
|
+
: "no lifecycle transition (noop_not_my_child)";
|
|
4841
|
+
console.log(`[commhub] ✓ ${row.action}-node finalize: request=${request_id} child=${row.child_alias} (${row.child_node_id}) status=${status} → ${did}`);
|
|
4842
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
|
|
4843
|
+
},
|
|
4844
|
+
);
|
|
4845
|
+
|
|
4846
|
+
// start_node is deliberately daemon-mediated. A stopped child has no SSE
|
|
4847
|
+
// consumer of its own, so restart_node's self-doorbell cannot revive it.
|
|
4848
|
+
server.tool(
|
|
4849
|
+
"start_node",
|
|
4850
|
+
"Start a stopped child through its host supervisor daemon. daemon_node_id is auto-resolved from the original create request.",
|
|
4851
|
+
{
|
|
4852
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
4853
|
+
daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
|
|
4854
|
+
network_id: z.string().max(200).optional(),
|
|
4855
|
+
},
|
|
4856
|
+
async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, network_id: clientNetId }) => {
|
|
4857
|
+
const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
|
|
4858
|
+
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
4859
|
+
const child_node_id = idArg.node_id;
|
|
4860
|
+
const resolvedDaemonId = daemon_node_id ?? resolveDaemonForChild(child_node_id);
|
|
4861
|
+
if (!resolvedDaemonId) {
|
|
4862
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
4863
|
+
ok: false, error: "daemon_not_resolvable",
|
|
4864
|
+
message: "no create request maps this child to a daemon",
|
|
4865
|
+
}) }] };
|
|
4866
|
+
}
|
|
4867
|
+
const scope = resolveReadScope(clientNetId);
|
|
4868
|
+
if (scope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "forbidden_cross_tenant" }) }] };
|
|
4869
|
+
const q: any[] = [child_node_id];
|
|
4870
|
+
const child = db.get<{ node_id: string; alias: string; network_id: string; lifecycle_state: string | null }>(
|
|
4871
|
+
addReadScope(`SELECT node_id, alias, network_id, lifecycle_state FROM nodes WHERE node_id = ?1`, q, scope), ...q,
|
|
4872
|
+
);
|
|
4873
|
+
if (!child) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "forbidden_cross_tenant" }) }] };
|
|
4874
|
+
if (!canWrite(child.network_id)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
4875
|
+
// #1448 finding-2 — stale-starting reaper,对齐 update_node_config / restart_node
|
|
4876
|
+
// 的 60s single-flight stale-supersede。start 只受理 'stopped';一旦卡 'starting'
|
|
4877
|
+
// (门铃丢 / daemon 在 UPDATE 'starting' 后死),config/restart 有 reaper、delete 有
|
|
4878
|
+
// 5min force,唯独 start 裸奔 → 下面的 gate 永远拦、也无人把节点拉回 stopped →
|
|
4879
|
+
// 永久卡 'starting'。出口:'starting' 且最近一条 in-flight start 请求晾过阈值 → 标
|
|
4880
|
+
// 旧请求 timeout 超越、放行重派;未过阈值则拒 node_already_starting(带 age)。
|
|
4881
|
+
const priorState = child.lifecycle_state ?? "active";
|
|
4882
|
+
if (priorState !== "stopped") {
|
|
4883
|
+
if (priorState !== "starting") {
|
|
4884
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_stopped", current_state: priorState }) }] };
|
|
4885
|
+
}
|
|
4886
|
+
const STALE_STARTING_MS = 60_000;
|
|
4887
|
+
const inFlight = db.get<{ request_id: string; created_at: number; delivered_at: number | null; acked_at: number | null }>(
|
|
4888
|
+
`SELECT request_id, created_at, delivered_at, acked_at FROM node_start_requests
|
|
4889
|
+
WHERE child_node_id = ?1 AND status IN ('pending', 'delivered')
|
|
4890
|
+
ORDER BY created_at DESC LIMIT 1`, child.node_id,
|
|
4891
|
+
);
|
|
4892
|
+
if (inFlight) {
|
|
4893
|
+
// Age anchor = COALESCE(acked_at, delivered_at, created_at),同 config reaper:
|
|
4894
|
+
// 已 pull/已 ack 的活着但慢的 start 会刷新存活时钟,不被误 reap。
|
|
4895
|
+
const ageAnchor = inFlight.acked_at ?? inFlight.delivered_at ?? inFlight.created_at;
|
|
4896
|
+
const age = Date.now() - ageAnchor;
|
|
4897
|
+
if (age <= STALE_STARTING_MS) {
|
|
4898
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
4899
|
+
ok: false, error: "node_already_starting",
|
|
4900
|
+
existing_request_id: inFlight.request_id, age_ms: age,
|
|
4901
|
+
hint: `再等一下;超过 ${STALE_STARTING_MS / 1000}s 仍卡 starting,重试 start 会重新派发`,
|
|
4902
|
+
}) }] };
|
|
4903
|
+
}
|
|
4904
|
+
// Stale — 标旧请求 timeout(autocommit,先于下面 tx 的新 INSERT,解除
|
|
4905
|
+
// node_start_requests 对 pending/delivered 的 child 唯一约束),放行重派。
|
|
4906
|
+
db.run(
|
|
4907
|
+
`UPDATE node_start_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3`,
|
|
4908
|
+
[Date.now(), `superseded by new start after ${age}ms stale (> ${STALE_STARTING_MS}ms threshold)`, inFlight.request_id],
|
|
4909
|
+
);
|
|
4910
|
+
}
|
|
4911
|
+
// else: 'starting' 但无 in-flight 行(异常残留态)——照样放行重派以自愈。
|
|
4912
|
+
}
|
|
4913
|
+
// The caller may provide daemon_node_id, but it cannot override the
|
|
4914
|
+
// authoritative child->daemon relationship recorded at creation.
|
|
4915
|
+
const authoritativeDaemonId = resolveDaemonForChild(child_node_id);
|
|
4916
|
+
if (!authoritativeDaemonId || authoritativeDaemonId !== resolvedDaemonId) {
|
|
4917
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_child_mismatch" }) }] };
|
|
4918
|
+
}
|
|
4919
|
+
const daemon = db.get<{ node_id: string; alias: string; network_id: string }>(
|
|
4920
|
+
`SELECT node_id, alias, network_id FROM nodes WHERE node_id = ?1`, resolvedDaemonId,
|
|
4921
|
+
);
|
|
4922
|
+
if (!daemon) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found" }) }] };
|
|
4923
|
+
if (daemon.network_id !== child.network_id) {
|
|
4924
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_cross_tenant" }) }] };
|
|
4925
|
+
}
|
|
4926
|
+
const requestId = generateId("str");
|
|
4927
|
+
const now = Date.now();
|
|
4928
|
+
try {
|
|
4929
|
+
db.transaction(() => {
|
|
4930
|
+
db.run(
|
|
4931
|
+
`INSERT INTO node_start_requests
|
|
4932
|
+
(request_id, network_id, daemon_node_id, child_node_id, child_alias,
|
|
4933
|
+
created_by_token, status, created_at)
|
|
4934
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7)`,
|
|
4935
|
+
[requestId, child.network_id, daemon.node_id, child.node_id, child.alias, callerTokenId || "unknown", now],
|
|
4936
|
+
);
|
|
4937
|
+
// #1448 finding-2 — 也接受从 'starting' 重派(stale-supersede 后原态仍是
|
|
4938
|
+
// starting);两种入态都合法地转/留到 'starting'。
|
|
4939
|
+
db.run(`UPDATE nodes SET lifecycle_state = 'starting' WHERE node_id = ?1 AND lifecycle_state IN ('stopped', 'starting')`, [child.node_id]);
|
|
4940
|
+
auditCreateNodeStrict({
|
|
4941
|
+
action: "start_node_dispatched", user_id: enforceUserId,
|
|
4942
|
+
network_id: child.network_id, target_id: requestId,
|
|
4943
|
+
detail: { child_node_id: child.node_id, child_alias: child.alias, daemon_node_id: daemon.node_id,
|
|
4944
|
+
lifecycle_state_before: priorState, lifecycle_state_after: "starting", ts_request: now },
|
|
4945
|
+
});
|
|
4946
|
+
});
|
|
4947
|
+
} catch (e: any) {
|
|
4948
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "dispatch_tx_failed", message: e?.message || String(e) }) }] };
|
|
4949
|
+
}
|
|
4950
|
+
pushEvent(daemon.alias, { type: "start_node", request_id: requestId }, child.network_id);
|
|
4951
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, lifecycle_state: "starting" }) }] };
|
|
4952
|
+
},
|
|
4953
|
+
);
|
|
4954
|
+
|
|
4955
|
+
server.tool(
|
|
4956
|
+
"get_start_request",
|
|
4957
|
+
"Host supervisor pulls an authenticated pending start request.",
|
|
4958
|
+
{ request_id: z.string().min(1).max(200) },
|
|
4959
|
+
async ({ request_id }) => {
|
|
4960
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
4961
|
+
if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
4962
|
+
const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
|
|
4963
|
+
`SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id = ?1`, request_id,
|
|
4964
|
+
);
|
|
4965
|
+
if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
4966
|
+
if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
4967
|
+
if (row.network_id !== callerDaemon.networkId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
|
|
4968
|
+
if (!['pending', 'delivered'].includes(row.status)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_startable", status: row.status }) }] };
|
|
4969
|
+
db.run(`UPDATE node_start_requests SET status='delivered', delivered_at=?1 WHERE request_id=?2 AND status='pending'`, [Date.now(), request_id]);
|
|
4970
|
+
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 }) }] };
|
|
4971
|
+
},
|
|
4972
|
+
);
|
|
4973
|
+
|
|
4974
|
+
server.tool(
|
|
4975
|
+
"ack_start_request",
|
|
4976
|
+
"Host supervisor reports start completion or failure.",
|
|
4977
|
+
{
|
|
4978
|
+
request_id: z.string().min(1).max(200),
|
|
4979
|
+
status: z.enum(["started", "start_failed"]),
|
|
4980
|
+
child_pid: z.number().int().positive().optional(),
|
|
4981
|
+
error: z.string().max(1000).optional(),
|
|
4982
|
+
},
|
|
4983
|
+
async ({ request_id, status, child_pid, error: ackError }) => {
|
|
4984
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
4985
|
+
if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
4986
|
+
const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
|
|
4987
|
+
`SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id=?1`, request_id,
|
|
4988
|
+
);
|
|
4989
|
+
if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
4990
|
+
if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
4991
|
+
if (row.network_id !== callerDaemon.networkId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
|
|
4992
|
+
if (row.status === "started" || row.status === "start_failed") {
|
|
4993
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: row.status, idempotent: true }) }] };
|
|
4994
|
+
}
|
|
4995
|
+
if (!['pending', 'delivered'].includes(row.status)) {
|
|
4996
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_ackable", status: row.status }) }] };
|
|
4997
|
+
}
|
|
4998
|
+
if (status === "started" && !child_pid) {
|
|
4999
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "child_pid_required" }) }] };
|
|
5000
|
+
}
|
|
5001
|
+
const now = Date.now();
|
|
5002
|
+
try {
|
|
5003
|
+
db.transaction(() => {
|
|
5004
|
+
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]);
|
|
5005
|
+
db.run(`UPDATE nodes SET lifecycle_state=?1 WHERE node_id=?2`, [status === "started" ? "active" : "stopped", row.child_node_id]);
|
|
5006
|
+
if (status === "started") auditCreateNodeStrict({
|
|
5007
|
+
action: "start_node_completed", network_id: row.network_id, target_id: request_id,
|
|
5008
|
+
detail: { child_node_id: row.child_node_id, child_alias: row.child_alias, child_pid, lifecycle_state_after: "active", ts_daemon_ack: now },
|
|
5009
|
+
});
|
|
5010
|
+
});
|
|
5011
|
+
} catch (e: any) {
|
|
5012
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "finalize_tx_failed", message: e?.message || String(e) }) }] };
|
|
5013
|
+
}
|
|
2148
5014
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
|
|
2149
5015
|
},
|
|
2150
5016
|
);
|
|
2151
5017
|
|
|
5018
|
+
// RFC-027 PR1.1 — list_my_children: daemon-only query. Returns the
|
|
5019
|
+
// {child_node_id, alias, lifecycle_state} tuples for nodes whose
|
|
5020
|
+
// active create-request has this daemon as daemon_node_id. Used at
|
|
5021
|
+
// daemon boot to rebuild the in-memory childrenMap (PR1 dropped
|
|
5022
|
+
// every entry on daemon restart → stop/delete silently no-op'd).
|
|
5023
|
+
//
|
|
5024
|
+
// Network-scope is the daemon's own (resolveCallerDaemonTokenBound
|
|
5025
|
+
// already binds tokenIsNetwork to a single network). No SEC-1 leak:
|
|
5026
|
+
// returns ONLY children whose request landed under this daemon's
|
|
5027
|
+
// token + network.
|
|
5028
|
+
server.tool(
|
|
5029
|
+
"list_my_children",
|
|
5030
|
+
"Daemon pulls the alias + node_id list of children it spawned (for childrenMap rebuild after restart). RFC-027 PR1.1.",
|
|
5031
|
+
{},
|
|
5032
|
+
async () => {
|
|
5033
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
5034
|
+
if (!callerDaemon.ok) {
|
|
5035
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
5036
|
+
}
|
|
5037
|
+
// node_create_requests carries the canonical authoritative
|
|
5038
|
+
// child→daemon mapping (the row id IS the request, and the
|
|
5039
|
+
// child node_id derives from it deterministically via
|
|
5040
|
+
// `node_${request_id.replace(/^cr_/, "")}`). Filter to children
|
|
5041
|
+
// that completed registration (have a nodes row) so we don't
|
|
5042
|
+
// ask the daemon to pgrep for children that never came up.
|
|
5043
|
+
const rows = db.all<{ request_id: string; child_name: string; child_node_id: string; lifecycle_state: string | null }>(
|
|
5044
|
+
`SELECT ncr.request_id, ncr.child_name,
|
|
5045
|
+
('node_' || substr(ncr.request_id, 4)) AS child_node_id,
|
|
5046
|
+
n.lifecycle_state
|
|
5047
|
+
FROM node_create_requests ncr
|
|
5048
|
+
LEFT JOIN nodes n ON n.node_id = ('node_' || substr(ncr.request_id, 4))
|
|
5049
|
+
WHERE ncr.daemon_node_id = ?1
|
|
5050
|
+
AND ncr.network_id = ?2
|
|
5051
|
+
AND ncr.status IN ('succeeded', 'delivered')
|
|
5052
|
+
AND n.node_id IS NOT NULL
|
|
5053
|
+
AND COALESCE(n.lifecycle_state, 'active') NOT IN ('stopped', 'stop_failed')`,
|
|
5054
|
+
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
5055
|
+
);
|
|
5056
|
+
const children = rows.map(r => ({
|
|
5057
|
+
child_node_id: r.child_node_id,
|
|
5058
|
+
alias: r.child_name,
|
|
5059
|
+
lifecycle_state: r.lifecycle_state ?? "active",
|
|
5060
|
+
}));
|
|
5061
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: children.length, children }) }] };
|
|
5062
|
+
},
|
|
5063
|
+
);
|
|
5064
|
+
|
|
2152
5065
|
// ── RFC-028 P1 — Provider & Model Registry + connectivity probe ──
|
|
2153
5066
|
// Background timers (probe GC + sweeper) idempotent; safe to call
|
|
2154
5067
|
// per registerTools.
|
|
@@ -2377,6 +5290,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2377
5290
|
const willReplaceModels = patch.models !== undefined;
|
|
2378
5291
|
const newModelIds: string[] = [];
|
|
2379
5292
|
|
|
5293
|
+
// No-op early return (通信牛 nit): all patch fields matched the
|
|
5294
|
+
// existing row values + no models supplied → no real change to
|
|
5295
|
+
// persist. Returning here means we don't write an empty-diff
|
|
5296
|
+
// audit row (audit noise). Audit_log INSERT must only fire when
|
|
5297
|
+
// something actually changed.
|
|
5298
|
+
if (sets.length === 0 && !willReplaceModels) {
|
|
5299
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id, no_changes: true, hint: "patch fields matched existing row, no-op" }) }] };
|
|
5300
|
+
}
|
|
5301
|
+
|
|
2380
5302
|
try {
|
|
2381
5303
|
db.exec("BEGIN");
|
|
2382
5304
|
if (sets.length > 0) {
|
|
@@ -2415,10 +5337,6 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2415
5337
|
throw e;
|
|
2416
5338
|
}
|
|
2417
5339
|
|
|
2418
|
-
if (Object.keys(diff).length === 0 && !willReplaceModels) {
|
|
2419
|
-
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id, no_changes: true, hint: "patch fields matched existing row, no-op" }) }] };
|
|
2420
|
-
}
|
|
2421
|
-
|
|
2422
5340
|
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
2423
5341
|
ok: true, provider_id,
|
|
2424
5342
|
fields_changed: Object.keys(diff),
|
|
@@ -2628,6 +5546,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2628
5546
|
export interface UpsertNodeWithSec1GuardInput {
|
|
2629
5547
|
node_id: string;
|
|
2630
5548
|
callerNetworkId: string | null;
|
|
5549
|
+
callerUserId?: string | null;
|
|
5550
|
+
callerTokenId?: string | null;
|
|
2631
5551
|
node_name?: string | null;
|
|
2632
5552
|
alias?: string | null;
|
|
2633
5553
|
runtime?: string | null;
|
|
@@ -2640,19 +5560,60 @@ export interface UpsertNodeWithSec1GuardInput {
|
|
|
2640
5560
|
}
|
|
2641
5561
|
export type UpsertNodeOutcome =
|
|
2642
5562
|
| { result: "inserted" | "updated"; node_id: string }
|
|
2643
|
-
| { result: "refused"; reason: "cross_network"; existingNet: string | null; callerNet: string | null }
|
|
5563
|
+
| { result: "refused"; reason: "cross_network" | "token_node_mismatch" | "owner_mismatch"; existingNet: string | null; callerNet: string | null }
|
|
2644
5564
|
| { result: "skipped"; reason: "missing_node_id" };
|
|
2645
5565
|
|
|
2646
5566
|
const _norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x);
|
|
2647
5567
|
|
|
5568
|
+
/**
|
|
5569
|
+
* A capability that changes peer delivery semantics is trusted only when the
|
|
5570
|
+
* authenticating ntok is immutably bound to the exact reported node. Legacy
|
|
5571
|
+
* alias-only tokens may still heartbeat, but cannot opt a node into #698.
|
|
5572
|
+
*/
|
|
5573
|
+
export function trustedConfigSnapshotForNode(
|
|
5574
|
+
snapshot: unknown | null | undefined,
|
|
5575
|
+
callerTokenId: string | null | undefined,
|
|
5576
|
+
nodeId: string,
|
|
5577
|
+
): unknown | null | undefined {
|
|
5578
|
+
if (!snapshot || typeof snapshot !== "object") return snapshot;
|
|
5579
|
+
const copy = { ...(snapshot as Record<string, unknown>) };
|
|
5580
|
+
const token = callerTokenId
|
|
5581
|
+
? db.get<{ bound_node_id: string | null }>(
|
|
5582
|
+
"SELECT bound_node_id FROM api_tokens WHERE token_id = ?1",
|
|
5583
|
+
callerTokenId,
|
|
5584
|
+
)
|
|
5585
|
+
: null;
|
|
5586
|
+
if (token?.bound_node_id !== nodeId) delete copy.peer_reply_inbox_capable;
|
|
5587
|
+
return copy;
|
|
5588
|
+
}
|
|
5589
|
+
|
|
2648
5590
|
export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): UpsertNodeOutcome {
|
|
2649
5591
|
if (!input.node_id) return { result: "skipped", reason: "missing_node_id" };
|
|
2650
|
-
const existing = db.get<{ network_id: string | null }>(
|
|
2651
|
-
"SELECT network_id FROM nodes WHERE node_id = ?1",
|
|
5592
|
+
const existing = db.get<{ network_id: string | null; owner_user_id: string | null }>(
|
|
5593
|
+
"SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1",
|
|
2652
5594
|
input.node_id,
|
|
2653
5595
|
);
|
|
2654
5596
|
const callerNet = input.callerNetworkId;
|
|
2655
5597
|
|
|
5598
|
+
// RFC-036 — when a token was minted with a node_id binding, a heartbeat may
|
|
5599
|
+
// report only that exact node. Legacy unbound ntok rows remain compatible,
|
|
5600
|
+
// but they can never consume owner-gated schedule intents.
|
|
5601
|
+
if (input.callerTokenId) {
|
|
5602
|
+
const token = db.get<{ bound_node_id: string | null; user_id: string; network_id: string | null }>(
|
|
5603
|
+
"SELECT bound_node_id, user_id, network_id FROM api_tokens WHERE token_id = ?1",
|
|
5604
|
+
input.callerTokenId,
|
|
5605
|
+
);
|
|
5606
|
+
if (token?.bound_node_id && token.bound_node_id !== input.node_id) {
|
|
5607
|
+
return { result: "refused", reason: "token_node_mismatch", existingNet: existing?.network_id ?? null, callerNet };
|
|
5608
|
+
}
|
|
5609
|
+
if (token?.network_id && _norm(token.network_id) !== _norm(callerNet)) {
|
|
5610
|
+
return { result: "refused", reason: "cross_network", existingNet: existing?.network_id ?? null, callerNet };
|
|
5611
|
+
}
|
|
5612
|
+
}
|
|
5613
|
+
if (existing?.owner_user_id && input.callerUserId !== existing.owner_user_id) {
|
|
5614
|
+
return { result: "refused", reason: "owner_mismatch", existingNet: existing.network_id, callerNet };
|
|
5615
|
+
}
|
|
5616
|
+
|
|
2656
5617
|
// Legacy / first-write paths: row missing OR network_id NULL → claim.
|
|
2657
5618
|
const isLegacy = !existing
|
|
2658
5619
|
|| existing.network_id === null
|
|
@@ -2698,10 +5659,38 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
|
|
|
2698
5659
|
callerNet ?? null,
|
|
2699
5660
|
],
|
|
2700
5661
|
);
|
|
2701
|
-
|
|
5662
|
+
const trustedSnapshot = trustedConfigSnapshotForNode(
|
|
5663
|
+
input.config_snapshot,
|
|
5664
|
+
input.callerTokenId,
|
|
5665
|
+
input.node_id,
|
|
5666
|
+
);
|
|
5667
|
+
if (trustedSnapshot) {
|
|
5668
|
+
// RFC-026 §9.3 / #338 PR2+PR3 — promote daemon self-declare fields
|
|
5669
|
+
// to first-class indexable columns alongside the snapshot blob.
|
|
5670
|
+
// The snapshot stays the source of truth for non-list reads; the
|
|
5671
|
+
// columns exist so `list_host_supervisors` doesn't JSON.parse on
|
|
5672
|
+
// every call. typeof-narrow per
|
|
5673
|
+
// per team rule (typeof-narrow extracted JSON fields at the boundary) — zod narrowed but
|
|
5674
|
+
// input.config_snapshot is typed `unknown` here.
|
|
5675
|
+
//
|
|
5676
|
+
// PR3 nit ①: read from nested `daemon_capabilities.*` (canonical
|
|
5677
|
+
// per RFC §9.3 + matches existing hub create_node reads at
|
|
5678
|
+
// tools.ts:2010/2075). PR2 ate from top-level keys, which the
|
|
5679
|
+
// hub create_node path never read → max_concurrent_children
|
|
5680
|
+
// backpressure was dead config + allowlist enforcement bypassed.
|
|
5681
|
+
const snap = trustedSnapshot as Record<string, unknown> | null;
|
|
5682
|
+
const caps = (snap?.daemon_capabilities ?? null) as Record<string, unknown> | null;
|
|
5683
|
+
const runtimesRaw = caps?.runtimes_supported;
|
|
5684
|
+
const allowedRaw = caps?.allowed_secret_keys;
|
|
5685
|
+
const runtimesJson = Array.isArray(runtimesRaw) && runtimesRaw.every(s => typeof s === "string")
|
|
5686
|
+
? JSON.stringify(runtimesRaw)
|
|
5687
|
+
: null;
|
|
5688
|
+
const allowedJson = Array.isArray(allowedRaw) && allowedRaw.every(s => typeof s === "string")
|
|
5689
|
+
? JSON.stringify(allowedRaw)
|
|
5690
|
+
: null;
|
|
2702
5691
|
db.run(
|
|
2703
|
-
`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?
|
|
2704
|
-
[JSON.stringify(
|
|
5692
|
+
`UPDATE nodes SET config_snapshot = ?1, runtimes_supported = ?2, allowed_secret_keys = ?3 WHERE node_id = ?4`,
|
|
5693
|
+
[JSON.stringify(trustedSnapshot), runtimesJson, allowedJson, input.node_id],
|
|
2705
5694
|
);
|
|
2706
5695
|
// RFC-024 — finalize any pending/restarting update whose target
|
|
2707
5696
|
// patch is now reflected in the snapshot. Closes the
|
|
@@ -2711,7 +5700,7 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
|
|
|
2711
5700
|
// update_id to call ack_config_update(applied) itself. Hub does
|
|
2712
5701
|
// it on the new child's behalf by content-matching the patch
|
|
2713
5702
|
// against the reported snapshot.
|
|
2714
|
-
finalizePendingMatchingUpdates(input.node_id,
|
|
5703
|
+
finalizePendingMatchingUpdates(input.node_id, trustedSnapshot);
|
|
2715
5704
|
}
|
|
2716
5705
|
// RFC-026 §2.5 step 4 — on every register/report_status, opportunistically
|
|
2717
5706
|
// close out any pending create_node request whose child_name matches
|
|
@@ -2771,6 +5760,15 @@ export function finalizePendingMatchingUpdates(
|
|
|
2771
5760
|
|
|
2772
5761
|
const snapModel: string | null | undefined = snapshot?.model;
|
|
2773
5762
|
const snapFlags: Record<string, unknown> = (snapshot?.flags && typeof snapshot.flags === "object") ? snapshot.flags : {};
|
|
5763
|
+
// #260 P5 — snapshot.channels is the (sorted, bare-type) channel set
|
|
5764
|
+
// the node currently has forked. agent-node's buildConfigSnapshot
|
|
5765
|
+
// always emits it (even as []), so the "absent field" case here means
|
|
5766
|
+
// an older pre-#260-P5 agent-node — for those, don't finalize a
|
|
5767
|
+
// channels-carrying patch (would false-positive on the OTHER fields).
|
|
5768
|
+
const snapChannelsPresent = Array.isArray(snapshot?.channels);
|
|
5769
|
+
const snapChannels: string[] = snapChannelsPresent
|
|
5770
|
+
? (snapshot.channels as unknown[]).filter((c): c is string => typeof c === "string")
|
|
5771
|
+
: [];
|
|
2774
5772
|
|
|
2775
5773
|
const finalizedIds: string[] = [];
|
|
2776
5774
|
|
|
@@ -2795,6 +5793,35 @@ export function finalizePendingMatchingUpdates(
|
|
|
2795
5793
|
if (snapFlags[k] !== v) { matches = false; break; }
|
|
2796
5794
|
}
|
|
2797
5795
|
}
|
|
5796
|
+
// #260 P5 — channels field content-match. Without this the
|
|
5797
|
+
// node's very first startup report_status matches (patch.flags
|
|
5798
|
+
// is `{}` for a channels-only update, so the flags loop is
|
|
5799
|
+
// trivially satisfied) and hub prematurely marks the update
|
|
5800
|
+
// applied, deleting the pending row + bumping config_revision
|
|
5801
|
+
// BEFORE the doorbell reaches the child. Codex catch on PR #411.
|
|
5802
|
+
//
|
|
5803
|
+
// Set-equality is enough because the patch side comes from
|
|
5804
|
+
// narrowChannelsPatch → already deduped + case-folded + in
|
|
5805
|
+
// EDITABLE_CHANNELS iteration order; the snapshot side comes
|
|
5806
|
+
// from buildConfigSnapshot which mirrors the same shape.
|
|
5807
|
+
if (matches && Array.isArray(patch.channels)) {
|
|
5808
|
+
if (!snapChannelsPresent) {
|
|
5809
|
+
// Pre-#260-P5 agent-node — no channels field in snapshot,
|
|
5810
|
+
// so we can't prove the apply landed. Skip finalize; hub's
|
|
5811
|
+
// F-B reaper still supersedes this eventually.
|
|
5812
|
+
matches = false;
|
|
5813
|
+
} else {
|
|
5814
|
+
const wanted = new Set<string>(patch.channels);
|
|
5815
|
+
const have = new Set<string>(snapChannels);
|
|
5816
|
+
if (wanted.size !== have.size) {
|
|
5817
|
+
matches = false;
|
|
5818
|
+
} else {
|
|
5819
|
+
for (const w of wanted) {
|
|
5820
|
+
if (!have.has(w)) { matches = false; break; }
|
|
5821
|
+
}
|
|
5822
|
+
}
|
|
5823
|
+
}
|
|
5824
|
+
}
|
|
2798
5825
|
}
|
|
2799
5826
|
|
|
2800
5827
|
if (!matches) continue;
|