@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.71
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 +130 -10
- 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-delete-network-http.test.ts +177 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access.ts +461 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +167 -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-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +423 -29
- 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 +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- 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-translate.test.ts +112 -0
- package/src/db-adapter.ts +246 -83
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +799 -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 +321 -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 +404 -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 +732 -0
- package/src/health-redaction.test.ts +642 -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/human-dm.ts +149 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -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 +224 -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-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -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/pg-worker.ts +87 -0
- package/src/push.ts +204 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- 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/requirement-tags.ts +16 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +708 -0
- package/src/requirements-mcp.test.ts +142 -0
- package/src/requirements-migrate.ts +83 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +899 -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 +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -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-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +625 -0
- package/src/scheduled-tasks.ts +597 -0
- package/src/send-dedup-request-id.test.ts +229 -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/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4385 -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/status-alias-filter-http.test.ts +106 -0
- package/src/stop-delete-node.test.ts +1164 -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 +3446 -257
- 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,14 +1,25 @@
|
|
|
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 { addAgentNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
12
|
+
import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS } from "./agent-access.js";
|
|
13
|
+
import { restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
14
|
+
import { handleRequirementsRequest } from "./requirements.js";
|
|
6
15
|
import {
|
|
7
16
|
buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
|
|
8
17
|
validateName as validateChildName,
|
|
9
18
|
validateRuntime,
|
|
10
19
|
validateModel,
|
|
11
20
|
validateChannelsP1,
|
|
21
|
+
validateWorkdir,
|
|
22
|
+
daemonDefaultWorkdirRoot,
|
|
12
23
|
validateEnvRefs,
|
|
13
24
|
FLAG_KEYS,
|
|
14
25
|
validateFlagValue,
|
|
@@ -22,12 +33,14 @@ import {
|
|
|
22
33
|
startPendingEnvGcTimer,
|
|
23
34
|
startSweeperTimer,
|
|
24
35
|
auditCreateNode,
|
|
36
|
+
auditCreateNodeStrict,
|
|
25
37
|
resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
|
|
26
38
|
} from "./create-node.js";
|
|
27
39
|
import {
|
|
28
40
|
vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
|
|
29
41
|
VaultError,
|
|
30
42
|
} from "./vault.js";
|
|
43
|
+
import { stripHostLocalPathsForCrossHostSafe, validateAttachments } from "./uploads.js";
|
|
31
44
|
import {
|
|
32
45
|
validateBaseUrl as _validateBaseUrl,
|
|
33
46
|
SUPPORTED_VENDORS,
|
|
@@ -44,11 +57,17 @@ import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias
|
|
|
44
57
|
import {
|
|
45
58
|
ALLOWED_FLAGS,
|
|
46
59
|
SECURITY_SENSITIVE_FLAGS,
|
|
60
|
+
EDITABLE_CHANNELS,
|
|
47
61
|
computeApplyMode,
|
|
48
62
|
validatePatch,
|
|
63
|
+
narrowChannelsPatch,
|
|
49
64
|
isAllowedToChangeFlag,
|
|
50
65
|
} from "./config-apply-validate.js";
|
|
51
66
|
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
67
|
+
import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
|
|
68
|
+
import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
|
|
69
|
+
import { parseHubTimestamp } from "./hub-timestamp";
|
|
70
|
+
import { noteTerminalResultRead, purgeLogsResultNow, sweepNodeRequestContent } from "./node-request-retention.js";
|
|
52
71
|
|
|
53
72
|
function ts(): string {
|
|
54
73
|
return new Date().toTimeString().slice(0, 8);
|
|
@@ -61,10 +80,93 @@ function parseMetaJson(value: unknown): unknown | null {
|
|
|
61
80
|
|
|
62
81
|
function normalizeMetaJson(meta: unknown): string | null {
|
|
63
82
|
if (!meta || typeof meta !== "object") return null;
|
|
64
|
-
|
|
83
|
+
// #222 cross-host safety — strip `path` from meta.attachments[] when
|
|
84
|
+
// `file_id` is also present. Shared helper lives in uploads.ts so the
|
|
85
|
+
// REST handler in index.ts can apply the identical sanitization
|
|
86
|
+
// (otherwise REST and MCP transports would have different meta_json
|
|
87
|
+
// shapes for the same send).
|
|
88
|
+
try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── #1281 — 子节点生命周期工具的参数名统一 ────────────────────────────
|
|
92
|
+
//
|
|
93
|
+
// stop_node / start_node / delete_node 历史上用 `child_node_id`(RFC-027),
|
|
94
|
+
// restart_node / update_node_config 用 `node_id`。同一个对象(子节点)两个名字,
|
|
95
|
+
// 调用方按一个工具的习惯给另一个传就吃 -32602。两侧都已是**已发布契约**
|
|
96
|
+
// (start_node 随 #1273 合入),且 hub API 在 RFC-030 混版滚动期老客户端还在
|
|
97
|
+
// 发老名——所以不能硬改名,改为**两个名字都接受**:
|
|
98
|
+
// · canonical = `node_id`(= DB 列 nodes.node_id、= create_node 的输出、
|
|
99
|
+
// restart/update 历史用名;「建完节点→再操作它」用同一个名才是傻瓜式)。
|
|
100
|
+
// · `child_node_id` 作为**兼容 alias 保留** + deprecation 注释;不加运行时
|
|
101
|
+
// warning(老客户端是设计内兼容,不是错误,别刷屏)。
|
|
102
|
+
// create_node 的 `daemon_node_id` 指宿主 daemon,是另一层语义,不在本次统一内。
|
|
103
|
+
//
|
|
104
|
+
// server.tool 收的是 ZodRawShape(裸对象),挂不了 .refine(),所以「至少一个
|
|
105
|
+
// 必填」+「两个都传须相等」在 handler 侧用 resolveNodeIdArg 兜——返回结构化
|
|
106
|
+
// ok:false 反而比 schema 层的 -32602 更友好。两个名字在 schema 层都做成
|
|
107
|
+
// optional + 宽松 .min(1).max(200)(不带 regex),使命名与校验强度同时统一:
|
|
108
|
+
// 对 restart/update 不变(本就宽松),对 stop/start/delete 是放宽(原 regex 会
|
|
109
|
+
// 在 schema 层挡掉 alias/杂串,放宽后落到查表返回 node_not_found,对合法调用
|
|
110
|
+
// 方无影响,只是错误形态更一致)。
|
|
111
|
+
const NODE_ID_ALIAS_FIELDS = {
|
|
112
|
+
node_id: z.string().min(1).max(200).optional()
|
|
113
|
+
.describe("Target child node ID (canonical, #1281). The value create_node returns. Must be in caller's network."),
|
|
114
|
+
child_node_id: z.string().min(1).max(200).optional()
|
|
115
|
+
.describe("DEPRECATED alias for node_id (#1281): kept for back-compat with pre-unification clients; prefer node_id."),
|
|
116
|
+
} as const;
|
|
117
|
+
|
|
118
|
+
/** #1281 — 把 node_id / child_node_id 收敛成单一内部 id。两个都传且不等 ⇒
|
|
119
|
+
* node_id_conflict(挡「node_id 填 A、child_node_id 填 B」的手滑,比单纯二选一强);
|
|
120
|
+
* 都不传 ⇒ node_id_required。纯函数,模块级导出以便单测直接验解析逻辑。 */
|
|
121
|
+
export function resolveNodeIdArg(a: { node_id?: string; child_node_id?: string }):
|
|
122
|
+
{ ok: true; node_id: string } | { ok: false; error: string; message: string } {
|
|
123
|
+
const n = a.node_id, c = a.child_node_id;
|
|
124
|
+
if (n && c && n !== c) {
|
|
125
|
+
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)" };
|
|
126
|
+
}
|
|
127
|
+
const id = n ?? c;
|
|
128
|
+
if (!id) {
|
|
129
|
+
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)" };
|
|
130
|
+
}
|
|
131
|
+
return { ok: true, node_id: id };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function guardRestrictedMemberTools(server: McpServer, restrictedNets: string[], _userId: string): void {
|
|
135
|
+
const denied = (tool: string) => ({
|
|
136
|
+
content: [{
|
|
137
|
+
type: "text" as const,
|
|
138
|
+
text: JSON.stringify({
|
|
139
|
+
ok: false,
|
|
140
|
+
error: "agent_access_restricted",
|
|
141
|
+
message: `${tool} is not available to restricted members in this network; pass network_id of a network where you are not restricted, or ask a network admin for access`,
|
|
142
|
+
}),
|
|
143
|
+
}],
|
|
144
|
+
});
|
|
145
|
+
const wrap = (name: string, handler: (...callArgs: any[]) => any) => async (...callArgs: any[]) => {
|
|
146
|
+
if (!RESTRICTED_MEMBER_TOOLS.has(name)) {
|
|
147
|
+
const args = callArgs[0];
|
|
148
|
+
const raw = args && typeof args === "object" ? (args as Record<string, unknown>).network_id : undefined;
|
|
149
|
+
const net = typeof raw === "string" && raw.trim() ? raw.trim() : null;
|
|
150
|
+
if (!net || restrictedNets.includes(net)) return denied(name);
|
|
151
|
+
}
|
|
152
|
+
return handler(...callArgs);
|
|
153
|
+
};
|
|
154
|
+
const anyServer = server as any;
|
|
155
|
+
for (const method of ["tool", "registerTool"] as const) {
|
|
156
|
+
const original = anyServer[method].bind(server);
|
|
157
|
+
anyServer[method] = (...regArgs: any[]) => {
|
|
158
|
+
const last = regArgs.length - 1;
|
|
159
|
+
if (typeof regArgs[0] === "string" && typeof regArgs[last] === "function") regArgs[last] = wrap(regArgs[0], regArgs[last]);
|
|
160
|
+
return original(...regArgs);
|
|
161
|
+
};
|
|
162
|
+
}
|
|
65
163
|
}
|
|
66
164
|
|
|
67
165
|
export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null) {
|
|
166
|
+
// 多用户 Agent 权限:用户令牌调用者在哪些网络里是受限成员(只看授权 Agent)。
|
|
167
|
+
// 网络令牌不会走到这里 —— 受限成员的网络令牌在 resolveToken 就被拒了。
|
|
168
|
+
const restrictedNets = enforceUserId && !callerTokenIsNetwork && !enforceNetworkId ? restrictedNetworkIds(enforceUserId) : [];
|
|
169
|
+
if (restrictedNets.length) guardRestrictedMemberTools(server, restrictedNets, enforceUserId!);
|
|
68
170
|
// Default from_session for outbound tools — extracted from the calling
|
|
69
171
|
// token's binding (ntok_ → node alias, utok_ → username). Without this,
|
|
70
172
|
// an agent's send_task call always claimed from='hub' and peer agents
|
|
@@ -87,30 +189,238 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
87
189
|
}],
|
|
88
190
|
};
|
|
89
191
|
};
|
|
90
|
-
//
|
|
91
|
-
|
|
192
|
+
// MCP-side auth context in the shape network-scope.ts helpers take.
|
|
193
|
+
// null = legacy global-token / open-dev mode (unscoped, allow-all).
|
|
194
|
+
const mcpAuthCtx = enforceUserId ? { userId: enforceUserId, networkId: enforceNetworkId ?? null } : null;
|
|
195
|
+
|
|
196
|
+
// If enforceNetworkId is set (ntok_), override any client-supplied
|
|
197
|
+
// network_id. #517: for utok_ with no explicit network_id, fall back to
|
|
198
|
+
// the user's single membership via the SAME singleNetworkId used by REST
|
|
199
|
+
// POST /api/task (network-scope.ts) — utok_ rows carry network_id=null by
|
|
200
|
+
// design, so without this fallback single-network nodes could read
|
|
201
|
+
// everything but write nothing. Multi-network stays null (ambiguous) and
|
|
202
|
+
// canWrite rejects with network_id_required.
|
|
203
|
+
const getNetworkId = (clientNetId?: string | null) => {
|
|
204
|
+
const explicit = enforceNetworkId ?? clientNetId ?? null;
|
|
205
|
+
if (explicit !== null || !enforceUserId) return explicit;
|
|
206
|
+
return singleNetworkId({ networkId: null, networkIds: getUserNetworkIds(enforceUserId) });
|
|
207
|
+
};
|
|
92
208
|
|
|
93
|
-
// Check write access
|
|
94
|
-
//
|
|
95
|
-
//
|
|
209
|
+
// Check write access — delegates to the shared canRestWriteNetwork so MCP
|
|
210
|
+
// and REST cannot drift again (#517). isAdmin=false: MCP has no admin
|
|
211
|
+
// bypass today; keep behavior identical to before the extraction.
|
|
96
212
|
const canWrite = (effectiveNetworkId?: string | null): boolean => {
|
|
97
|
-
if (!enforceUserId) return true; // legacy global token mode, allow
|
|
98
213
|
const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
214
|
+
return canRestWriteNetwork(mcpAuthCtx, netId, false);
|
|
215
|
+
};
|
|
216
|
+
// 人类侧写入(私信):不看 Agent 授权,受限成员也可以。
|
|
217
|
+
const canWriteHuman = (effectiveNetworkId?: string | null): boolean =>
|
|
218
|
+
canRestWriteNetworkAsHuman(mcpAuthCtx, enforceNetworkId ?? effectiveNetworkId ?? null, false);
|
|
219
|
+
// 给 Agent 发任务 / 消息的准入。不受限:原判据。受限网络里:目标必须是授权且 can_message 的 Agent,
|
|
220
|
+
// 发件人固定为自己的用户名;目标不存在与没授权返回同一个错误,不留 alias 探测差异。
|
|
221
|
+
const agentSendDenied = (effectiveNetworkId: string | null, alias: string, clientFrom: string | undefined, action: "send_task" | "write", meta?: unknown) => {
|
|
222
|
+
if (!effectiveNetworkId || !restrictedNets.includes(effectiveNetworkId)) {
|
|
223
|
+
return canWrite(effectiveNetworkId) ? null : writeDeniedReply(effectiveNetworkId, action);
|
|
224
|
+
}
|
|
225
|
+
if (!canWriteHuman(effectiveNetworkId)) return writeDeniedReply(effectiveNetworkId, action);
|
|
226
|
+
const requestedFrom = clientFrom?.trim();
|
|
227
|
+
if (requestedFrom && requestedFrom !== callerAlias) {
|
|
228
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "from_session_identity_mismatch", message: "restricted members always send as their own username" }) }] };
|
|
229
|
+
}
|
|
230
|
+
const targetAlias = resolveCanonicalAlias(effectiveNetworkId, alias).alias;
|
|
231
|
+
const session = db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", targetAlias, effectiveNetworkId);
|
|
232
|
+
if (!session || !canMessageAgent(enforceUserId!, effectiveNetworkId, { alias: targetAlias, nodeId: session.node_id })) {
|
|
233
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "agent_not_granted", message: "this agent has not been granted to you; ask a network admin" }) }] };
|
|
234
|
+
}
|
|
235
|
+
const rawAttachments = meta && typeof meta === "object" ? (meta as { attachments?: unknown }).attachments : undefined;
|
|
236
|
+
if (restrictedMemberAttachmentsDenied(enforceUserId!, callerAlias ?? "", effectiveNetworkId, rawAttachments)) {
|
|
237
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "attachment_not_accessible" }) }] };
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
102
240
|
};
|
|
103
241
|
|
|
242
|
+
// #517: name the REAL cause. The old catch-all blamed permissions for
|
|
243
|
+
// what was actually an unresolvable network, sending operators down the
|
|
244
|
+
// wrong debugging path (roles/membership) for hours.
|
|
104
245
|
const writeDeniedReply = (effectiveNetworkId?: string | null, action = "write") => {
|
|
105
246
|
const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
247
|
+
let error: string;
|
|
248
|
+
let message: string;
|
|
249
|
+
if (!netId) {
|
|
250
|
+
const memberships = enforceUserId ? getUserNetworkIds(enforceUserId) : [];
|
|
251
|
+
error = "network_id_required";
|
|
252
|
+
message = memberships.length === 0
|
|
253
|
+
? "user token has no network memberships; join or create a network first"
|
|
254
|
+
: `user token spans ${memberships.length} networks; pass network_id explicitly (see /api/auth/me networks[].network_id)`;
|
|
255
|
+
} else if (enforceUserId && !getUserNetworkRole(enforceUserId, netId)) {
|
|
256
|
+
error = "access_denied";
|
|
257
|
+
message = "access denied to requested network (not a member)";
|
|
258
|
+
} else {
|
|
259
|
+
error = "permission_denied";
|
|
260
|
+
message = action === "send_task"
|
|
109
261
|
? "Viewer role cannot send tasks"
|
|
110
262
|
: "Viewer role cannot write to this network";
|
|
111
|
-
|
|
263
|
+
}
|
|
264
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error, message }) }] };
|
|
112
265
|
};
|
|
113
266
|
|
|
267
|
+
const skillHubReply = (value: Record<string, unknown>) => ({
|
|
268
|
+
content: [{ type: "text" as const, text: JSON.stringify(value) }],
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// SkillHub is a network registry, not a public filesystem. Content is an
|
|
272
|
+
// immutable SKILL.md snapshot; identity comes only from the authenticated
|
|
273
|
+
// MCP principal (never from request fields).
|
|
274
|
+
server.tool(
|
|
275
|
+
"submit_skill",
|
|
276
|
+
"Submit an immutable SKILL.md version to the caller's network SkillHub. Node identity is token-bound. New submissions require review.",
|
|
277
|
+
{
|
|
278
|
+
slug: z.string().min(2).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
279
|
+
name: z.string().min(1).max(120),
|
|
280
|
+
description: z.string().max(1000).optional(),
|
|
281
|
+
version: z.string().min(1).max(40).regex(/^[0-9A-Za-z]+(?:[._-][0-9A-Za-z]+)*$/),
|
|
282
|
+
content: z.string().min(1).max(128 * 1024).refine(value => !value.includes("\0"), "content must not contain NUL bytes"),
|
|
283
|
+
network_id: z.string().max(200).optional(),
|
|
284
|
+
},
|
|
285
|
+
async ({ slug, name, description, version, content, network_id: clientNetId }) => {
|
|
286
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
287
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
288
|
+
if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
|
|
289
|
+
const sourceType = callerTokenIsNetwork ? "node" : "user";
|
|
290
|
+
if (sourceType === "node" && !callerAlias) return skillHubReply({ ok: false, error: "node_identity_unbound" });
|
|
291
|
+
const contentHash = createHash("sha256").update(content, "utf8").digest("hex");
|
|
292
|
+
const existing = db.get<any>(
|
|
293
|
+
`SELECT skill_id, content_hash, status FROM skillhub_skills
|
|
294
|
+
WHERE network_id = ?1 AND slug = ?2 AND version = ?3`,
|
|
295
|
+
effectiveNetId, slug, version,
|
|
296
|
+
);
|
|
297
|
+
if (existing) {
|
|
298
|
+
if (existing.content_hash === contentHash) {
|
|
299
|
+
return skillHubReply({ ok: true, idempotent: true, skill_id: existing.skill_id, status: existing.status });
|
|
300
|
+
}
|
|
301
|
+
return skillHubReply({ ok: false, error: "skill_version_conflict", hint: "publish changed content under a new version" });
|
|
302
|
+
}
|
|
303
|
+
const skillId = `skill_${uuidv4()}`;
|
|
304
|
+
try {
|
|
305
|
+
db.run(
|
|
306
|
+
`INSERT INTO skillhub_skills
|
|
307
|
+
(skill_id, network_id, slug, name, description, version, content, content_hash, status, source_type, source_alias, created_by_user)
|
|
308
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
|
|
309
|
+
[skillId, effectiveNetId, slug, name.trim(), description?.trim() || "", version, content, contentHash, sourceType, callerAlias, enforceUserId || null],
|
|
310
|
+
);
|
|
311
|
+
} catch (error: any) {
|
|
312
|
+
// Two submitters may pass the preflight SELECT together. Resolve the
|
|
313
|
+
// UNIQUE race into the same deterministic idempotent/conflict contract
|
|
314
|
+
// instead of leaking a storage exception as HTTP/MCP 500.
|
|
315
|
+
if (!/unique|duplicate key/i.test(error?.message || "")) throw error;
|
|
316
|
+
const winner = db.get<any>(
|
|
317
|
+
`SELECT skill_id, content_hash, status FROM skillhub_skills
|
|
318
|
+
WHERE network_id = ?1 AND slug = ?2 AND version = ?3`,
|
|
319
|
+
effectiveNetId, slug, version,
|
|
320
|
+
);
|
|
321
|
+
if (!winner) throw error;
|
|
322
|
+
if (winner.content_hash === contentHash) {
|
|
323
|
+
return skillHubReply({ ok: true, idempotent: true, skill_id: winner.skill_id, status: winner.status });
|
|
324
|
+
}
|
|
325
|
+
return skillHubReply({ ok: false, error: "skill_version_conflict", hint: "publish changed content under a new version" });
|
|
326
|
+
}
|
|
327
|
+
db.run(
|
|
328
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
329
|
+
VALUES (?1, ?2, 'skill_submit', 'skill', ?3, ?4, ?5)`,
|
|
330
|
+
[enforceUserId || null, callerAlias || null, skillId, JSON.stringify({ slug, version, source_type: sourceType }), effectiveNetId],
|
|
331
|
+
);
|
|
332
|
+
return skillHubReply({ ok: true, skill_id: skillId, status: "pending", source_type: sourceType, source_alias: callerAlias });
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
server.tool(
|
|
337
|
+
"list_skills",
|
|
338
|
+
"List published skills in a network. Owners/admins may include pending review items.",
|
|
339
|
+
{
|
|
340
|
+
network_id: z.string().max(200).optional(),
|
|
341
|
+
include_pending: z.boolean().optional(),
|
|
342
|
+
query: z.string().max(120).optional(),
|
|
343
|
+
limit: z.number().int().min(1).max(200).optional(),
|
|
344
|
+
},
|
|
345
|
+
async ({ network_id: clientNetId, include_pending, query, limit }) => {
|
|
346
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
347
|
+
if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "read");
|
|
348
|
+
const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
|
|
349
|
+
if (enforceUserId && !role) return writeDeniedReply(effectiveNetId, "read");
|
|
350
|
+
// An ntok_ belongs to a node even when it was minted by the network
|
|
351
|
+
// owner. Never inherit the owner's review power through that token.
|
|
352
|
+
const reviewer = !callerTokenIsNetwork && (role === "owner" || role === "admin");
|
|
353
|
+
const showPending = !!include_pending && reviewer;
|
|
354
|
+
const params: unknown[] = [effectiveNetId];
|
|
355
|
+
let sql = `SELECT skill_id, slug, name, description, version, status, source_type, source_alias,
|
|
356
|
+
created_at, updated_at, reviewed_at, review_note
|
|
357
|
+
FROM skillhub_skills WHERE network_id = ?1`;
|
|
358
|
+
if (!showPending) sql += ` AND status = 'published'`;
|
|
359
|
+
if (query?.trim()) {
|
|
360
|
+
params.push(`%${query.trim()}%`);
|
|
361
|
+
sql += ` AND (slug LIKE ?${params.length} OR name LIKE ?${params.length} OR description LIKE ?${params.length})`;
|
|
362
|
+
}
|
|
363
|
+
sql += ` ORDER BY updated_at DESC LIMIT ${limit ?? 100}`;
|
|
364
|
+
return skillHubReply({ ok: true, reviewer, skills: db.all(sql, ...params) });
|
|
365
|
+
},
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
server.tool(
|
|
369
|
+
"get_skill",
|
|
370
|
+
"Read one SkillHub SKILL.md. Pending content is visible only to owners/admins.",
|
|
371
|
+
{ skill_id: z.string().regex(/^skill_[A-Za-z0-9_-]+$/).max(200), network_id: z.string().max(200).optional() },
|
|
372
|
+
async ({ skill_id, network_id: clientNetId }) => {
|
|
373
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
374
|
+
if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "read");
|
|
375
|
+
const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
|
|
376
|
+
if (enforceUserId && !role) return writeDeniedReply(effectiveNetId, "read");
|
|
377
|
+
const row = db.get<any>(
|
|
378
|
+
`SELECT skill_id, slug, name, description, version, content, status,
|
|
379
|
+
source_type, source_alias, created_at, updated_at, reviewed_at, review_note
|
|
380
|
+
FROM skillhub_skills WHERE skill_id = ?1 AND network_id = ?2`,
|
|
381
|
+
skill_id, effectiveNetId,
|
|
382
|
+
);
|
|
383
|
+
if (!row) return skillHubReply({ ok: false, error: "skill_not_found" });
|
|
384
|
+
const reviewer = !callerTokenIsNetwork && (role === "owner" || role === "admin");
|
|
385
|
+
if (row.status !== "published" && !reviewer) {
|
|
386
|
+
return skillHubReply({ ok: false, error: "skill_not_found" });
|
|
387
|
+
}
|
|
388
|
+
return skillHubReply({ ok: true, skill: row });
|
|
389
|
+
},
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
server.tool(
|
|
393
|
+
"review_skill",
|
|
394
|
+
"Publish or reject a pending SkillHub submission. Network owner/admin only.",
|
|
395
|
+
{
|
|
396
|
+
skill_id: z.string().regex(/^skill_[A-Za-z0-9_-]+$/).max(200),
|
|
397
|
+
decision: z.enum(["published", "rejected"]),
|
|
398
|
+
note: z.string().max(1000).optional(),
|
|
399
|
+
network_id: z.string().max(200).optional(),
|
|
400
|
+
},
|
|
401
|
+
async ({ skill_id, decision, note, network_id: clientNetId }) => {
|
|
402
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
403
|
+
if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
|
|
404
|
+
const role = enforceUserId ? getUserNetworkRole(enforceUserId, effectiveNetId) : null;
|
|
405
|
+
if (callerTokenIsNetwork || (role !== "owner" && role !== "admin")) return skillHubReply({ ok: false, error: "skill_review_admin_required" });
|
|
406
|
+
const row = db.get<any>(`SELECT status FROM skillhub_skills WHERE skill_id = ?1 AND network_id = ?2`, skill_id, effectiveNetId);
|
|
407
|
+
if (!row) return skillHubReply({ ok: false, error: "skill_not_found" });
|
|
408
|
+
if (row.status !== "pending") return skillHubReply({ ok: false, error: "skill_not_pending", status: row.status });
|
|
409
|
+
const updated = db.run(
|
|
410
|
+
`UPDATE skillhub_skills SET status = ?1, review_note = ?2, reviewed_by_user = ?3,
|
|
411
|
+
reviewed_at = datetime('now'), updated_at = datetime('now') WHERE skill_id = ?4 AND network_id = ?5 AND status = 'pending'`,
|
|
412
|
+
[decision, note?.trim() || null, enforceUserId || null, skill_id, effectiveNetId],
|
|
413
|
+
);
|
|
414
|
+
if (updated.changes !== 1) return skillHubReply({ ok: false, error: "skill_not_pending" });
|
|
415
|
+
db.run(
|
|
416
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
417
|
+
VALUES (?1, ?2, 'skill_review', 'skill', ?3, ?4, ?5)`,
|
|
418
|
+
[enforceUserId || null, callerAlias || null, skill_id, JSON.stringify({ decision }), effectiveNetId],
|
|
419
|
+
);
|
|
420
|
+
return skillHubReply({ ok: true, skill_id, status: decision });
|
|
421
|
+
},
|
|
422
|
+
);
|
|
423
|
+
|
|
114
424
|
const addScope = (sql: string, params: any[], networkId?: string | null, column = "network_id"): string => {
|
|
115
425
|
if (!networkId) return sql;
|
|
116
426
|
sql += ` AND ${column} = ?${params.length + 1}`;
|
|
@@ -118,49 +428,72 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
118
428
|
return sql;
|
|
119
429
|
};
|
|
120
430
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
431
|
+
/** #1548 —— 把一个还标着 blocked 的会话拉回 idle。只在「它刚发出终态回复 / 刚派了任务」这两个活性证据处调用。 */
|
|
432
|
+
const releaseBlockedSession = (alias: string | null | undefined, networkId: string | null | undefined): void => {
|
|
433
|
+
if (!alias) return;
|
|
434
|
+
const params: any[] = [alias];
|
|
435
|
+
let sql = "UPDATE sessions SET status = 'idle', updated_at = datetime('now') WHERE alias = ?1 AND status = 'blocked'";
|
|
436
|
+
sql = addScope(sql, params, networkId ?? null);
|
|
437
|
+
try {
|
|
438
|
+
db.run(sql, params);
|
|
439
|
+
} catch {
|
|
440
|
+
// 展示层的一次修正失败不能影响主路径。
|
|
441
|
+
}
|
|
129
442
|
};
|
|
130
443
|
|
|
444
|
+
type ReadScope = RestNetworkScope;
|
|
445
|
+
|
|
446
|
+
// Delegates to the shared membership query (network-scope.ts) — was a
|
|
447
|
+
// byte-for-byte duplicate of getUserNetworkIds before #517.
|
|
448
|
+
const getReadableNetworkIds = (): string[] =>
|
|
449
|
+
enforceUserId ? getUserNetworkIds(enforceUserId) : [];
|
|
450
|
+
|
|
451
|
+
// 受限网络挂在 scope.agentRestriction 上:addReadScope(= addNetworkScope)对它们 fail-closed,
|
|
452
|
+
// 逐条审过的工具改用 addAgentNetworkScope / addOwnTrafficScope 按授权放行。
|
|
453
|
+
const withRestriction = (scope: ReadScope): ReadScope => {
|
|
454
|
+
if (!restrictedNets.length || !enforceUserId) return scope;
|
|
455
|
+
const inScope = scope.networkId ? [scope.networkId] : (scope.networkIds ?? []);
|
|
456
|
+
const restricted = restrictedNets.filter((id) => inScope.includes(id));
|
|
457
|
+
return restricted.length ? { ...scope, agentRestriction: { userId: enforceUserId, username: callerAlias ?? "", networkIds: restricted } } : scope;
|
|
458
|
+
};
|
|
131
459
|
const resolveReadScope = (clientNetId?: string | null): ReadScope => {
|
|
132
460
|
if (!enforceUserId) return { networkId: clientNetId ?? null, networkIds: null };
|
|
133
461
|
if (enforceNetworkId) {
|
|
134
462
|
const role = getUserNetworkRole(enforceUserId, enforceNetworkId);
|
|
135
|
-
return role ? { networkId: enforceNetworkId, networkIds: null } : { denied: "not a member of token network" };
|
|
463
|
+
return role ? { networkId: enforceNetworkId, networkIds: null } : { networkId: null, networkIds: [], denied: "not a member of token network" };
|
|
136
464
|
}
|
|
137
465
|
if (clientNetId) {
|
|
138
466
|
const role = getUserNetworkRole(enforceUserId, clientNetId);
|
|
139
|
-
return role ? { networkId: clientNetId, networkIds: null } : { denied: "access denied to requested network" };
|
|
467
|
+
return role ? withRestriction({ networkId: clientNetId, networkIds: null }) : { networkId: null, networkIds: [], denied: "access denied to requested network" };
|
|
140
468
|
}
|
|
141
|
-
return { networkId: null, networkIds: getReadableNetworkIds() };
|
|
469
|
+
return withRestriction({ networkId: null, networkIds: getReadableNetworkIds() });
|
|
142
470
|
};
|
|
143
471
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
return sql;
|
|
157
|
-
};
|
|
472
|
+
// RFC-027 §2.3 race-free invariant — assertNodeActive lives in
|
|
473
|
+
// server/src/lifecycle-guard.ts so REST handlers in server/src/index.ts
|
|
474
|
+
// can use the SAME code path. PR1.1 had it inline here; PR1.2a
|
|
475
|
+
// (#346 review catch) extracted because the closure scope made it
|
|
476
|
+
// unreachable from REST and left the §2.3 race open on dashboard
|
|
477
|
+
// Dispatch (POST /api/task + /api/broadcast). Per
|
|
478
|
+
// 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.
|
|
479
|
+
|
|
480
|
+
// 默认读作用域:对受限网络 fail-closed(见 network-scope.ts addNetworkScope)。
|
|
481
|
+
const addReadScope = (sql: string, params: any[], scope: ReadScope, column = "network_id"): string =>
|
|
482
|
+
addNetworkScope(sql, params, scope, column);
|
|
158
483
|
|
|
159
484
|
type DeliveryTarget =
|
|
160
485
|
| { state: "online"; alias: string; session: any }
|
|
161
486
|
| { state: "offline"; alias: string; session: any; message: string }
|
|
162
487
|
| { state: "not_found"; alias: string; message: string };
|
|
163
488
|
|
|
489
|
+
// Only call after the authenticated, network-scoped lookup resolved a
|
|
490
|
+
// concrete target. Error paths intentionally omit this identity object.
|
|
491
|
+
const actualTo = (target: Exclude<DeliveryTarget, { state: "not_found" }>, networkId?: string | null) => ({
|
|
492
|
+
alias: target.alias,
|
|
493
|
+
to_node_id: target.session?.node_id ?? null,
|
|
494
|
+
network_id: networkId ?? null,
|
|
495
|
+
});
|
|
496
|
+
|
|
164
497
|
const scopedSessionStatus = (alias: string, networkId?: string | null) => {
|
|
165
498
|
const params: any[] = [alias];
|
|
166
499
|
let sql = "SELECT status, updated_at, last_seen_at, node_id FROM sessions WHERE alias = ?1";
|
|
@@ -261,9 +594,39 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
261
594
|
model: z.string().max(200).optional().describe("AI model name"),
|
|
262
595
|
node_name: z.string().max(200).optional().describe("Stable node display name (may differ from alias)"),
|
|
263
596
|
network_id: z.string().max(200).optional().describe("Network this agent belongs to"),
|
|
597
|
+
// ╭─ 🔴 往 report_status 里加字段之前先读这一段(#1545 实测,zod 4.3.6)─────────╮
|
|
598
|
+
// │ **这份 schema 的子对象一半严一半松,而后果完全不同。**
|
|
599
|
+
// │
|
|
600
|
+
// │ 非 strict:host / process_telemetry / config_snapshot / daemon_capabilities
|
|
601
|
+
// │ `.strict()`:external_schedules(含其 schedules[] 元素)
|
|
602
|
+
// │ side_thread_capability(含其 exactBoundary)
|
|
603
|
+
// │
|
|
604
|
+
// │ 往**非 strict** 的对象里加一个 hub 还不认识的键 ⇒ zod **静默丢弃**。
|
|
605
|
+
// │ 节点不会掉线,但字段人间蒸发 —— 现场表现为「daemon 明明发了、hub 上没有」,
|
|
606
|
+
// │ 排查起来和 daemon 侧 bug 一模一样。
|
|
607
|
+
// │ 往**strict** 的对象里加同一个键 ⇒ `unrecognized_keys`,**整份 report_status 被拒**。
|
|
608
|
+
// │ 而 agent-node 的 register() 是 `await` 且无人 catch ⇒ **节点进程当场死掉**
|
|
609
|
+
// │ (#1225 就是这么全网躺倒的,见下面 host.ip 那段)。
|
|
610
|
+
// │
|
|
611
|
+
// │ 所以「新字段能不能 daemon 先发」**没有统一答案,取决于你往哪个子对象里加**:
|
|
612
|
+
// │ 非 strict → 可以先发(会丢,不会死);strict → hub 必须先合,否则升级即失联。
|
|
613
|
+
// │
|
|
614
|
+
// │ 🔴 别把这条读成「strict 更危险、都改成非 strict」。两者各有其位:
|
|
615
|
+
// │ external_schedules / side_thread_capability 是**有界快照**,strict 挡的是
|
|
616
|
+
// │ 「节点往里塞任意键」;daemon_capabilities 是**能力自述**,天然要向前兼容
|
|
617
|
+
// │ (旧 hub 见到新能力应当忽略而不是拒绝整份上报)。**加字段前先看清你在哪一边。**
|
|
618
|
+
// ╰──────────────────────────────────────────────────────────────────────────────╯
|
|
264
619
|
host: z.object({
|
|
265
|
-
|
|
266
|
-
ip:
|
|
620
|
+
// 🔴 `.nullable()` 不是防御性冗余,是**发送方声明的类型**:agent-node 的
|
|
621
|
+
// HostTelemetry 是 `ip: string | null`,`firstNonInternalIPv4()` 在没有
|
|
622
|
+
// 非回环 IPv4 的机器上返回 null(--network none 的容器、断网的笔记本、
|
|
623
|
+
// 部分 CI runner)。此前这里只有 `.optional()`,于是那台机器上的
|
|
624
|
+
// agent-node 一启动就收到 MCP -32602,而 register() 是 await 且无人 catch
|
|
625
|
+
// —— **整个节点进程当场死掉**,和 runtime 无关。
|
|
626
|
+
// 同一个对象里四个数值字段本来就写着 `.nullable().optional()`,
|
|
627
|
+
// 两个字符串字段是唯一的例外;正确写法一直在隔壁那一行。
|
|
628
|
+
hostname: z.string().max(200).nullable().optional(),
|
|
629
|
+
ip: z.string().max(200).nullable().optional(),
|
|
267
630
|
cpu_load_1min: z.number().nullable().optional(),
|
|
268
631
|
cpu_cores: z.number().nullable().optional(),
|
|
269
632
|
mem_total_gb: z.number().nullable().optional(),
|
|
@@ -281,6 +644,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
281
644
|
uptime_seconds: z.number().nullable().optional(),
|
|
282
645
|
in_flight_count: z.number().nullable().optional(),
|
|
283
646
|
}).optional().describe("Per-agent process telemetry reported by agent-node"),
|
|
647
|
+
external_schedules: z.object({
|
|
648
|
+
observed_at: z.string().datetime({ offset: true }).max(64),
|
|
649
|
+
schedules: z.array(z.object({
|
|
650
|
+
id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/),
|
|
651
|
+
name: z.string().min(1).max(200),
|
|
652
|
+
kind: z.enum(["cron", "systemd", "tmux", "playwright", "custom"]),
|
|
653
|
+
frequency: z.string().min(1).max(120),
|
|
654
|
+
last_run_at: z.string().datetime({ offset: true }).max(64).nullable(),
|
|
655
|
+
last_status: z.enum(["success", "failed", "running", "unknown"]),
|
|
656
|
+
last_error: z.string().max(500).nullable(),
|
|
657
|
+
next_run_at: z.string().datetime({ offset: true }).max(64).nullable(),
|
|
658
|
+
log_ref: z.string().min(1).max(255).nullable(),
|
|
659
|
+
enabled: z.boolean(),
|
|
660
|
+
// RFC-036 — only agent-node managed cron markers advertise write
|
|
661
|
+
// capability. Legacy/other kinds omit these fields and stay read-only.
|
|
662
|
+
editable: z.boolean().optional(),
|
|
663
|
+
revision: z.number().int().min(0).optional(),
|
|
664
|
+
}).strict()).max(64),
|
|
665
|
+
error: z.enum(["invalid_manifest", "unsafe_manifest", "read_failed"]).optional(),
|
|
666
|
+
}).strict().optional().describe("Bounded node-reported external schedule snapshot; never includes host paths or commands"),
|
|
284
667
|
// RFC-024 B6 — masked snapshot of the node's effective config
|
|
285
668
|
// (model + 6 dashboard-editable flags). Secrets ARE NOT in this
|
|
286
669
|
// shape (env._envRef stays on host); the dashboard reads this
|
|
@@ -291,21 +674,126 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
291
674
|
// can grey out remote-restart for them.
|
|
292
675
|
config_snapshot: z.object({
|
|
293
676
|
model: z.string().max(200).optional().nullable(),
|
|
294
|
-
flags: z.record(z.unknown()).optional(),
|
|
677
|
+
flags: z.record(z.string(), z.unknown()).optional(),
|
|
295
678
|
config_revision: z.number().int().min(0).optional(),
|
|
296
679
|
config_update_capable: z.boolean().optional(),
|
|
680
|
+
peer_reply_inbox_capable: z.literal(true).optional(),
|
|
681
|
+
side_thread_capability: z.object({
|
|
682
|
+
supported: z.boolean(),
|
|
683
|
+
runtime: z.string().max(64),
|
|
684
|
+
runtimeVersion: z.string().max(64),
|
|
685
|
+
topology: z.string().max(64),
|
|
686
|
+
evidenceRevision: z.string().max(100),
|
|
687
|
+
mode: z.literal("native-exact-fork").optional(),
|
|
688
|
+
exactBoundary: z.object({ through: z.boolean(), before: z.boolean() }).strict().optional(),
|
|
689
|
+
reason: z.enum(["runtime", "version", "topology", "experimental-api", "exact-boundary"]).optional(),
|
|
690
|
+
}).strict().optional(),
|
|
297
691
|
// RFC-026 P2 / #338 — daemon role surfaced for hub /api/nodes
|
|
298
692
|
// discovery (#337 extracts this field). "host_supervisor" =
|
|
299
693
|
// anet daemon. Default-stripping zod would drop this otherwise.
|
|
300
694
|
role: z.string().max(64).optional().nullable(),
|
|
301
|
-
// RFC-026 §9.3 — daemon self-declare
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
695
|
+
// RFC-026 §9.3 / #338 PR3 — daemon self-declare nested under
|
|
696
|
+
// `daemon_capabilities` (canonical shape per existing hub reads
|
|
697
|
+
// at tools.ts:2010/2075 — PR1/PR2 placed these top-level, hub
|
|
698
|
+
// never saw them, max_concurrent_children stayed default + the
|
|
699
|
+
// allowlists stayed unenforced. PR3 nit ① per 通信龙).
|
|
700
|
+
// Soft caps avoid abuse via attacker daemon.
|
|
701
|
+
// 🔴 这个对象**故意不是 `.strict()`** —— 能力自述必须向前兼容:
|
|
702
|
+
// 旧 hub 见到新 daemon 报的新能力,应当忽略那一格,而不是拒掉整份 report
|
|
703
|
+
// 把节点踢下线。代价是新键在 hub 升级前会被**静默丢弃**(所以新字段仍然
|
|
704
|
+
// 应当 hub 先合)。同一份 schema 里 side_thread_capability / external_schedules
|
|
705
|
+
// 是 `.strict()` 的,后果不同 —— 见 `host:` 上方那段方框注释。
|
|
706
|
+
daemon_capabilities: z.object({
|
|
707
|
+
runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
|
|
708
|
+
allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
|
|
709
|
+
max_concurrent_children: z.number().int().min(1).max(1000).optional(),
|
|
710
|
+
// #1353 —— daemon **当下**能不能创建节点。与 runtimes_supported 不是一回事:
|
|
711
|
+
// 后者是声明(我支持哪些 runtime),这个是实际能力。一个 daemon 可以声明支持
|
|
712
|
+
// 三种 runtime,同时因为 ANET_BIN pin 解析不出来而一种也创建不了。
|
|
713
|
+
//
|
|
714
|
+
// 🔴 为什么 hub 需要看见它:pin 只存在于 /etc/anet-daemon/path.conf 或
|
|
715
|
+
// 显式开启的环境变量里,**重启不带环境就会丢**。丢了之后 daemon 照常注册、
|
|
716
|
+
// 在线、收 doorbell,hub 返回 ok:true + request_id —— 而节点永远不出现。
|
|
717
|
+
// 在此之前 hub 完全看不出区别,Dashboard 的「选服务器」还会把它列为可选。
|
|
718
|
+
can_create_nodes: z.boolean().optional(),
|
|
719
|
+
// 只收**代码**,不收原始报错文本 —— 上游 unsafePathHelp() 的消息里带完整机器路径,
|
|
720
|
+
// 而这个字段会走到 Dashboard。enum 而不是 string:一个自由文本字段等于给
|
|
721
|
+
// 「把路径塞进来」留了口子。
|
|
722
|
+
// #1353 —— 四类失败,修法完全不同:
|
|
723
|
+
// identity 重装 anet / unset ANET_BIN_ABS
|
|
724
|
+
// source 写 /etc/anet-daemon/path.conf(**要 sudo**)
|
|
725
|
+
// shape 换成 realpath
|
|
726
|
+
// permission **一行 chmod go-w**
|
|
727
|
+
// 混成一条会让人修错方向 —— 2026-08-28 我一天里撞到其中两类,看到的是同一条错误。
|
|
728
|
+
//
|
|
729
|
+
// 🔴 `anet_bin_pin_unresolved` **必须留着**:已经在跑的 agent-node@2.5.0-preview.40
|
|
730
|
+
// 发的就是这个值。zod 对象里任何一个字段验证失败会让**整个 report_status 被拒**,
|
|
731
|
+
// 不只是丢掉这一格 —— 那会让所有 .40 daemon 在 hub 上变成失联。
|
|
732
|
+
// 删一个 enum 值的代价,远大于多留一个。
|
|
733
|
+
// 🔴 仍然是 enum 不是 string:自由文本字段就是给机器路径留的口子,
|
|
734
|
+
// 而上游 e.message 里带的正是完整路径。
|
|
735
|
+
create_nodes_blocked_reason: z.enum([
|
|
736
|
+
"anet_bin_pin_unresolved", // 兼容:.40 及更早
|
|
737
|
+
"anet_bin_identity",
|
|
738
|
+
"anet_bin_source",
|
|
739
|
+
"anet_bin_shape",
|
|
740
|
+
"anet_bin_permission",
|
|
741
|
+
"anet_bin_unknown",
|
|
742
|
+
]).optional(),
|
|
743
|
+
// #1545 —— 上面那一格**是什么时候测出来的**。
|
|
744
|
+
//
|
|
745
|
+
// 🔴 版本号一律带包名 + 完整版本:仓里三个包各自独立编号,裸的 `preview.NN`
|
|
746
|
+
// 同时是三个不同的时间点。此处曾写成「preview.67」——
|
|
747
|
+
// 那是 agent-network 的号,agent-node 根本没有 .67(npm 404,最高 .56)。
|
|
748
|
+
// 🔴 为什么必须有:**agent-node ≤ 2.5.0-preview.54** 的 `daemonCreateCapability()`
|
|
749
|
+
// 用一个进程级缓存(`_createCapCache`)只算一次。开机时 pin 坏 ⇒ 之后
|
|
750
|
+
// 永远上报 blocked(哪怕运维已经写好 path.conf);开机时好 ⇒ 之后把二进制
|
|
751
|
+
// chmod 掉也**永远上报 ready**。后者是朝「没问题」方向说谎。
|
|
752
|
+
// 而 hub 这边,一个 3 秒前测的 blocked 和一个三周前测的 blocked,
|
|
753
|
+
// 在 `last_seen_at` 上长得一模一样 —— 心跳是新的,那一格不是。
|
|
754
|
+
//
|
|
755
|
+
// 🔴 为什么是「多久以前」(时长)而不是绝对时间戳:这个值来自**节点自己的钟**。
|
|
756
|
+
// 时钟偏移下,绝对时间戳会算出一个既可能"永远新鲜"也可能"来自 1970"的年龄,
|
|
757
|
+
// 而且错的方向不可预测。时长对偏移免疫 —— hub 用自己的钟在收到时换算。
|
|
758
|
+
//
|
|
759
|
+
// 🔴 为什么不加 `.min()/.max()/.int()`:**这几个约束都会让整条 report_status 被拒**
|
|
760
|
+
// (zod 对象里任何一个已知字段验证失败 = 整份被拒,不是丢掉这一格),
|
|
761
|
+
// 而这一格是纯诊断信息,不值得拿一台节点的在线状态去换。
|
|
762
|
+
// 收得宽,**在读取处消毒**(见 /api/host-supervisors)。
|
|
763
|
+
// 这条不是我新立的:同一个 schema 上方 `anet_bin_pin_unresolved` 那条注释
|
|
764
|
+
// 记的就是同一次教训 —— 删一个 enum 值会让所有 .40 daemon 失联。
|
|
765
|
+
//
|
|
766
|
+
// 🔴 `.catch(null)` 不是装饰:光写 `z.number()` **仍然会拒 NaN**
|
|
767
|
+
// (实测:`z.number().nullable().optional()` 对 NaN 抛),而那意味着
|
|
768
|
+
// 一次算错的时长能让整台节点在 hub 上失联 —— 正是 #1225 里 host.ip
|
|
769
|
+
// 那个形状。`.catch(null)` 让这一格**在任何输入下都不可能拒掉整份 report**:
|
|
770
|
+
// 合法数字透传,其余一律变 null(读取侧当作「没报」)。
|
|
771
|
+
// 类型仍然是 number 而不是 unknown —— 别给一个 attacker daemon
|
|
772
|
+
// 留下"往快照里塞任意大对象"的口子(同 schema 上方 soft cap 的立场)。
|
|
773
|
+
//
|
|
774
|
+
// 不上报这一格 ≠ 0。旧 daemon 压根不发,读的人必须能把
|
|
775
|
+
// 「刚测的」「很久以前测的」「不知道」分成三件事说。
|
|
776
|
+
create_capability_observed_ms_ago: z.number().nullable().catch(null).optional(),
|
|
777
|
+
// app「新建节点」工作目录:daemon 的默认根。出现即表示该 daemon 认 node_spec.workdir。
|
|
778
|
+
// `.catch(undefined)`:同上一格的立场 —— 一个诊断/展示字段不许拒掉整份 report。
|
|
779
|
+
// 读取侧再按形状消毒(create-node-validate.ts daemonDefaultWorkdirRoot)。
|
|
780
|
+
default_workdir_root: z.string().max(1024).optional().catch(undefined),
|
|
781
|
+
}).optional(),
|
|
306
782
|
}).optional().describe("RFC-024 — masked node config snapshot"),
|
|
783
|
+
// app#225 follow-up — the reporting process answers the `rules_file`
|
|
784
|
+
// doorbell (agent-node, and the claude-code channel server node-server).
|
|
785
|
+
// Top-level instead of config_snapshot: claude-code sessions usually have
|
|
786
|
+
// no `nodes` row, and config_snapshot is persisted per node_id. Only a
|
|
787
|
+
// node token bound to this same alias can set it; see the UPDATE below.
|
|
788
|
+
rules_file_capable: z.literal(true).optional(),
|
|
789
|
+
// Node skills view — same doorbell, ops skills_list / skill_read.
|
|
790
|
+
skills_capable: z.literal(true).optional(),
|
|
791
|
+
// Project folder view — same doorbell, ops files_list / file_read.
|
|
792
|
+
files_capable: z.literal(true).optional(),
|
|
793
|
+
// Node run-log view — same doorbell, op logs_tail.
|
|
794
|
+
logs_capable: z.literal(true).optional(),
|
|
307
795
|
},
|
|
308
|
-
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 }) => {
|
|
796
|
+
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, logs_capable: logsCapable }) => {
|
|
309
797
|
const effectiveNetId = getNetworkId(netId);
|
|
310
798
|
const sessionNetId = effectiveNetId ?? "default";
|
|
311
799
|
if (!callerTokenIsNetwork || !enforceNetworkId) {
|
|
@@ -343,6 +831,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
343
831
|
};
|
|
344
832
|
}
|
|
345
833
|
}
|
|
834
|
+
// #203 identity guard — network tokens must not silently rebind their
|
|
835
|
+
// own name via report_status. Without this, a runtime whose ALIAS
|
|
836
|
+
// drifted (env leak / wrong --alias / CurrentAliasResolver seeded from
|
|
837
|
+
// the wrong node_id) could rewrite api_tokens.name and cause every
|
|
838
|
+
// subsequent send_task from this token to be attributed to the drifted
|
|
839
|
+
// alias — the observed #203 symptom (grokB's send arriving as
|
|
840
|
+
// from=grokA). Only the legit rename path (rename.ts) may cross the
|
|
841
|
+
// token→alias binding. Symmetric to fromIdentityMismatchReply on the
|
|
842
|
+
// send side (test198).
|
|
843
|
+
if (callerTokenIsNetwork && callerAlias && !canonical.renamed && effectiveAlias !== callerAlias) {
|
|
844
|
+
return {
|
|
845
|
+
content: [{
|
|
846
|
+
type: "text" as const,
|
|
847
|
+
text: JSON.stringify({
|
|
848
|
+
ok: false,
|
|
849
|
+
error: "alias_identity_mismatch",
|
|
850
|
+
message: "report_status alias does not match the token-bound node alias; use anet node rename to change identity",
|
|
851
|
+
token_alias: callerAlias,
|
|
852
|
+
reported_alias: effectiveAlias,
|
|
853
|
+
}),
|
|
854
|
+
}],
|
|
855
|
+
};
|
|
856
|
+
}
|
|
346
857
|
console.log(`[${ts()}] ${effectiveAlias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
|
|
347
858
|
if (callerTokenIsNetwork && callerTokenId) {
|
|
348
859
|
try {
|
|
@@ -367,6 +878,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
367
878
|
const processCpuPct = typeof proc?.cpu_pct === "number" ? proc.cpu_pct : null;
|
|
368
879
|
const processUptimeSeconds = typeof proc?.uptime_seconds === "number" ? proc.uptime_seconds : null;
|
|
369
880
|
const processInFlightCount = typeof proc?.in_flight_count === "number" ? proc.in_flight_count : null;
|
|
881
|
+
const externalSchedulesJson = externalSchedules === undefined ? null : JSON.stringify(externalSchedules);
|
|
882
|
+
const trustedCurrentSnapshot = node_id
|
|
883
|
+
? trustedConfigSnapshotForNode(cfgSnap ?? null, callerTokenId ?? null, node_id)
|
|
884
|
+
: null;
|
|
885
|
+
const peerReplyInboxCapable = !!(
|
|
886
|
+
trustedCurrentSnapshot
|
|
887
|
+
&& typeof trustedCurrentSnapshot === "object"
|
|
888
|
+
&& (trustedCurrentSnapshot as Record<string, unknown>).peer_reply_inbox_capable === true
|
|
889
|
+
);
|
|
370
890
|
const statusHostTelemetry = host ? {
|
|
371
891
|
hostname: hostHostname,
|
|
372
892
|
ip: hostIp,
|
|
@@ -389,10 +909,30 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
389
909
|
|
|
390
910
|
db.transaction(() => {
|
|
391
911
|
// Only delete same-alias sessions within the same network
|
|
912
|
+
// 同一 alias 可以有两个上报者轮流说话(agent-node 自己 `sdk-<node>` +
|
|
913
|
+
// grok 共存里 TUI 的 MCP `grok-cli-<node>`)。下面这条 DELETE 会把「另一个
|
|
914
|
+
// resume_id」的那行整行删掉,再 INSERT 一行新的 —— ON CONFLICT 里那一串
|
|
915
|
+
// COALESCE 在这条路径上一次都不会触发,于是上报里没带的列(version / agent /
|
|
916
|
+
// hostname / 六个遥测字段 / registered_at …)全部变 NULL。2026-09-04 DEV 真机:
|
|
917
|
+
// grok-v1 working 时 version=.64,7 秒后 idle 就成了 NULL,registered_at 被改成
|
|
918
|
+
// 那一秒。先把要被删的那行读出来,INSERT 之后把仍为 NULL 的列从它接手 —— 让「换 resume_id」
|
|
919
|
+
// 和「同 resume_id 更新」对描述性列的语义一致:上报了就覆盖,没上报就保留。
|
|
920
|
+
// 状态类列(status / task / output / progress / score)不接手,那是本次上报的事实。
|
|
921
|
+
const handover = db.get<Record<string, unknown>>(
|
|
922
|
+
`SELECT tmux_name, server, ip, hostname, agent, project_dir, version, node_id, session_id, config_path,
|
|
923
|
+
channels, model, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb,
|
|
924
|
+
disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct,
|
|
925
|
+
process_uptime_seconds, process_in_flight_count, external_schedules, registered_at
|
|
926
|
+
FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3
|
|
927
|
+
ORDER BY updated_at DESC LIMIT 1`,
|
|
928
|
+
effectiveAlias, resume_id, sessionNetId,
|
|
929
|
+
) ?? null;
|
|
930
|
+
const keep = <T,>(fresh: T | null | undefined, column: string): T | null =>
|
|
931
|
+
(fresh ?? (handover?.[column] as T | null | undefined) ?? null);
|
|
392
932
|
db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [effectiveAlias, resume_id, sessionNetId]);
|
|
393
933
|
db.run(
|
|
394
|
-
`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)
|
|
395
|
-
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'))
|
|
934
|
+
`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)
|
|
935
|
+
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'))
|
|
396
936
|
ON CONFLICT(resume_id) DO UPDATE SET
|
|
397
937
|
alias = COALESCE(?2, sessions.alias), tmux_name = COALESCE(?3, sessions.tmux_name),
|
|
398
938
|
server = COALESCE(?4, sessions.server), ip = COALESCE(?5, sessions.ip),
|
|
@@ -417,9 +957,57 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
417
957
|
process_cpu_pct = COALESCE(?31, sessions.process_cpu_pct),
|
|
418
958
|
process_uptime_seconds = COALESCE(?32, sessions.process_uptime_seconds),
|
|
419
959
|
process_in_flight_count = COALESCE(?33, sessions.process_in_flight_count),
|
|
960
|
+
external_schedules = COALESCE(?34, sessions.external_schedules),
|
|
961
|
+
peer_reply_inbox_capable = ?35,
|
|
420
962
|
last_seen_at = datetime('now'), updated_at = datetime('now')`,
|
|
421
|
-
[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]
|
|
963
|
+
[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]
|
|
422
964
|
);
|
|
965
|
+
if (handover) {
|
|
966
|
+
// 换 resume_id 那条路径上 ON CONFLICT 不会触发;INSERT 完再把没上报(仍为 NULL)的
|
|
967
|
+
// 描述性列从被替换的那行接手。INSERT 语句与参数列表保持原样 —— test698 的变异
|
|
968
|
+
// 用 sed 钉着它的字节形状。
|
|
969
|
+
db.run(
|
|
970
|
+
`UPDATE sessions SET
|
|
971
|
+
tmux_name = COALESCE(tmux_name, ?2), server = COALESCE(server, ?3), ip = COALESCE(ip, ?4),
|
|
972
|
+
hostname = COALESCE(hostname, ?5), agent = COALESCE(agent, ?6), project_dir = COALESCE(project_dir, ?7),
|
|
973
|
+
version = COALESCE(version, ?8), node_id = COALESCE(node_id, ?9), session_id = COALESCE(session_id, ?10),
|
|
974
|
+
config_path = COALESCE(config_path, ?11), channels = COALESCE(channels, ?12), model = COALESCE(model, ?13),
|
|
975
|
+
cpu_load_1min = COALESCE(cpu_load_1min, ?14), cpu_cores = COALESCE(cpu_cores, ?15),
|
|
976
|
+
mem_total_gb = COALESCE(mem_total_gb, ?16), mem_used_gb = COALESCE(mem_used_gb, ?17),
|
|
977
|
+
mem_avail_gb = COALESCE(mem_avail_gb, ?18), disk_total_gb = COALESCE(disk_total_gb, ?19),
|
|
978
|
+
disk_used_gb = COALESCE(disk_used_gb, ?20), disk_avail_gb = COALESCE(disk_avail_gb, ?21),
|
|
979
|
+
process_rss_bytes = COALESCE(process_rss_bytes, ?22), process_rss_mb = COALESCE(process_rss_mb, ?23),
|
|
980
|
+
process_cpu_pct = COALESCE(process_cpu_pct, ?24), process_uptime_seconds = COALESCE(process_uptime_seconds, ?25),
|
|
981
|
+
process_in_flight_count = COALESCE(process_in_flight_count, ?26),
|
|
982
|
+
external_schedules = COALESCE(external_schedules, ?27),
|
|
983
|
+
registered_at = COALESCE(?28, registered_at)
|
|
984
|
+
WHERE resume_id = ?1`,
|
|
985
|
+
[resume_id, handover.tmux_name ?? null, handover.server ?? null, handover.ip ?? null, handover.hostname ?? null,
|
|
986
|
+
handover.agent ?? null, handover.project_dir ?? null, handover.version ?? null, handover.node_id ?? null,
|
|
987
|
+
handover.session_id ?? null, handover.config_path ?? null, handover.channels ?? null, handover.model ?? null,
|
|
988
|
+
handover.cpu_load_1min ?? null, handover.cpu_cores ?? null, handover.mem_total_gb ?? null, handover.mem_used_gb ?? null,
|
|
989
|
+
handover.mem_avail_gb ?? null, handover.disk_total_gb ?? null, handover.disk_used_gb ?? null, handover.disk_avail_gb ?? null,
|
|
990
|
+
handover.process_rss_bytes ?? null, handover.process_rss_mb ?? null, handover.process_cpu_pct ?? null,
|
|
991
|
+
handover.process_uptime_seconds ?? null, handover.process_in_flight_count ?? null, handover.external_schedules ?? null,
|
|
992
|
+
handover.registered_at ?? null],
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
// app#225 follow-up — separate statement on purpose: the INSERT above is
|
|
996
|
+
// byte-pinned by test698's mutation harness. Sticky: only ever set to 1,
|
|
997
|
+
// and only by a node token bound to this alias (a user token or another
|
|
998
|
+
// node cannot mark someone else's session as able to serve its files).
|
|
999
|
+
if (rulesFileCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
|
|
1000
|
+
db.run("UPDATE sessions SET rules_file_capable = 1 WHERE resume_id = ?1", [resume_id]);
|
|
1001
|
+
}
|
|
1002
|
+
if (skillsCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
|
|
1003
|
+
db.run("UPDATE sessions SET skills_capable = 1 WHERE resume_id = ?1", [resume_id]);
|
|
1004
|
+
}
|
|
1005
|
+
if (filesCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
|
|
1006
|
+
db.run("UPDATE sessions SET files_capable = 1 WHERE resume_id = ?1", [resume_id]);
|
|
1007
|
+
}
|
|
1008
|
+
if (logsCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
|
|
1009
|
+
db.run("UPDATE sessions SET logs_capable = 1 WHERE resume_id = ?1", [resume_id]);
|
|
1010
|
+
}
|
|
423
1011
|
if (host || proc) {
|
|
424
1012
|
db.run(
|
|
425
1013
|
`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)
|
|
@@ -468,6 +1056,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
468
1056
|
upsertNodeWithSec1Guard({
|
|
469
1057
|
node_id,
|
|
470
1058
|
callerNetworkId: effectiveNetId ?? null,
|
|
1059
|
+
callerUserId: enforceUserId ?? null,
|
|
1060
|
+
callerTokenId: callerTokenId ?? null,
|
|
471
1061
|
node_name: nn || effectiveAlias,
|
|
472
1062
|
alias: effectiveAlias,
|
|
473
1063
|
runtime: nodeRuntime,
|
|
@@ -559,6 +1149,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
559
1149
|
} else {
|
|
560
1150
|
updatedTaskId = task;
|
|
561
1151
|
}
|
|
1152
|
+
if (updatedTaskId) syncScheduledRunForTask(updatedTaskId, effectiveNetId);
|
|
562
1153
|
});
|
|
563
1154
|
// Log event after transaction
|
|
564
1155
|
if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
|
|
@@ -619,7 +1210,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
619
1210
|
const rows0 = db.get<{ cnt: number }>(countSql, ...countParams);
|
|
620
1211
|
console.log(`[${ts()}] ${alias} → get_inbox: ${rows0?.cnt ?? 0} pending messages`);
|
|
621
1212
|
const rowsParams: any[] = [alias];
|
|
622
|
-
let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json
|
|
1213
|
+
let rowsSql = `SELECT id, type, priority, content, context, from_session, created_at, network_id, meta_json,
|
|
1214
|
+
CASE WHEN type = 'task' THEN COALESCE(task_id, id) ELSE task_id END AS task_id
|
|
623
1215
|
FROM inbox WHERE session_name = ?1 AND acked = 0`;
|
|
624
1216
|
rowsSql = addReadScope(rowsSql, rowsParams, readScope);
|
|
625
1217
|
rowsSql += ` ORDER BY CASE priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, created_at
|
|
@@ -643,13 +1235,32 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
643
1235
|
alias: z.string().min(1).max(200).describe("Session alias"),
|
|
644
1236
|
message_id: z.string().min(1).max(200),
|
|
645
1237
|
response: z.string().max(10000).optional(),
|
|
1238
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
646
1239
|
},
|
|
647
|
-
async ({ alias, message_id, response }) => {
|
|
648
|
-
const effectiveNetId = getNetworkId(
|
|
1240
|
+
async ({ alias, message_id, response, network_id: netId }) => {
|
|
1241
|
+
const effectiveNetId = getNetworkId(netId);
|
|
649
1242
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
650
1243
|
console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
|
|
651
|
-
|
|
652
|
-
|
|
1244
|
+
// New task consumers ACK by the logical tasks.task_id exposed by
|
|
1245
|
+
// get_inbox. Keep exact inbox.id support for legacy consumers and for
|
|
1246
|
+
// non-task messages. Restrict the lookup to pending rows: after a retry
|
|
1247
|
+
// the original row can have id == task_id but is already ACKed, while
|
|
1248
|
+
// the current row has a fresh transport id and the same logical task_id.
|
|
1249
|
+
const inboxTaskParams: any[] = [message_id, alias];
|
|
1250
|
+
let inboxTaskSql = `SELECT id, type, COALESCE(task_id, id) AS task_id
|
|
1251
|
+
FROM inbox
|
|
1252
|
+
WHERE session_name = ?2 AND acked = 0
|
|
1253
|
+
AND (id = ?1 OR (type = 'task' AND task_id = ?1))`;
|
|
1254
|
+
inboxTaskSql = addScope(inboxTaskSql, inboxTaskParams, effectiveNetId);
|
|
1255
|
+
inboxTaskSql += " ORDER BY created_at DESC LIMIT 1";
|
|
1256
|
+
const inboxTask = db.get<{ id: string; type: string; task_id: string }>(inboxTaskSql, ...inboxTaskParams);
|
|
1257
|
+
if (!inboxTask) {
|
|
1258
|
+
return {
|
|
1259
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "message not found or already acknowledged" }) }],
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
const ackParams: any[] = [inboxTask.id, alias];
|
|
1263
|
+
let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2 AND acked = 0";
|
|
653
1264
|
ackSql = addScope(ackSql, ackParams, effectiveNetId);
|
|
654
1265
|
const result = db.run(ackSql, ackParams);
|
|
655
1266
|
if (result.changes === 0) {
|
|
@@ -659,11 +1270,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
659
1270
|
}
|
|
660
1271
|
// V2: sync tasks table — ack_inbox means delivered→acked
|
|
661
1272
|
try {
|
|
662
|
-
|
|
1273
|
+
if (inboxTask?.type !== "task") {
|
|
1274
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }] };
|
|
1275
|
+
}
|
|
1276
|
+
const taskParams: any[] = [inboxTask.task_id];
|
|
663
1277
|
let taskSql = "UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'";
|
|
664
1278
|
taskSql = addScope(taskSql, taskParams, effectiveNetId);
|
|
665
1279
|
const ackResult = db.run(taskSql, taskParams);
|
|
666
|
-
if (ackResult.changes > 0) logTaskEvent(
|
|
1280
|
+
if (ackResult.changes > 0) logTaskEvent(inboxTask.task_id, "delivered", "acked", alias);
|
|
667
1281
|
} catch {}
|
|
668
1282
|
return {
|
|
669
1283
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }],
|
|
@@ -671,44 +1285,252 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
671
1285
|
}
|
|
672
1286
|
);
|
|
673
1287
|
|
|
1288
|
+
// #520 — two monotonic runtime-evidence levels for exact tasks.
|
|
1289
|
+
//
|
|
1290
|
+
// runtime_submitted_at means agent-node handed the body to the vendor
|
|
1291
|
+
// runtime. consumed_at is stronger: an attributable turn-start or first
|
|
1292
|
+
// activity event came back. Merely fetching/acking an inbox row sets neither.
|
|
1293
|
+
// Node identity comes exclusively from the ntok; callers cannot self-report
|
|
1294
|
+
// an alias or node_id. A consumed mark also fills runtime_submitted_at because
|
|
1295
|
+
// that stronger fact logically implies submission.
|
|
1296
|
+
const markTaskRuntimeEvidence = (
|
|
1297
|
+
taskIds: string[],
|
|
1298
|
+
level: "submitted" | "consumed",
|
|
1299
|
+
taskContexts: Array<{ task_id: string; thread_id: string; turn_id: string }> = [],
|
|
1300
|
+
) => {
|
|
1301
|
+
const task_ids = taskIds;
|
|
1302
|
+
if (!callerTokenIsNetwork || !callerAlias || !enforceNetworkId) {
|
|
1303
|
+
return {
|
|
1304
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_required" }) }],
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
// The all-or-nothing batch promise needs a transaction() that really is
|
|
1308
|
+
// one transaction; refuse evidence on any adapter that cannot give that
|
|
1309
|
+
// rather than publish a partially stamped batch.
|
|
1310
|
+
if (db.transactionalFeaturesRefusal !== null) {
|
|
1311
|
+
return {
|
|
1312
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
1313
|
+
ok: false,
|
|
1314
|
+
error: "task_runtime_evidence_backend_unsupported", message: db.transactionalFeaturesRefusal || undefined,
|
|
1315
|
+
}) }],
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
const uniqueTaskIds = [...new Set(task_ids)];
|
|
1320
|
+
if (uniqueTaskIds.length !== task_ids.length) {
|
|
1321
|
+
return {
|
|
1322
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_id" }) }],
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
if (level !== "consumed" && taskContexts.length > 0) {
|
|
1327
|
+
return {
|
|
1328
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "runtime_context_requires_consumed" }) }],
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
const contextByTaskId = new Map(taskContexts.map((context) => [context.task_id, context]));
|
|
1332
|
+
if (contextByTaskId.size !== taskContexts.length) {
|
|
1333
|
+
return {
|
|
1334
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_context" }) }],
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
const foreignContext = taskContexts.find((context) => !uniqueTaskIds.includes(context.task_id));
|
|
1338
|
+
if (foreignContext) {
|
|
1339
|
+
return {
|
|
1340
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task_context_not_requested", task_id: foreignContext.task_id }) }],
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
const placeholders = uniqueTaskIds.map((_, i) => `?${i + 2}`).join(", ");
|
|
1345
|
+
|
|
1346
|
+
// Keep ownership preflight and every stamp in one SQLite transaction.
|
|
1347
|
+
// This closes the cross-process preflight→UPDATE race: no other writer
|
|
1348
|
+
// can reassign a task between the identity decision and the write.
|
|
1349
|
+
const outcome = db.transaction(() => {
|
|
1350
|
+
const canonicalCaller = resolveCanonicalAlias(enforceNetworkId, callerAlias).alias;
|
|
1351
|
+
const callerSession = db.get<{ node_id: string | null }>(
|
|
1352
|
+
`SELECT node_id FROM sessions
|
|
1353
|
+
WHERE network_id = ?1 AND alias = ?2
|
|
1354
|
+
ORDER BY updated_at DESC LIMIT 1`,
|
|
1355
|
+
enforceNetworkId,
|
|
1356
|
+
canonicalCaller,
|
|
1357
|
+
);
|
|
1358
|
+
const rows = db.all<{ task_id: string; to_node_id: string | null; to_name: string; thread_id: string | null; turn_id: string | null }>(
|
|
1359
|
+
`SELECT task_id, to_node_id, to_name, thread_id, turn_id FROM tasks
|
|
1360
|
+
WHERE network_id = ?1 AND task_id IN (${placeholders})`,
|
|
1361
|
+
enforceNetworkId,
|
|
1362
|
+
...uniqueTaskIds,
|
|
1363
|
+
);
|
|
1364
|
+
const owned = new Map(rows.map((row) => [row.task_id, row]));
|
|
1365
|
+
const rejectedTaskId = uniqueTaskIds.find((taskId) => {
|
|
1366
|
+
const row = owned.get(taskId);
|
|
1367
|
+
if (!row) return true;
|
|
1368
|
+
// Prefer immutable node identity whenever the task has one. Direct
|
|
1369
|
+
// and legacy token-bound sessions legitimately have NULL node_id;
|
|
1370
|
+
// only that shape may fall back to the canonical token alias.
|
|
1371
|
+
if (row.to_node_id) return row.to_node_id !== callerSession?.node_id;
|
|
1372
|
+
return resolveCanonicalAlias(enforceNetworkId, row.to_name).alias !== canonicalCaller;
|
|
1373
|
+
});
|
|
1374
|
+
if (rejectedTaskId) {
|
|
1375
|
+
return { ok: false as const, error: "task_not_owned", task_id: rejectedTaskId };
|
|
1376
|
+
}
|
|
1377
|
+
const conflictingContext = uniqueTaskIds.find((taskId) => {
|
|
1378
|
+
const context = contextByTaskId.get(taskId);
|
|
1379
|
+
if (!context) return false;
|
|
1380
|
+
const row = owned.get(taskId)!;
|
|
1381
|
+
return (row.thread_id !== null && row.thread_id !== context.thread_id)
|
|
1382
|
+
|| (row.turn_id !== null && row.turn_id !== context.turn_id);
|
|
1383
|
+
});
|
|
1384
|
+
if (conflictingContext) {
|
|
1385
|
+
return { ok: false as const, error: "task_runtime_context_conflict", task_id: conflictingContext };
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
for (const taskId of uniqueTaskIds) {
|
|
1389
|
+
const row = owned.get(taskId)!;
|
|
1390
|
+
const ownershipSql = row.to_node_id
|
|
1391
|
+
? "to_node_id = ?3"
|
|
1392
|
+
: "to_node_id IS NULL AND to_name = ?3";
|
|
1393
|
+
const ownerValue = row.to_node_id ?? row.to_name;
|
|
1394
|
+
let updateResult;
|
|
1395
|
+
if (level === "consumed") {
|
|
1396
|
+
const context = contextByTaskId.get(taskId);
|
|
1397
|
+
updateResult = db.run(
|
|
1398
|
+
`UPDATE tasks SET
|
|
1399
|
+
runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now')),
|
|
1400
|
+
consumed_at = COALESCE(consumed_at, datetime('now')),
|
|
1401
|
+
thread_id = COALESCE(thread_id, ?4),
|
|
1402
|
+
turn_id = COALESCE(turn_id, ?5)
|
|
1403
|
+
WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
|
|
1404
|
+
[enforceNetworkId, taskId, ownerValue, context?.thread_id ?? null, context?.turn_id ?? null],
|
|
1405
|
+
);
|
|
1406
|
+
} else {
|
|
1407
|
+
updateResult = db.run(
|
|
1408
|
+
`UPDATE tasks SET runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now'))
|
|
1409
|
+
WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
|
|
1410
|
+
[enforceNetworkId, taskId, ownerValue],
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
if (updateResult.changes !== 1) {
|
|
1414
|
+
// A zero-row write after a successful preflight is an invariant
|
|
1415
|
+
// failure, never a successful evidence report.
|
|
1416
|
+
throw new Error(`task_runtime_evidence_write_race:${taskId}`);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
const evidenceRows = db.all<{
|
|
1420
|
+
task_id: string;
|
|
1421
|
+
runtime_submitted_at: string;
|
|
1422
|
+
consumed_at: string | null;
|
|
1423
|
+
thread_id: string | null;
|
|
1424
|
+
turn_id: string | null;
|
|
1425
|
+
}>(
|
|
1426
|
+
`SELECT task_id, runtime_submitted_at, consumed_at, thread_id, turn_id FROM tasks
|
|
1427
|
+
WHERE network_id = ?1 AND task_id IN (${placeholders})`,
|
|
1428
|
+
enforceNetworkId,
|
|
1429
|
+
...uniqueTaskIds,
|
|
1430
|
+
);
|
|
1431
|
+
return { ok: true as const, tasks: evidenceRows };
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
return {
|
|
1435
|
+
content: [{
|
|
1436
|
+
type: "text" as const,
|
|
1437
|
+
text: JSON.stringify(outcome),
|
|
1438
|
+
}],
|
|
1439
|
+
};
|
|
1440
|
+
};
|
|
1441
|
+
|
|
1442
|
+
const taskRuntimeEvidenceSchema = {
|
|
1443
|
+
task_ids: z.array(z.string().min(1).max(200)).min(1).max(100),
|
|
1444
|
+
task_contexts: z.array(z.object({
|
|
1445
|
+
task_id: z.string().min(1).max(200),
|
|
1446
|
+
thread_id: z.string().min(1).max(500),
|
|
1447
|
+
turn_id: z.string().min(1).max(500),
|
|
1448
|
+
}).strict()).max(100).optional(),
|
|
1449
|
+
};
|
|
1450
|
+
|
|
1451
|
+
server.tool(
|
|
1452
|
+
"mark_tasks_runtime_submitted",
|
|
1453
|
+
"Internal agent-node signal: exact task bodies were submitted to this token-bound node's vendor runtime.",
|
|
1454
|
+
taskRuntimeEvidenceSchema,
|
|
1455
|
+
async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "submitted", task_contexts),
|
|
1456
|
+
);
|
|
1457
|
+
|
|
1458
|
+
server.tool(
|
|
1459
|
+
"mark_tasks_consumed",
|
|
1460
|
+
"Internal agent-node signal: exact tasks produced attributable turn-start/activity evidence in this token-bound node's runtime.",
|
|
1461
|
+
taskRuntimeEvidenceSchema,
|
|
1462
|
+
async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "consumed", task_contexts),
|
|
1463
|
+
);
|
|
1464
|
+
|
|
674
1465
|
// ═══════════════════════════════════════════
|
|
675
1466
|
// Hub Tools (5)
|
|
676
1467
|
// ═══════════════════════════════════════════
|
|
677
1468
|
|
|
678
1469
|
server.tool(
|
|
679
1470
|
"get_all_status",
|
|
680
|
-
"Get status of all sessions. Hub uses this for the patrol loop."
|
|
1471
|
+
"Get status of all sessions. Hub uses this for the patrol loop. " +
|
|
1472
|
+
"Pass filter_alias (comma-separated) when you only care about specific " +
|
|
1473
|
+
"nodes — the unfiltered result is one row per session with 31 columns and " +
|
|
1474
|
+
"is large enough on a real fleet that callers cannot read it.",
|
|
681
1475
|
{
|
|
682
1476
|
filter_status: z.string().max(50).optional(),
|
|
683
1477
|
filter_server: z.string().max(200).optional(),
|
|
1478
|
+
// 2026-08-17: on a 222-session hub the unfiltered response is ~259 KB, past
|
|
1479
|
+
// what an MCP client can take in one result — so the caller that wanted the
|
|
1480
|
+
// status of THREE nodes could not get it from this tool at all. The patrol
|
|
1481
|
+
// loop still wants everything, hence optional rather than required.
|
|
1482
|
+
filter_alias: z.string().max(2000).optional()
|
|
1483
|
+
.describe("One alias, or several separated by commas. Exact matches only."),
|
|
684
1484
|
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
685
1485
|
},
|
|
686
|
-
async ({ filter_status, filter_server, network_id: netId }) => {
|
|
1486
|
+
async ({ filter_status, filter_server, filter_alias, network_id: netId }) => {
|
|
687
1487
|
const readScope = resolveReadScope(netId);
|
|
688
1488
|
if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
689
|
-
console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${readScope.networkId ? " net=" + readScope.networkId.slice(0, 12) : ""}`);
|
|
1489
|
+
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) : ""}`);
|
|
690
1490
|
|
|
691
1491
|
// Round-2/4 review ③: stale-marking moved to startStaleSessionSweeper()
|
|
692
1492
|
// (background timer, ~60s cadence). Read path no longer fires UPDATE.
|
|
693
1493
|
let sql = "SELECT * FROM sessions WHERE 1=1";
|
|
694
1494
|
const params: any[] = [];
|
|
695
|
-
sql =
|
|
1495
|
+
sql = addAgentNetworkScope(sql, params, readScope, { alias: "alias", nodeId: "node_id" });
|
|
696
1496
|
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
697
1497
|
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
1498
|
+
const aliasFilter = parseAliasFilter(filter_alias);
|
|
1499
|
+
const aliases = aliasFilter.aliases;
|
|
1500
|
+
if (aliasFilter.sql) {
|
|
1501
|
+
sql += aliasFilter.sql;
|
|
1502
|
+
params.push(...aliases);
|
|
1503
|
+
}
|
|
698
1504
|
sql += " ORDER BY updated_at DESC";
|
|
699
1505
|
const sessions = db.all(sql, ...params);
|
|
700
1506
|
|
|
1507
|
+
// `summary` has always counted every session in the read scope, ignoring
|
|
1508
|
+
// filter_status / filter_server — and now filter_alias. That is fine for
|
|
1509
|
+
// the patrol loop, but a caller who asked about three aliases and gets
|
|
1510
|
+
// back three rows plus "idle: 96" can easily read the 96 as being about
|
|
1511
|
+
// their three. A count that does not say what it counted invites exactly
|
|
1512
|
+
// that. So the response now says so, rather than the semantics changing
|
|
1513
|
+
// under existing callers.
|
|
701
1514
|
const summaryParams: any[] = [];
|
|
702
1515
|
let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
|
|
703
|
-
summarySql =
|
|
1516
|
+
summarySql = addAgentNetworkScope(summarySql, summaryParams, readScope, { alias: "alias", nodeId: "node_id" });
|
|
704
1517
|
summarySql += " GROUP BY status";
|
|
705
1518
|
const summary = db.all(summarySql, ...summaryParams);
|
|
706
1519
|
|
|
1520
|
+
const filtered = !!(filter_status || filter_server || aliases.length > 0);
|
|
707
1521
|
return {
|
|
708
1522
|
content: [
|
|
709
1523
|
{
|
|
710
1524
|
type: "text" as const,
|
|
711
|
-
text: JSON.stringify({
|
|
1525
|
+
text: JSON.stringify({
|
|
1526
|
+
ok: true,
|
|
1527
|
+
sessions,
|
|
1528
|
+
summary,
|
|
1529
|
+
summary_scope: filtered
|
|
1530
|
+
? "every session in the read scope — NOT narrowed by the filters applied to `sessions`"
|
|
1531
|
+
: "every session in the read scope",
|
|
1532
|
+
sessions_returned: sessions.length,
|
|
1533
|
+
}),
|
|
712
1534
|
},
|
|
713
1535
|
],
|
|
714
1536
|
};
|
|
@@ -725,7 +1547,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
725
1547
|
console.log(`[${ts()}] hub → get_session_status: ${alias}`);
|
|
726
1548
|
const sessionParams: any[] = [alias];
|
|
727
1549
|
let sessionSql = "SELECT * FROM sessions WHERE alias = ?1";
|
|
728
|
-
sessionSql =
|
|
1550
|
+
sessionSql = addAgentNetworkScope(sessionSql, sessionParams, readScope, { alias: "alias", nodeId: "node_id" });
|
|
729
1551
|
const session = db.get(sessionSql, ...sessionParams);
|
|
730
1552
|
|
|
731
1553
|
const pendingParams: any[] = [alias];
|
|
@@ -766,7 +1588,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
766
1588
|
},
|
|
767
1589
|
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);
|
|
768
1590
|
const effectiveNetId = getNetworkId(netId);
|
|
769
|
-
const
|
|
1591
|
+
const authOrigin: TaskAuthOrigin = callerTokenIsNetwork
|
|
1592
|
+
? "node"
|
|
1593
|
+
: enforceUserId
|
|
1594
|
+
? "user"
|
|
1595
|
+
: "legacy";
|
|
1596
|
+
const metaJson = normalizeMetaJson(stampTaskAuthOrigin(meta, authOrigin));
|
|
770
1597
|
|
|
771
1598
|
// Role check FIRST — round5 follow-up (通信牛 oracle catch):
|
|
772
1599
|
// the explicit-parent verification below distinguishes
|
|
@@ -775,8 +1602,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
775
1602
|
// existence + ownership of foreign parents via the difference in
|
|
776
1603
|
// error codes. Run canWrite first so a viewer ALWAYS gets the
|
|
777
1604
|
// same `permission_denied` regardless of parent state.
|
|
778
|
-
|
|
779
|
-
|
|
1605
|
+
{
|
|
1606
|
+
const sendDenied = agentSendDenied(effectiveNetId, alias, _fromIn, "send_task", meta);
|
|
1607
|
+
if (sendDenied) return sendDenied;
|
|
780
1608
|
}
|
|
781
1609
|
|
|
782
1610
|
// Resolve parent_task_id: explicit > inferred (caller's most recent
|
|
@@ -841,6 +1669,46 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
841
1669
|
const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
|
|
842
1670
|
if (target.state === "not_found") return deliveryTargetReply(target)!;
|
|
843
1671
|
|
|
1672
|
+
// Dashboard sends carry a random client_request_id inside meta. Derive
|
|
1673
|
+
// the task primary key from authenticated scope + that request id so a
|
|
1674
|
+
// lost HTTP response can be retried safely, even after a hub restart.
|
|
1675
|
+
// The existing row must match the full request; key reuse with changed
|
|
1676
|
+
// content/target fails closed instead of silently returning another task.
|
|
1677
|
+
const clientRequestId = clientRequestIdFromMeta(meta);
|
|
1678
|
+
const id = clientRequestId
|
|
1679
|
+
? idempotentTaskId(effectiveNetId ?? null, from_session, clientRequestId)
|
|
1680
|
+
: uuidv4();
|
|
1681
|
+
if (clientRequestId) {
|
|
1682
|
+
const existing = db.get<StoredIdempotentTask & { to_node_id: string | null }>(
|
|
1683
|
+
"SELECT task_id, from_name, to_node_id, to_name, priority, content, network_id, meta_json, status FROM tasks WHERE task_id = ?1",
|
|
1684
|
+
[id],
|
|
1685
|
+
);
|
|
1686
|
+
if (existing) {
|
|
1687
|
+
if (!idempotentTaskMatches(existing, {
|
|
1688
|
+
fromName: from_session, toName: targetAlias, priority, content: task,
|
|
1689
|
+
networkId: effectiveNetId ?? null, metaJson,
|
|
1690
|
+
})) {
|
|
1691
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
1692
|
+
ok: false, error: "idempotency_conflict",
|
|
1693
|
+
message: "client_request_id was already used with a different task payload",
|
|
1694
|
+
}) }] };
|
|
1695
|
+
}
|
|
1696
|
+
console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: REPLAY (key=request_id, task=${existing.task_id.slice(0, 13)})`);
|
|
1697
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
1698
|
+
ok: true, message_id: existing.task_id, task_id: existing.task_id,
|
|
1699
|
+
task_status: existing.status, idempotent_replay: true,
|
|
1700
|
+
// A replay acknowledges the already-persisted dispatch. Use that
|
|
1701
|
+
// row rather than today's session record so alias reuse or a
|
|
1702
|
+
// restarted process cannot rewrite historical recipient identity.
|
|
1703
|
+
actual_to: {
|
|
1704
|
+
alias: existing.to_name,
|
|
1705
|
+
to_node_id: existing.to_node_id ?? null,
|
|
1706
|
+
network_id: existing.network_id ?? null,
|
|
1707
|
+
},
|
|
1708
|
+
}) }] };
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
844
1712
|
// #212 dedup guardrail. If this exact (from, to, content) has already
|
|
845
1713
|
// been delivered within COMMHUB_SEND_DEDUP_WINDOW_MS (default 5 min)
|
|
846
1714
|
// we refuse the call and surface a structured `duplicate_send`
|
|
@@ -849,7 +1717,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
849
1717
|
// incident: 50+ identical dispatches across 5 LLM turns ignored
|
|
850
1718
|
// three STOP replies — the LLM cannot be trusted to debounce
|
|
851
1719
|
// itself, so the runtime layer must.
|
|
852
|
-
|
|
1720
|
+
//
|
|
1721
|
+
// A send carrying a validated client_request_id is keyed on that id
|
|
1722
|
+
// instead of the content hash (send_dedup.ts header): a human typing
|
|
1723
|
+
// the same short text twice sent two messages. Same-id retries were
|
|
1724
|
+
// already answered from the durable row above.
|
|
1725
|
+
const dedupScope = { requestId: clientRequestId, networkId: effectiveNetId ?? null };
|
|
1726
|
+
const dedup = sharedSendDedup.check(from_session, targetAlias, task, undefined, dedupScope);
|
|
853
1727
|
if (dedup.duplicate) {
|
|
854
1728
|
const payload = buildDuplicateSendPayload({
|
|
855
1729
|
from: from_session,
|
|
@@ -857,22 +1731,28 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
857
1731
|
ageMs: dedup.ageMs,
|
|
858
1732
|
windowMs: sharedSendDedup.windowMs,
|
|
859
1733
|
});
|
|
860
|
-
console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: DROPPED duplicate (age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
|
|
1734
|
+
console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: DROPPED duplicate (key=${dedup.kind}, age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
|
|
861
1735
|
return { content: [{ type: "text" as const, text: JSON.stringify(payload) }] };
|
|
862
1736
|
}
|
|
863
1737
|
|
|
1738
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 1/6).
|
|
1739
|
+
{
|
|
1740
|
+
const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
|
|
1741
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
1742
|
+
}
|
|
1743
|
+
|
|
864
1744
|
console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
|
|
865
|
-
const id = uuidv4();
|
|
866
1745
|
const fromNodeId = resolveNodeIdForAlias(from_session, effectiveNetId);
|
|
867
1746
|
const targetNodeId = target.session?.node_id ?? null;
|
|
1747
|
+
const resolvedActualTo = actualTo(target, effectiveNetId);
|
|
868
1748
|
// 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
|
|
869
1749
|
// dashboard 在派任务一刻就反映出"任务已下发",不再等 agent 的
|
|
870
1750
|
// report_status 心跳;status 字段交给 agent,避免与 working/idle
|
|
871
1751
|
// 报告冲突)。
|
|
872
1752
|
db.transaction(() => {
|
|
873
1753
|
db.run(
|
|
874
|
-
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, context, from_session, requires_response, network_id, meta_json)
|
|
875
|
-
VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, ?7, 'reply', ?8, ?9)`,
|
|
1754
|
+
`INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, context, from_session, requires_response, network_id, meta_json)
|
|
1755
|
+
VALUES (?1, ?1, ?2, ?3, 'task', ?4, ?5, ?6, ?7, 'reply', ?8, ?9)`,
|
|
876
1756
|
[id, targetAlias, targetNodeId, priority, task, context ?? null, from_session, effectiveNetId ?? null, metaJson]
|
|
877
1757
|
);
|
|
878
1758
|
db.run(
|
|
@@ -884,12 +1764,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
884
1764
|
let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
|
|
885
1765
|
touchSql = addScope(touchSql, touchParams, effectiveNetId);
|
|
886
1766
|
db.run(touchSql, touchParams);
|
|
1767
|
+
// #1548 —— 能派活的发送方不可能真的 blocked;此处出 blocked → idle。
|
|
1768
|
+
releaseBlockedSession(from_session, effectiveNetId);
|
|
887
1769
|
});
|
|
888
1770
|
logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${targetAlias} (parent=${parentTaskId.slice(0,8)})` : `→ ${targetAlias}`);
|
|
889
1771
|
// Only stamp the dedup index after the inbox/tasks transaction
|
|
890
1772
|
// succeeds, so a failed insert never silently shadows a legitimate
|
|
891
1773
|
// retry.
|
|
892
|
-
sharedSendDedup.record(from_session, targetAlias, task);
|
|
1774
|
+
sharedSendDedup.record(from_session, targetAlias, task, undefined, dedupScope);
|
|
893
1775
|
|
|
894
1776
|
// SSE push by alias.
|
|
895
1777
|
// The SSE channel is keyed by alias (subscribers connected to /events/<alias>),
|
|
@@ -902,9 +1784,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
902
1784
|
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
903
1785
|
pendingSql = addScope(pendingSql, pendingParams, effectiveNetId);
|
|
904
1786
|
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
1787
|
+
// 🔴 不要用 last_seen_at 的时间戳去猜「有没有人在听」——SSE 订阅者注册表
|
|
1788
|
+
// 就是那件事本身。pushEvent 在没有订阅者时已经是 no-op,所以这道闸不提供
|
|
1789
|
+
// 任何保护,只会把**连着但最近没心跳**的会话排除掉;而全网「停发心跳/状态类
|
|
1790
|
+
// 消息」的省额度规则让安静恰恰是常态。上面那段注释早就写着 "Push
|
|
1791
|
+
// unconditionally",同一类缺陷上次也是打在 Dashboard 任务上(f015d9d6:
|
|
1792
|
+
// 任务标了 delivered,会话却从未收到推送)。
|
|
1793
|
+
pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority, from: from_session, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
|
|
1794
|
+
const actuallyDelivered = hasSubscribers(targetAlias, effectiveNetId);
|
|
1795
|
+
// #461 network observer summary — unconditional (task row exists
|
|
1796
|
+
// even when the target is offline/queued), metadata only.
|
|
1797
|
+
pushNetworkObserverEvent(effectiveNetId, { type: "new_task", task_id: id, from: from_session, to: targetAlias, status: actuallyDelivered ? "delivered" : "queued", priority });
|
|
908
1798
|
|
|
909
1799
|
if (target.state === "offline") {
|
|
910
1800
|
return {
|
|
@@ -919,6 +1809,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
919
1809
|
task_id: id,
|
|
920
1810
|
message_id: id,
|
|
921
1811
|
session_status: target.session.status ?? "offline",
|
|
1812
|
+
actual_to: resolvedActualTo,
|
|
922
1813
|
...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
|
|
923
1814
|
}),
|
|
924
1815
|
}],
|
|
@@ -932,6 +1823,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
932
1823
|
text: JSON.stringify({
|
|
933
1824
|
ok: true,
|
|
934
1825
|
message_id: id,
|
|
1826
|
+
actual_to: resolvedActualTo,
|
|
935
1827
|
...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
|
|
936
1828
|
session_status: target.session?.status ?? "unknown",
|
|
937
1829
|
}),
|
|
@@ -948,14 +1840,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
948
1840
|
alias: z.string().min(1).max(200).describe("Target session alias"),
|
|
949
1841
|
message: z.string().min(1).max(10000).describe("Message content"),
|
|
950
1842
|
from_session: z.string().max(200).optional(),
|
|
1843
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
951
1844
|
},
|
|
952
|
-
async ({ alias, message, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
953
|
-
const effectiveNetId = getNetworkId(
|
|
954
|
-
|
|
1845
|
+
async ({ alias, message, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1846
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1847
|
+
{ const sendDenied = agentSendDenied(effectiveNetId, alias, _fromIn, "write"); if (sendDenied) return sendDenied; }
|
|
955
1848
|
const canonical = resolveCanonicalAlias(effectiveNetId, alias);
|
|
956
1849
|
const targetAlias = canonical.alias;
|
|
957
1850
|
const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
|
|
958
1851
|
if (target.state === "not_found") return deliveryTargetReply(target)!;
|
|
1852
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 2/6).
|
|
1853
|
+
{
|
|
1854
|
+
const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
|
|
1855
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
1856
|
+
}
|
|
959
1857
|
console.log(`[${ts()}] ${from_session} → send_message → ${targetAlias}: ${message.slice(0, 60)}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
|
|
960
1858
|
const id = uuidv4();
|
|
961
1859
|
db.run(
|
|
@@ -964,9 +1862,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
964
1862
|
[id, targetAlias, target.session?.node_id ?? null, message, from_session, effectiveNetId ?? null]
|
|
965
1863
|
);
|
|
966
1864
|
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
}
|
|
1865
|
+
// 同 send_task:可达性用订阅者注册表判,不用心跳时间戳猜。
|
|
1866
|
+
pushEvent(targetAlias, { type: "new_message", inbox_count: pendingInboxCount(targetAlias, effectiveNetId), from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
|
|
970
1867
|
|
|
971
1868
|
const offlineReply = deliveryTargetReply(target, { message_id: id });
|
|
972
1869
|
if (offlineReply) {
|
|
@@ -994,96 +1891,270 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
994
1891
|
}
|
|
995
1892
|
);
|
|
996
1893
|
|
|
997
|
-
// ── V2
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1894
|
+
// ── V2/V3 reply primitives ──
|
|
1895
|
+
const replyToolSchema = {
|
|
1896
|
+
// #1085 — alias is OPTIONAL: when omitted (or empty) and `in_reply_to`
|
|
1897
|
+
// is set, the hub derives the target from the original task's
|
|
1898
|
+
// `from_name` (it already SELECTs that task by in_reply_to below).
|
|
1899
|
+
// The SDK-runtime bridge (agent-node/src/commhub-mcp.ts) exposes
|
|
1900
|
+
// send_reply as (task_id, text, status) with NO alias, so before this
|
|
1901
|
+
// it always sent alias=undefined → -32602 "expected string, received
|
|
1902
|
+
// undefined at alias". The target was already knowable from the task,
|
|
1903
|
+
// so requiring the caller to pass it was a redundant-param trap.
|
|
1904
|
+
alias: z.string().min(1).max(200).optional().describe("Target session alias — optional; derived from in_reply_to's task sender when omitted"),
|
|
1003
1905
|
text: z.string().min(1).max(10000).describe("Reply content"),
|
|
1004
1906
|
in_reply_to: z.string().max(200).optional().describe("Original task/message ID"),
|
|
1005
1907
|
status: z.enum(["replied", "failed", "cancelled"]).optional().default("replied").describe("Task outcome"),
|
|
1006
1908
|
from_session: z.string().max(200).optional(),
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1909
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1910
|
+
// #507 — top-level attachments (parity with REST /api/task L506). MCP Zod
|
|
1911
|
+
// otherwise silently strips unknown fields, so a caller passing
|
|
1912
|
+
// `attachments` before this schema entry existed would see `ok:true`
|
|
1913
|
+
// and never learn the attachments were dropped. Validated by
|
|
1914
|
+
// validateAttachments (uploads.ts) — the same helper the REST path uses.
|
|
1915
|
+
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."),
|
|
1916
|
+
// #507 — optional structured metadata (parity with send_task L837). If
|
|
1917
|
+
// both `attachments` (top-level) and `meta.attachments` are supplied,
|
|
1918
|
+
// top-level wins (same rule as REST /api/task L2101).
|
|
1919
|
+
meta: z.any().optional().describe("Optional structured reply metadata, e.g. { attachments: [...] }."),
|
|
1920
|
+
};
|
|
1921
|
+
const handleReply = async (args: any, peerCapabilityRequired: boolean) => {
|
|
1922
|
+
const { alias, text, in_reply_to, status: replyStatus = "replied", from_session: _fromIn, network_id: netId, attachments, meta } = args;
|
|
1923
|
+
const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1924
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1010
1925
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1926
|
+
if (peerCapabilityRequired && !in_reply_to) {
|
|
1927
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "peer_reply_task_required" }) }] };
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// #507 — validate attachments BEFORE any DB write. Rejects malformed
|
|
1931
|
+
// input (bad file_id, >20 items, size > cap, non-object item, wrong
|
|
1932
|
+
// type field) with an explicit error the caller can act on. Empty /
|
|
1933
|
+
// absent attachments return { ok: true, attachments: [] } — the
|
|
1934
|
+
// reverse-(e) invariant (no attachments → behavior unchanged) is
|
|
1935
|
+
// pinned by tests that assert byte-identical response shape
|
|
1936
|
+
// before/after this validation runs.
|
|
1937
|
+
const attachmentsResult = validateAttachments(attachments ?? (meta && typeof meta === "object" ? (meta as any).attachments : undefined));
|
|
1938
|
+
if (!attachmentsResult.ok) {
|
|
1939
|
+
return {
|
|
1940
|
+
content: [{
|
|
1941
|
+
type: "text" as const,
|
|
1942
|
+
text: JSON.stringify({
|
|
1943
|
+
ok: false,
|
|
1944
|
+
error: "bad_attachments",
|
|
1945
|
+
message: attachmentsResult.error,
|
|
1946
|
+
}),
|
|
1947
|
+
}],
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
const mergedMeta = attachmentsResult.attachments.length
|
|
1951
|
+
? { ...(meta && typeof meta === "object" ? meta : {}), attachments: attachmentsResult.attachments }
|
|
1952
|
+
: meta;
|
|
1953
|
+
const metaJson = normalizeMetaJson(mergedMeta);
|
|
1954
|
+
|
|
1955
|
+
// #1085 — derive the reply target when the caller omitted `alias`.
|
|
1956
|
+
// The original task's `from_name` IS the reply target; the hub knows
|
|
1957
|
+
// it from `in_reply_to`. Without this, an SDK-runtime send_reply
|
|
1958
|
+
// (schema has no alias) died with -32602 before reaching this handler.
|
|
1959
|
+
let effectiveAlias: string | undefined =
|
|
1960
|
+
(typeof alias === "string" && alias.trim()) ? alias : undefined;
|
|
1961
|
+
if (!effectiveAlias) {
|
|
1962
|
+
if (!in_reply_to) {
|
|
1963
|
+
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" }) }] };
|
|
1032
1964
|
}
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
error: "reply_task_terminal",
|
|
1040
|
-
message: `cannot apply reply: task is already terminal (${taskBefore.status})`,
|
|
1041
|
-
in_reply_to,
|
|
1042
|
-
task_status: taskBefore.status,
|
|
1043
|
-
reply_queued: false,
|
|
1044
|
-
}),
|
|
1045
|
-
}],
|
|
1046
|
-
};
|
|
1965
|
+
const derivParams: any[] = [in_reply_to];
|
|
1966
|
+
let derivSql = "SELECT from_name FROM tasks WHERE task_id = ?1";
|
|
1967
|
+
derivSql = addScope(derivSql, derivParams, effectiveNetId);
|
|
1968
|
+
const derived = db.get<{ from_name: string }>(derivSql, ...derivParams);
|
|
1969
|
+
if (!derived?.from_name) {
|
|
1970
|
+
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})` }) }] };
|
|
1047
1971
|
}
|
|
1972
|
+
effectiveAlias = derived.from_name;
|
|
1048
1973
|
}
|
|
1049
|
-
|
|
1050
|
-
const
|
|
1974
|
+
|
|
1975
|
+
const canonicalReplyTarget = resolveCanonicalAlias(effectiveNetId, effectiveAlias);
|
|
1976
|
+
const replyTargetAlias = canonicalReplyTarget.alias;
|
|
1977
|
+
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}]` : ""}`);
|
|
1978
|
+
const id = uuidv4();
|
|
1979
|
+
const replyTargetNodeId = resolveNodeIdForAlias(replyTargetAlias, effectiveNetId);
|
|
1980
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 3/6).
|
|
1981
|
+
{
|
|
1982
|
+
const lc = assertNodeActive(replyTargetAlias, effectiveNetId ?? null);
|
|
1983
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
1984
|
+
}
|
|
1985
|
+
const replyOutcome = db.transaction(() => {
|
|
1986
|
+
type ReplyTask = {
|
|
1987
|
+
status: string;
|
|
1988
|
+
from_node_id: string | null;
|
|
1989
|
+
from_name: string;
|
|
1990
|
+
to_name: string;
|
|
1991
|
+
to_node_id: string | null;
|
|
1992
|
+
};
|
|
1993
|
+
let taskBefore: ReplyTask | null = null;
|
|
1994
|
+
let callerNodeId: string | null = null;
|
|
1995
|
+
if (in_reply_to) {
|
|
1996
|
+
const taskParams: any[] = [in_reply_to];
|
|
1997
|
+
let taskSql = "SELECT status, from_node_id, from_name, to_name, to_node_id FROM tasks WHERE task_id = ?1";
|
|
1998
|
+
taskSql = addScope(taskSql, taskParams, effectiveNetId);
|
|
1999
|
+
taskBefore = db.get<ReplyTask>(taskSql, ...taskParams) ?? null;
|
|
2000
|
+
if (!taskBefore) return { ok: false as const, error: "reply_task_not_found" as const };
|
|
2001
|
+
if (!["created", "delivered", "acked", "running"].includes(taskBefore.status)) {
|
|
2002
|
+
return { ok: false as const, error: "reply_task_terminal" as const, taskStatus: taskBefore.status };
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
// V3 atomic peer replies are capability-negotiated. Both identity
|
|
2006
|
+
// bindings and the recipient capability are re-read inside this
|
|
2007
|
+
// transaction before any inbox/task/run write. Legacy/unbound rows
|
|
2008
|
+
// fail toward compatibility send_reply, which terminalizes the
|
|
2009
|
+
// original task instead of creating a second response-requiring row.
|
|
2010
|
+
if (peerCapabilityRequired) {
|
|
2011
|
+
if (!taskBefore.from_node_id) {
|
|
2012
|
+
// Origin type is an intrinsic property of the task and must be
|
|
2013
|
+
// decided before caller capability. A Dashboard/human task still
|
|
2014
|
+
// needs the established send_reply terminalization path when the
|
|
2015
|
+
// replying node uses a legacy/unbound token.
|
|
2016
|
+
return { ok: false as const, error: "peer_reply_origin_not_node" as const };
|
|
2017
|
+
}
|
|
2018
|
+
if (!callerTokenIsNetwork || !callerTokenId || !enforceNetworkId) {
|
|
2019
|
+
return { ok: false as const, error: "peer_reply_node_token_required" as const };
|
|
2020
|
+
}
|
|
2021
|
+
const token = db.get<{ bound_node_id: string | null }>(
|
|
2022
|
+
"SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
|
|
2023
|
+
callerTokenId,
|
|
2024
|
+
enforceNetworkId,
|
|
2025
|
+
);
|
|
2026
|
+
if (!token?.bound_node_id || !taskBefore.to_node_id) {
|
|
2027
|
+
return { ok: false as const, error: "peer_reply_unsupported" as const };
|
|
2028
|
+
}
|
|
2029
|
+
if (token.bound_node_id !== taskBefore.to_node_id) {
|
|
2030
|
+
return { ok: false as const, error: "reply_task_not_owned" as const };
|
|
2031
|
+
}
|
|
2032
|
+
callerNodeId = token.bound_node_id;
|
|
2033
|
+
const canonicalTarget = replyTargetAlias;
|
|
2034
|
+
const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, taskBefore.from_name).alias;
|
|
2035
|
+
if (canonicalTarget !== canonicalOrigin) {
|
|
2036
|
+
return { ok: false as const, error: "reply_target_mismatch" as const };
|
|
2037
|
+
}
|
|
2038
|
+
const recipient = db.get<{ peer_reply_inbox_capable: number }>(
|
|
2039
|
+
`SELECT peer_reply_inbox_capable FROM sessions
|
|
2040
|
+
WHERE node_id = ?1 AND network_id = ?2 AND alias = ?3
|
|
2041
|
+
ORDER BY updated_at DESC LIMIT 1`,
|
|
2042
|
+
taskBefore.from_node_id,
|
|
2043
|
+
enforceNetworkId,
|
|
2044
|
+
canonicalOrigin,
|
|
2045
|
+
);
|
|
2046
|
+
if (recipient?.peer_reply_inbox_capable !== 1) {
|
|
2047
|
+
return { ok: false as const, error: "peer_reply_unsupported" as const };
|
|
2048
|
+
}
|
|
2049
|
+
} else if (callerTokenIsNetwork && enforceNetworkId) {
|
|
2050
|
+
// Compatibility send_reply is still an exact task transition for
|
|
2051
|
+
// node callers. Bind it to the token-authenticated assignee alias
|
|
2052
|
+
// and immutable original sender. Alias binding survives a
|
|
2053
|
+
// legitimate node-id rotation without letting another node close
|
|
2054
|
+
// the task.
|
|
2055
|
+
const canonicalCaller = resolveCanonicalAlias(enforceNetworkId, from_session).alias;
|
|
2056
|
+
const canonicalAssignee = resolveCanonicalAlias(enforceNetworkId, taskBefore.to_name).alias;
|
|
2057
|
+
if (canonicalCaller !== canonicalAssignee) {
|
|
2058
|
+
return { ok: false as const, error: "reply_task_not_owned" as const };
|
|
2059
|
+
}
|
|
2060
|
+
const canonicalOrigin = resolveCanonicalAlias(enforceNetworkId, taskBefore.from_name).alias;
|
|
2061
|
+
if (replyTargetAlias !== canonicalOrigin) {
|
|
2062
|
+
return { ok: false as const, error: "reply_target_mismatch" as const };
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
// #507 — write meta_json on inbox insert (parity with send_task L952).
|
|
2069
|
+
// Prior to this the attachments field on a send_reply call was
|
|
2070
|
+
// silently stripped by MCP Zod, leaving `ok:true` with no persisted
|
|
2071
|
+
// meta.
|
|
1051
2072
|
db.run(
|
|
1052
|
-
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
|
|
1053
|
-
VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7)`,
|
|
1054
|
-
[id,
|
|
2073
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id, meta_json)
|
|
2074
|
+
VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7, ?8)`,
|
|
2075
|
+
[id, replyTargetAlias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null, metaJson]
|
|
1055
2076
|
);
|
|
1056
2077
|
|
|
1057
2078
|
// 更新 tasks 表
|
|
1058
2079
|
if (in_reply_to) {
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
2080
|
+
// #507 — persist meta_json onto the tasks row too, so
|
|
2081
|
+
// dashboard's task view sees the reply's attachments alongside
|
|
2082
|
+
// the reply text (parity with send_task L957/959 which writes
|
|
2083
|
+
// meta_json into both inbox and tasks). The COALESCE guards
|
|
2084
|
+
// against clobbering pre-existing meta_json when this reply
|
|
2085
|
+
// brings no attachments (metaJson === null → keep the existing
|
|
2086
|
+
// task meta_json unchanged). Reverse-(e) invariant.
|
|
2087
|
+
// #1823 —— 回复的附件写到 tasks.meta_json.reply_attachments,**不碰** meta_json.attachments:
|
|
2088
|
+
// 那个键是提问者随任务带的附件,客户端把它画在提问者的气泡里。以前这里用回复的 metaJson
|
|
2089
|
+
// 整体 COALESCE 替换,于是 agent 回的图出现在提问者气泡下面、提问者自己带的附件被覆盖
|
|
2090
|
+
// (Vincent 2026-09-06 实测「好像没成功发送给我」)。inbox 行(收件方视角)照旧写 metaJson。
|
|
2091
|
+
const replyTaskMetaJson = (() => {
|
|
2092
|
+
if (!attachmentsResult.attachments.length) return null;
|
|
2093
|
+
const rowParams: any[] = [in_reply_to];
|
|
2094
|
+
let rowSql = "SELECT meta_json FROM tasks WHERE task_id = ?1";
|
|
2095
|
+
rowSql = addScope(rowSql, rowParams, effectiveNetId);
|
|
2096
|
+
const existing = db.get<{ meta_json: string | null }>(rowSql, ...rowParams)?.meta_json ?? null;
|
|
2097
|
+
let base: Record<string, unknown> = {};
|
|
2098
|
+
if (existing) { try { const parsed = JSON.parse(existing); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) base = parsed; } catch {} }
|
|
2099
|
+
return JSON.stringify({ ...base, reply_attachments: attachmentsResult.attachments });
|
|
2100
|
+
})();
|
|
2101
|
+
const updateParams: any[] = [replyStatus, text, replyTaskMetaJson, in_reply_to];
|
|
2102
|
+
let updateSql = `UPDATE tasks
|
|
2103
|
+
SET status = ?1,
|
|
2104
|
+
result = ?2,
|
|
2105
|
+
completed_at = datetime('now'),
|
|
2106
|
+
meta_json = COALESCE(?3, meta_json)
|
|
2107
|
+
WHERE task_id = ?4 AND status IN ('created', 'delivered', 'acked', 'running')`;
|
|
2108
|
+
if (peerCapabilityRequired && callerTokenIsNetwork && taskBefore) {
|
|
2109
|
+
if (taskBefore.to_node_id) {
|
|
2110
|
+
updateParams.push(callerNodeId);
|
|
2111
|
+
updateSql += ` AND to_node_id = ?${updateParams.length}`;
|
|
2112
|
+
} else {
|
|
2113
|
+
updateParams.push(taskBefore.to_name);
|
|
2114
|
+
updateSql += ` AND to_node_id IS NULL AND to_name = ?${updateParams.length}`;
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
1062
2117
|
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
1063
2118
|
const result = db.run(updateSql, updateParams);
|
|
1064
2119
|
if (result.changes === 0) {
|
|
1065
|
-
|
|
1066
|
-
return false;
|
|
2120
|
+
throw new Error(`reply_atomic_cas_failed:${in_reply_to}`);
|
|
1067
2121
|
}
|
|
1068
|
-
|
|
2122
|
+
syncScheduledRunForTask(in_reply_to, effectiveNetId);
|
|
2123
|
+
// #1548 —— 终态回复是最强的活性证据:一个还标着 blocked 的发送方在此回到 idle。
|
|
2124
|
+
// 只出 blocked;working 可能真在忙别的任务,不碰。
|
|
2125
|
+
releaseBlockedSession(from_session, effectiveNetId);
|
|
2126
|
+
return { ok: true as const, replyLogged: true };
|
|
1069
2127
|
}
|
|
1070
|
-
return false;
|
|
2128
|
+
return { ok: true as const, replyLogged: false };
|
|
1071
2129
|
});
|
|
1072
2130
|
|
|
1073
|
-
if (
|
|
2131
|
+
if (!replyOutcome.ok) {
|
|
2132
|
+
const taskStatus = "taskStatus" in replyOutcome ? replyOutcome.taskStatus : undefined;
|
|
2133
|
+
const messages: Record<string, string> = {
|
|
2134
|
+
reply_task_not_found: `cannot apply reply: task not found (${in_reply_to})`,
|
|
2135
|
+
reply_task_terminal: `cannot apply reply: task is already terminal (${taskStatus})`,
|
|
2136
|
+
peer_reply_node_token_required: "atomic peer reply requires a node token",
|
|
2137
|
+
peer_reply_unsupported: "recipient or caller does not support atomic peer replies",
|
|
2138
|
+
peer_reply_origin_not_node: "original task sender is not a node; use send_reply",
|
|
2139
|
+
reply_node_identity_unbound: "cannot apply reply: node identity is not token-bound",
|
|
2140
|
+
reply_task_not_owned: "cannot apply reply: task is not owned by this node",
|
|
2141
|
+
reply_target_mismatch: "cannot apply reply: target is not the original task sender",
|
|
2142
|
+
};
|
|
1074
2143
|
return {
|
|
1075
2144
|
content: [{
|
|
1076
2145
|
type: "text" as const,
|
|
1077
2146
|
text: JSON.stringify({
|
|
1078
2147
|
ok: false,
|
|
1079
|
-
error:
|
|
1080
|
-
message:
|
|
2148
|
+
error: replyOutcome.error,
|
|
2149
|
+
message: messages[replyOutcome.error],
|
|
1081
2150
|
in_reply_to,
|
|
2151
|
+
...(taskStatus ? { task_status: taskStatus } : {}),
|
|
1082
2152
|
reply_queued: false,
|
|
1083
2153
|
}),
|
|
1084
2154
|
}],
|
|
1085
2155
|
};
|
|
1086
2156
|
}
|
|
2157
|
+
const replyLogged = replyOutcome.replyLogged;
|
|
1087
2158
|
|
|
1088
2159
|
// Log event after commit (outside transaction)
|
|
1089
2160
|
if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
|
|
@@ -1120,16 +2191,71 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1120
2191
|
}
|
|
1121
2192
|
}
|
|
1122
2193
|
|
|
1123
|
-
const session = scopedSessionStatus(
|
|
1124
|
-
pushEvent(
|
|
2194
|
+
const session = scopedSessionStatus(replyTargetAlias, effectiveNetId);
|
|
2195
|
+
pushEvent(replyTargetAlias, { type: "new_reply", inbox_count: pendingInboxCount(replyTargetAlias, effectiveNetId), from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
|
|
2196
|
+
// #461 network observer summary — ids + routing only, no reply text.
|
|
2197
|
+
pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to ?? null, message_id: id, from: from_session, to: replyTargetAlias, status: replyStatus });
|
|
2198
|
+
|
|
2199
|
+
// #498 compatibility tripwire. Legacy send_reply is intentionally kept
|
|
2200
|
+
// for old agents and Dashboard callers during rollout, but it is not the
|
|
2201
|
+
// capability-negotiated atomic peer primitive. Warn only for an actual
|
|
2202
|
+
// agent target; Dashboard hub/api replies remain quiet. New agents call
|
|
2203
|
+
// send_peer_reply and never see this warning.
|
|
2204
|
+
const targetIsAgent = replyTargetNodeId !== null
|
|
2205
|
+
&& replyTargetAlias !== "hub"
|
|
2206
|
+
&& replyTargetAlias !== "api";
|
|
2207
|
+
const warning = !peerCapabilityRequired && targetIsAgent
|
|
2208
|
+
? `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.)`
|
|
2209
|
+
: undefined;
|
|
2210
|
+
|
|
2211
|
+
// #507 — echo attachments READ BACK FROM DB (lead 2b5f6634): the point
|
|
2212
|
+
// of the echo is to prove attachments actually landed in storage, not
|
|
2213
|
+
// to reflect the in-memory variable we tried to write. Reading
|
|
2214
|
+
// `attachmentsResult.attachments` here would still show `ok:true` +
|
|
2215
|
+
// full echo if the UPDATE failed with `changes=0` (e.g. task moved to
|
|
2216
|
+
// terminal between the pre-check and the transaction). SELECT from
|
|
2217
|
+
// inbox.meta_json (always written, uses the id we just generated) so
|
|
2218
|
+
// the echo means "these attachments are on disk now". Absent field
|
|
2219
|
+
// when the caller sent no attachments — reverse-(e) invariant: a
|
|
2220
|
+
// no-attachment call returns the exact same response shape as before
|
|
2221
|
+
// this PR. Persisted-attachments field is only added when the caller
|
|
2222
|
+
// actually asked for attachments.
|
|
2223
|
+
let attachmentsSaved: unknown[] | null = null;
|
|
2224
|
+
if (attachmentsResult.attachments.length > 0) {
|
|
2225
|
+
const persistedRow = db.get<{ meta_json: string | null }>(
|
|
2226
|
+
"SELECT meta_json FROM inbox WHERE id = ?1", [id]
|
|
2227
|
+
);
|
|
2228
|
+
const persistedMeta = parseMetaJson(persistedRow?.meta_json ?? null);
|
|
2229
|
+
attachmentsSaved = persistedMeta && typeof persistedMeta === "object" && Array.isArray((persistedMeta as any).attachments)
|
|
2230
|
+
? (persistedMeta as any).attachments
|
|
2231
|
+
: [];
|
|
2232
|
+
}
|
|
1125
2233
|
|
|
1126
2234
|
return {
|
|
1127
2235
|
content: [{
|
|
1128
2236
|
type: "text" as const,
|
|
1129
|
-
text: JSON.stringify({
|
|
2237
|
+
text: JSON.stringify({
|
|
2238
|
+
ok: true,
|
|
2239
|
+
message_id: id,
|
|
2240
|
+
session_status: session?.status ?? "unknown",
|
|
2241
|
+
...(warning ? { warning } : {}),
|
|
2242
|
+
...(attachmentsSaved !== null ? { attachments_saved: attachmentsSaved } : {}),
|
|
2243
|
+
}),
|
|
1130
2244
|
}],
|
|
1131
2245
|
};
|
|
1132
|
-
}
|
|
2246
|
+
};
|
|
2247
|
+
|
|
2248
|
+
server.tool(
|
|
2249
|
+
"send_reply",
|
|
2250
|
+
"Reply to a Dashboard/UI-originated task. Also serves as the terminal legacy fallback when atomic peer reply capability is unavailable.",
|
|
2251
|
+
replyToolSchema,
|
|
2252
|
+
(args) => handleReply(args, false),
|
|
2253
|
+
);
|
|
2254
|
+
server.tool(
|
|
2255
|
+
"send_peer_reply",
|
|
2256
|
+
"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.",
|
|
2257
|
+
replyToolSchema,
|
|
2258
|
+
(args) => handleReply(args, true),
|
|
1133
2259
|
);
|
|
1134
2260
|
|
|
1135
2261
|
// ── V2: send_ack (不入 inbox,仅更新状态) ──
|
|
@@ -1139,9 +2265,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1139
2265
|
{
|
|
1140
2266
|
task_id: z.string().min(1).max(200).describe("Task ID to acknowledge"),
|
|
1141
2267
|
from_session: z.string().max(200).optional(),
|
|
2268
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1142
2269
|
},
|
|
1143
|
-
async ({ task_id, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1144
|
-
const effectiveNetId = getNetworkId(
|
|
2270
|
+
async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
2271
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1145
2272
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1146
2273
|
console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
|
|
1147
2274
|
const updateParams: any[] = [task_id];
|
|
@@ -1165,9 +2292,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1165
2292
|
{
|
|
1166
2293
|
task_id: z.string().min(1).max(200).describe("Task ID to retry"),
|
|
1167
2294
|
from_session: z.string().max(200).optional(),
|
|
2295
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1168
2296
|
},
|
|
1169
|
-
async ({ task_id, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1170
|
-
const effectiveNetId = getNetworkId(
|
|
2297
|
+
async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
2298
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1171
2299
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1172
2300
|
console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
|
|
1173
2301
|
// Find the original task
|
|
@@ -1181,6 +2309,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1181
2309
|
if (!["failed", "expired", "cancelled"].includes(task.status)) {
|
|
1182
2310
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task status is ${task.status}, not retryable` }) }] };
|
|
1183
2311
|
}
|
|
2312
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 4/6).
|
|
2313
|
+
{
|
|
2314
|
+
const lc = assertNodeActive(task.to_name, effectiveNetId ?? task.network_id ?? null);
|
|
2315
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
2316
|
+
}
|
|
1184
2317
|
db.transaction(() => {
|
|
1185
2318
|
// Reset task status
|
|
1186
2319
|
const updateParams: any[] = [task_id];
|
|
@@ -1188,17 +2321,19 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1188
2321
|
WHERE task_id = ?1`;
|
|
1189
2322
|
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
1190
2323
|
db.run(updateSql, updateParams);
|
|
2324
|
+
syncScheduledRunForTask(task_id, effectiveNetId ?? task.network_id ?? null);
|
|
1191
2325
|
// Re-queue in inbox with new ID (original ID may already exist)
|
|
1192
2326
|
const retryInboxId = uuidv4();
|
|
1193
2327
|
db.run(
|
|
1194
|
-
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id)
|
|
1195
|
-
VALUES (?1, ?2, ?3, 'task', ?
|
|
1196
|
-
[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]
|
|
2328
|
+
`INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id)
|
|
2329
|
+
VALUES (?1, ?2, ?3, ?4, 'task', ?5, ?6, ?7, 'reply', ?8)`,
|
|
2330
|
+
[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]
|
|
1197
2331
|
);
|
|
1198
2332
|
});
|
|
1199
2333
|
logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
|
|
1200
2334
|
// SSE push (unconditional — channel is keyed by alias, not network)
|
|
1201
|
-
pushEvent(task.to_name, { type: "new_task", inbox_count:
|
|
2335
|
+
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);
|
|
2336
|
+
pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: task.to_name, status: "delivered", priority: task.priority });
|
|
1202
2337
|
return {
|
|
1203
2338
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, retried_to: task.to_name }) }],
|
|
1204
2339
|
};
|
|
@@ -1217,7 +2352,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1217
2352
|
if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
1218
2353
|
const params: any[] = [task_id];
|
|
1219
2354
|
let sql = "SELECT * FROM tasks WHERE task_id = ?1";
|
|
1220
|
-
sql =
|
|
2355
|
+
sql = addOwnTrafficScope(sql, params, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
1221
2356
|
const task = db.get<any>(sql, ...params);
|
|
1222
2357
|
return {
|
|
1223
2358
|
content: [{
|
|
@@ -1238,33 +2373,88 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1238
2373
|
from_name: z.string().max(200).optional().describe("Filter by sender"),
|
|
1239
2374
|
from_node_id: z.string().max(200).optional().describe("Filter by immutable sender node_id"),
|
|
1240
2375
|
network_id: z.string().max(200).optional().describe("Filter by network"),
|
|
2376
|
+
before_created_at: z.string().max(64).optional().describe("Pagination cursor timestamp"),
|
|
2377
|
+
before_task_id: z.string().max(200).optional().describe("Pagination cursor task id"),
|
|
2378
|
+
durable_cursor: z.boolean().optional().describe("Require immutable node-scoped cursor protocol"),
|
|
2379
|
+
durable_terminal_cursor: z.boolean().optional().describe("Read terminal outbound journal in monotonic order"),
|
|
2380
|
+
after_terminal_seq: z.number().int().min(0).optional().describe("Exclusive terminal journal watermark"),
|
|
1241
2381
|
limit: z.number().min(1).max(100).optional().default(20),
|
|
1242
2382
|
},
|
|
1243
|
-
async ({ alias, status, from_name, from_node_id, network_id: netId, limit }) => {
|
|
2383
|
+
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 }) => {
|
|
1244
2384
|
const readScope = resolveReadScope(netId);
|
|
1245
2385
|
if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
1246
|
-
|
|
2386
|
+
if (durable_cursor) {
|
|
2387
|
+
if (!callerTokenIsNetwork) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_required" }) }] };
|
|
2388
|
+
const boundNodeId = callerTokenId && enforceNetworkId
|
|
2389
|
+
? db.get<{ bound_node_id: string | null }>(
|
|
2390
|
+
"SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
|
|
2391
|
+
callerTokenId,
|
|
2392
|
+
enforceNetworkId,
|
|
2393
|
+
)?.bound_node_id ?? null
|
|
2394
|
+
: null;
|
|
2395
|
+
if (!boundNodeId || from_node_id !== boundNodeId) {
|
|
2396
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "from_node_id_identity_mismatch" }) }] };
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
if (durable_terminal_cursor) {
|
|
2400
|
+
if (!durable_cursor) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "durable_cursor_required" }) }] };
|
|
2401
|
+
const params: any[] = [enforceNetworkId, from_node_id, after_terminal_seq ?? 0, limit];
|
|
2402
|
+
const tasks = db.all(
|
|
2403
|
+
`SELECT t.task_id, t.from_node_id, t.from_name, t.to_node_id, t.to_name, t.priority,
|
|
2404
|
+
t.status, t.content, t.result, t.created_at, t.runtime_submitted_at,
|
|
2405
|
+
t.consumed_at, t.thread_id, t.turn_id, t.completed_at, e.terminal_seq
|
|
2406
|
+
FROM task_terminal_events e
|
|
2407
|
+
JOIN tasks t ON t.task_id = e.task_id
|
|
2408
|
+
WHERE COALESCE(e.network_id, 'default') = COALESCE(?1, 'default')
|
|
2409
|
+
AND e.from_node_id = ?2 AND e.terminal_seq > ?3
|
|
2410
|
+
ORDER BY e.terminal_seq ASC LIMIT ?4`,
|
|
2411
|
+
...params,
|
|
2412
|
+
);
|
|
2413
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
2414
|
+
ok: true,
|
|
2415
|
+
capability: "list_tasks.immutable-terminal-sequence.v2",
|
|
2416
|
+
tasks,
|
|
2417
|
+
count: tasks.length,
|
|
2418
|
+
next_terminal_seq: tasks.length ? tasks[tasks.length - 1]?.terminal_seq : (after_terminal_seq ?? 0),
|
|
2419
|
+
has_more: tasks.length === limit,
|
|
2420
|
+
}) }] };
|
|
2421
|
+
}
|
|
2422
|
+
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";
|
|
1247
2423
|
const params: any[] = [];
|
|
1248
|
-
sql =
|
|
2424
|
+
sql = addOwnTrafficScope(sql, params, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
1249
2425
|
if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
|
|
1250
2426
|
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
1251
2427
|
if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
|
|
1252
2428
|
if (from_node_id) { sql += ` AND from_node_id = ?${params.length + 1}`; params.push(from_node_id); }
|
|
1253
|
-
|
|
2429
|
+
if (before_created_at && before_task_id) {
|
|
2430
|
+
sql += ` AND (created_at < ?${params.length + 1} OR (created_at = ?${params.length + 1} AND task_id < ?${params.length + 2}))`;
|
|
2431
|
+
params.push(before_created_at, before_task_id);
|
|
2432
|
+
}
|
|
2433
|
+
sql += ` ORDER BY created_at DESC, task_id DESC LIMIT ?${params.length + 1}`;
|
|
1254
2434
|
params.push(limit);
|
|
1255
2435
|
const tasks = db.all(sql, ...params);
|
|
1256
2436
|
|
|
1257
2437
|
// Stats
|
|
1258
2438
|
const statsParams: any[] = [];
|
|
1259
2439
|
let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
|
|
1260
|
-
statsSql =
|
|
2440
|
+
statsSql = addOwnTrafficScope(statsSql, statsParams, readScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
1261
2441
|
statsSql += " GROUP BY status";
|
|
1262
2442
|
const stats = db.all(statsSql, ...statsParams);
|
|
1263
2443
|
|
|
1264
2444
|
return {
|
|
1265
2445
|
content: [{
|
|
1266
2446
|
type: "text" as const,
|
|
1267
|
-
text: JSON.stringify({
|
|
2447
|
+
text: JSON.stringify({
|
|
2448
|
+
ok: true,
|
|
2449
|
+
...(durable_cursor ? { capability: "list_tasks.immutable-node-cursor.v1" } : {}),
|
|
2450
|
+
tasks,
|
|
2451
|
+
count: tasks.length,
|
|
2452
|
+
next_cursor: tasks.length === limit ? {
|
|
2453
|
+
before_created_at: tasks[tasks.length - 1]?.created_at,
|
|
2454
|
+
before_task_id: tasks[tasks.length - 1]?.task_id,
|
|
2455
|
+
} : null,
|
|
2456
|
+
stats,
|
|
2457
|
+
}),
|
|
1268
2458
|
}],
|
|
1269
2459
|
};
|
|
1270
2460
|
}
|
|
@@ -1278,24 +2468,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1278
2468
|
task_id: z.string().min(1).max(200).describe("Task ID to cancel"),
|
|
1279
2469
|
reason: z.string().max(1000).optional().describe("Cancellation reason"),
|
|
1280
2470
|
from_session: z.string().max(200).optional(),
|
|
2471
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1281
2472
|
},
|
|
1282
|
-
async ({ task_id, reason, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1283
|
-
const effectiveNetId = getNetworkId(
|
|
2473
|
+
async ({ task_id, reason, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
2474
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1284
2475
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1285
2476
|
console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
|
|
1286
2477
|
const updateParams: any[] = [reason || "cancelled by " + from_session, task_id];
|
|
1287
2478
|
let updateSql = `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
|
|
1288
2479
|
WHERE task_id = ?2 AND status IN ('created', 'delivered', 'acked', 'running')`;
|
|
1289
2480
|
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
1290
|
-
const result = db.
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
2481
|
+
const result = db.transaction(() => {
|
|
2482
|
+
const updated = db.run(updateSql, updateParams);
|
|
2483
|
+
// Also ack the inbox entry to prevent agent from picking it up.
|
|
2484
|
+
if (updated.changes > 0) {
|
|
2485
|
+
const inboxParams: any[] = [task_id];
|
|
2486
|
+
let inboxSql = "UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0";
|
|
2487
|
+
inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
|
|
2488
|
+
db.run(inboxSql, inboxParams);
|
|
2489
|
+
syncScheduledRunForTask(task_id, effectiveNetId);
|
|
2490
|
+
}
|
|
2491
|
+
return updated;
|
|
2492
|
+
});
|
|
2493
|
+
if (result.changes > 0) logTaskEvent(task_id, null, "cancelled", from_session, reason || undefined);
|
|
1299
2494
|
return {
|
|
1300
2495
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: result.changes > 0, task_id, cancelled: result.changes > 0 }) }],
|
|
1301
2496
|
};
|
|
@@ -1310,9 +2505,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1310
2505
|
task_id: z.string().min(1).max(200).describe("Task ID to reassign"),
|
|
1311
2506
|
new_alias: z.string().min(1).max(200).describe("Target agent alias"),
|
|
1312
2507
|
from_session: z.string().max(200).optional(),
|
|
2508
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1313
2509
|
},
|
|
1314
|
-
async ({ task_id, new_alias, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1315
|
-
const effectiveNetId = getNetworkId(
|
|
2510
|
+
async ({ task_id, new_alias, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
2511
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1316
2512
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1317
2513
|
console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
|
|
1318
2514
|
const taskParams: any[] = [task_id];
|
|
@@ -1328,10 +2524,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1328
2524
|
const reassignedAlias = canonical.alias;
|
|
1329
2525
|
const target = resolveDeliveryTarget(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
|
|
1330
2526
|
const newNodeId = target.state === "not_found" ? null : (target.session?.node_id ?? null);
|
|
2527
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 5/6).
|
|
2528
|
+
{
|
|
2529
|
+
const lc = assertNodeActive(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
|
|
2530
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
2531
|
+
}
|
|
1331
2532
|
db.transaction(() => {
|
|
1332
2533
|
// Ack old inbox to prevent original agent from picking it up
|
|
1333
2534
|
const inboxParams: any[] = [task_id];
|
|
1334
|
-
let inboxSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND acked = 0";
|
|
2535
|
+
let inboxSql = "UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0";
|
|
1335
2536
|
inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
|
|
1336
2537
|
db.run(inboxSql, inboxParams);
|
|
1337
2538
|
|
|
@@ -1341,15 +2542,179 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1341
2542
|
db.run(updateSql, updateParams);
|
|
1342
2543
|
|
|
1343
2544
|
const newInboxId = uuidv4();
|
|
1344
|
-
db.run("INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id) VALUES (?1, ?2, ?3, 'task', ?
|
|
1345
|
-
[newInboxId, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
|
|
2545
|
+
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)",
|
|
2546
|
+
[newInboxId, task_id, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
|
|
1346
2547
|
});
|
|
1347
2548
|
logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${reassignedAlias}`);
|
|
1348
|
-
pushEvent(reassignedAlias, { type: "new_task", inbox_count:
|
|
2549
|
+
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);
|
|
2550
|
+
pushNetworkObserverEvent(effectiveNetId ?? task.network_id ?? null, { type: "new_task", task_id, from: from_session, to: reassignedAlias, status: "delivered", priority: task.priority });
|
|
1349
2551
|
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 } : {}) }) }] };
|
|
1350
2552
|
}
|
|
1351
2553
|
);
|
|
1352
2554
|
|
|
2555
|
+
server.tool(
|
|
2556
|
+
"send_desktop_message",
|
|
2557
|
+
"Push a message to a user's active Desktop/web clients in one network. Targets user identity, not node alias.",
|
|
2558
|
+
{
|
|
2559
|
+
to_user_id: z.string().min(1).max(200).optional().describe("Target user_id. Mutually checked with to_username when both are supplied."),
|
|
2560
|
+
to_username: z.string().min(1).max(50).optional().describe("Target username. Mutually checked with to_user_id when both are supplied."),
|
|
2561
|
+
title: z.string().max(200).optional(),
|
|
2562
|
+
message: z.string().min(1).max(10000),
|
|
2563
|
+
severity: z.enum(["info", "success", "warning", "error"]).optional().default("info"),
|
|
2564
|
+
kind: z.string().min(1).max(80).optional().default("agent_message"),
|
|
2565
|
+
meta: z.record(z.string(), z.unknown()).optional(),
|
|
2566
|
+
from_session: z.string().max(200).optional(),
|
|
2567
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens; ntok stays bound to its network)."),
|
|
2568
|
+
},
|
|
2569
|
+
async ({ to_user_id, to_username, title, message, severity, kind, meta, from_session: _fromIn, network_id: netId }) => {
|
|
2570
|
+
const fromMismatch = fromIdentityMismatchReply(_fromIn);
|
|
2571
|
+
if (fromMismatch) return fromMismatch;
|
|
2572
|
+
const from_session = defaultFrom(_fromIn);
|
|
2573
|
+
const effectiveNetId = getNetworkId(netId);
|
|
2574
|
+
// 人类 ↔ 人类私信:受限成员也能发(canWriteHuman 不看 Agent 授权),但发件人固定为自己的用户名。
|
|
2575
|
+
if (!canWriteHuman(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
2576
|
+
if (effectiveNetId && restrictedNets.includes(effectiveNetId) && _fromIn?.trim() && _fromIn.trim() !== callerAlias) {
|
|
2577
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "from_session_identity_mismatch", message: "restricted members always send as their own username" }) }] };
|
|
2578
|
+
}
|
|
2579
|
+
if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
|
|
2580
|
+
|
|
2581
|
+
if (!to_user_id && !to_username) {
|
|
2582
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_required", message: "to_user_id or to_username is required" }) }] };
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
const byId = to_user_id
|
|
2586
|
+
? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE user_id = ?1", to_user_id)
|
|
2587
|
+
: null;
|
|
2588
|
+
const byName = to_username
|
|
2589
|
+
? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE username = ?1", to_username)
|
|
2590
|
+
: null;
|
|
2591
|
+
|
|
2592
|
+
if (to_user_id && !byId) {
|
|
2593
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_user_id" }) }] };
|
|
2594
|
+
}
|
|
2595
|
+
if (to_username && !byName) {
|
|
2596
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_username" }) }] };
|
|
2597
|
+
}
|
|
2598
|
+
if (byId && byName && byId.user_id !== byName.user_id) {
|
|
2599
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_mismatch", to_user_id, to_username }) }] };
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
const target = byId ?? byName;
|
|
2603
|
+
if (!target) {
|
|
2604
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found" }) }] };
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
const targetRole = getUserNetworkRole(target.user_id, effectiveNetId);
|
|
2608
|
+
if (!targetRole) {
|
|
2609
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_in_network", network_id: effectiveNetId }) }] };
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
const messageId = `dm_${uuidv4()}`;
|
|
2613
|
+
const event = {
|
|
2614
|
+
type: "desktop_message",
|
|
2615
|
+
message_id: messageId,
|
|
2616
|
+
kind,
|
|
2617
|
+
from: from_session,
|
|
2618
|
+
title: title || null,
|
|
2619
|
+
message,
|
|
2620
|
+
severity,
|
|
2621
|
+
created_at: new Date().toISOString(),
|
|
2622
|
+
...(meta && Object.keys(meta).length ? { meta } : {}),
|
|
2623
|
+
};
|
|
2624
|
+
|
|
2625
|
+
// #1459 ① P2 —— 审计与收件持久化必须同生共死:只落其中一半,就会出现
|
|
2626
|
+
// 「审计说发过、而用户永远收不到」或者反过来。放进同一个事务。
|
|
2627
|
+
db.transaction(() => {
|
|
2628
|
+
db.run(
|
|
2629
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
2630
|
+
VALUES (?1, ?2, 'send_desktop_message', 'user', ?3, ?4, ?5)`,
|
|
2631
|
+
[
|
|
2632
|
+
enforceUserId || null,
|
|
2633
|
+
callerAlias || null,
|
|
2634
|
+
target.user_id,
|
|
2635
|
+
JSON.stringify({
|
|
2636
|
+
message_id: messageId,
|
|
2637
|
+
to_username: target.username,
|
|
2638
|
+
from: from_session,
|
|
2639
|
+
severity,
|
|
2640
|
+
kind,
|
|
2641
|
+
title: title || null,
|
|
2642
|
+
meta_keys: meta && typeof meta === "object" ? Object.keys(meta) : [],
|
|
2643
|
+
}),
|
|
2644
|
+
effectiveNetId,
|
|
2645
|
+
],
|
|
2646
|
+
);
|
|
2647
|
+
// 🔴 `ON CONFLICT(message_id) DO NOTHING`,而不是 `INSERT OR IGNORE`:
|
|
2648
|
+
// 两者对"重投同一个 message_id"效果相同(幂等,不产生重复消息),
|
|
2649
|
+
// 但 OR IGNORE 会**连同其它约束错误一起吞掉** —— 我在实现本段时就
|
|
2650
|
+
// 撞上了:`kind` 为 NULL 触发 NOT NULL,OR IGNORE 静默丢弃整行,
|
|
2651
|
+
// 而工具照样返回 `persisted: true`。**一条消息凭空消失且报告成功**,
|
|
2652
|
+
// 正是本 issue 要消灭的那个形状。
|
|
2653
|
+
// DO NOTHING 只针对主键冲突,别的约束照常抛出、事务回滚。
|
|
2654
|
+
//
|
|
2655
|
+
// 另:普通 INSERT 会让重试抛主键冲突(无害重试变 500);
|
|
2656
|
+
// OR REPLACE 会覆盖旧行、把用户已 ack 的状态冲掉。都不取。
|
|
2657
|
+
db.run(
|
|
2658
|
+
`INSERT INTO user_inbox
|
|
2659
|
+
(message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, sender_user_id)
|
|
2660
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
|
2661
|
+
ON CONFLICT(message_id) DO NOTHING`,
|
|
2662
|
+
[
|
|
2663
|
+
messageId,
|
|
2664
|
+
effectiveNetId ?? null,
|
|
2665
|
+
target.user_id,
|
|
2666
|
+
from_session,
|
|
2667
|
+
// 🔴 不依赖 zod 的 .default():直接调用 handler 的调用方(测试、
|
|
2668
|
+
// 未来的内部调用)拿不到 zod 默认值,会把 NULL 写进 NOT NULL 列。
|
|
2669
|
+
// 列上虽有 DEFAULT,但**显式传 NULL 会覆盖列默认值**。
|
|
2670
|
+
kind ?? "agent_message",
|
|
2671
|
+
title || null,
|
|
2672
|
+
message,
|
|
2673
|
+
severity ?? "info",
|
|
2674
|
+
// 存储侧走与 inbox 一致的跨主机脱敏;读取侧另有一道 token 形状
|
|
2675
|
+
// 脱敏(规格 ③ redact-at-read),两层分别防不同的东西。
|
|
2676
|
+
meta && typeof meta === "object" ? normalizeMetaJson(meta) : null,
|
|
2677
|
+
// 多用户私信:只有人(用户令牌)发的才记发信人 user_id;节点令牌发的是 Agent 消息。
|
|
2678
|
+
!callerTokenIsNetwork && enforceUserId ? enforceUserId : null,
|
|
2679
|
+
],
|
|
2680
|
+
);
|
|
2681
|
+
});
|
|
2682
|
+
// #1459 / #1563 — pushUserEvent 在没有订阅者时静默 return。
|
|
2683
|
+
// 🔴 这段注释原先写「本工具既不写 inbox、也没有 user 级回读路径 ⇒ dashboard
|
|
2684
|
+
// 关着时消息永久没了」。**那半句已经不成立** —— 上面 20 行就在
|
|
2685
|
+
// `INSERT INTO user_inbox`,且 `/api/messages?scope=user` 能回读
|
|
2686
|
+
// (desktop-message-seam.test.ts 有真实 send → 真实 GET 的端到端断言)。
|
|
2687
|
+
// 留着它会把 debug 未读数的人直接带向错误的一层。
|
|
2688
|
+
//
|
|
2689
|
+
// 这里先把**丢失变可见**:`ok` 仍表示「请求被接受、审计已落库」,
|
|
2690
|
+
// 新增 `delivered` 表示「此刻真的有人收到」。判据取自订阅者注册表,
|
|
2691
|
+
// 不是猜的。持久化 + 重连补投是另一条(按 user_id 寻址的新收件表,#1459)。
|
|
2692
|
+
//
|
|
2693
|
+
// 🔴 `reason` 报的是**观测到的事实**(此刻没有活订阅者),不是它的后果。
|
|
2694
|
+
// 今天「没有活订阅者」确实等于「丢了」,但持久化落地之后同一个事实会
|
|
2695
|
+
// 变成「已入库、等重连补投」。把值取成 no_live_subscriber 而不是
|
|
2696
|
+
// "lost"/"dropped",就是为了那天不用改写历史含义 —— 届时按需细分
|
|
2697
|
+
// (如 lost vs queued)即可,现在不预先把语义焊死。
|
|
2698
|
+
// 🔴 push 必须在事务**提交之后**:放进事务里的话,活订阅者可能先收到
|
|
2699
|
+
// 事件、而行还没提交;一旦回滚,用户就看到了一条数据库里不存在的消息。
|
|
2700
|
+
const delivered = hasUserSubscribers(effectiveNetId, target.user_id);
|
|
2701
|
+
pushUserEvent(effectiveNetId, target.user_id, event);
|
|
2702
|
+
|
|
2703
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
2704
|
+
ok: true,
|
|
2705
|
+
message_id: messageId,
|
|
2706
|
+
delivered,
|
|
2707
|
+
// #1459 ① P2 —— 有了持久化之后,「此刻没人在听」不再等于「丢了」。
|
|
2708
|
+
// `delivered` 说的是实时投递、`persisted` 说的是它已经入库等重连补投。
|
|
2709
|
+
// `reason` 仍报**观测到的事实**而不是它的后果,所以这个中间态没有说错话。
|
|
2710
|
+
persisted: true,
|
|
2711
|
+
...(delivered ? {} : { reason: "no_live_subscriber" }),
|
|
2712
|
+
delivered_to_user_id: target.user_id,
|
|
2713
|
+
network_id: effectiveNetId,
|
|
2714
|
+
}) }] };
|
|
2715
|
+
},
|
|
2716
|
+
);
|
|
2717
|
+
|
|
1353
2718
|
server.tool(
|
|
1354
2719
|
"broadcast",
|
|
1355
2720
|
"Send a message to multiple sessions.",
|
|
@@ -1371,26 +2736,39 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1371
2736
|
|
|
1372
2737
|
const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
|
|
1373
2738
|
const ids: string[] = [];
|
|
2739
|
+
// #1440 ① — the push loop must walk the recipients that actually
|
|
2740
|
+
// got an inbox row, not every candidate target. Walking `targets`
|
|
2741
|
+
// announced a broadcast to nodes the lifecycle guard had just
|
|
2742
|
+
// skipped, i.e. nodes with nothing to fetch.
|
|
2743
|
+
const delivered: Array<{ alias: string; netId: string | null }> = [];
|
|
1374
2744
|
|
|
1375
2745
|
for (const t of targets) {
|
|
2746
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 6/6).
|
|
2747
|
+
// Broadcast skips non-active recipients silently rather than
|
|
2748
|
+
// failing the entire send — broadcast semantics are best-effort
|
|
2749
|
+
// per-recipient and a stopped node simply gets nothing.
|
|
2750
|
+
const netId = effectiveNetId ?? t.network_id ?? null;
|
|
2751
|
+
const lc = assertNodeActive(t.alias, netId);
|
|
2752
|
+
if (!lc.ok) continue;
|
|
1376
2753
|
const id = uuidv4();
|
|
1377
2754
|
db.run(
|
|
1378
2755
|
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
|
|
1379
2756
|
VALUES (?1, ?2, ?3, 'broadcast', 'normal', ?4, 'hub', ?5)`,
|
|
1380
|
-
[id, t.alias, t.node_id ?? null, message,
|
|
2757
|
+
[id, t.alias, t.node_id ?? null, message, netId]
|
|
1381
2758
|
);
|
|
1382
2759
|
ids.push(id);
|
|
2760
|
+
delivered.push({ alias: t.alias, netId });
|
|
1383
2761
|
}
|
|
1384
2762
|
|
|
1385
|
-
for (const
|
|
1386
|
-
pushEvent(
|
|
2763
|
+
for (const d of delivered) {
|
|
2764
|
+
pushEvent(d.alias, { type: "broadcast", inbox_count: pendingInboxCount(d.alias, d.netId) }, d.netId);
|
|
1387
2765
|
}
|
|
1388
2766
|
|
|
1389
2767
|
return {
|
|
1390
2768
|
content: [
|
|
1391
2769
|
{
|
|
1392
2770
|
type: "text" as const,
|
|
1393
|
-
text: JSON.stringify({ ok: true, recipients:
|
|
2771
|
+
text: JSON.stringify({ ok: true, recipients: ids.length, message_ids: ids }),
|
|
1394
2772
|
},
|
|
1395
2773
|
],
|
|
1396
2774
|
};
|
|
@@ -1483,15 +2861,34 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1483
2861
|
"update_node_config",
|
|
1484
2862
|
"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.",
|
|
1485
2863
|
{
|
|
1486
|
-
|
|
2864
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
1487
2865
|
base_revision: z.number().int().min(0).describe("Current revision per the dashboard's last GET — 409 if hub's current revision differs."),
|
|
1488
2866
|
patch: z.object({
|
|
1489
2867
|
model: z.string().max(200).optional(),
|
|
1490
|
-
flags: z.record(z.unknown()).optional(),
|
|
2868
|
+
flags: z.record(z.string(), z.unknown()).optional(),
|
|
2869
|
+
// #260 P5 — channel enable/disable. Restart-tier field (agent-node
|
|
2870
|
+
// reads config.channels once at boot to fork per-channel workers,
|
|
2871
|
+
// so the swap takes effect via process restart). Not
|
|
2872
|
+
// SECURITY_SENSITIVE — this is a lifecycle op, gated by SEC-1
|
|
2873
|
+
// (network scope) only.
|
|
2874
|
+
//
|
|
2875
|
+
// Deliberately `z.array(z.unknown()).max(16)` (mirrors flags's
|
|
2876
|
+
// `z.record(z.string(), z.unknown())`): trust nothing at the wire boundary,
|
|
2877
|
+
// narrow the same way `narrowChannelsPatch` narrows raw untrusted
|
|
2878
|
+
// JSON (typeof + allowlist + dedup + case-fold). A strict
|
|
2879
|
+
// `z.array(z.string())` would fail-fast on a single non-string
|
|
2880
|
+
// entry, but the wire contract wants junk silently dropped so a
|
|
2881
|
+
// dashboard fat-finger doesn't turn into a 400 the user has to
|
|
2882
|
+
// interpret. validatePatch then re-rejects if the caller bypassed
|
|
2883
|
+
// narrowing.
|
|
2884
|
+
channels: z.array(z.unknown()).max(16).optional(),
|
|
1491
2885
|
}).describe("Fields to update. Empty patch → no-op (use restart_node for that)."),
|
|
1492
2886
|
network_id: z.string().max(200).optional(),
|
|
1493
2887
|
},
|
|
1494
|
-
async ({ node_id
|
|
2888
|
+
async ({ node_id, child_node_id, base_revision: baseRev, patch, network_id: clientNetId }) => {
|
|
2889
|
+
const idArg = resolveNodeIdArg({ node_id, child_node_id });
|
|
2890
|
+
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
2891
|
+
const nodeId = idArg.node_id;
|
|
1495
2892
|
const effectiveNetId = getNetworkId(clientNetId);
|
|
1496
2893
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
1497
2894
|
|
|
@@ -1505,6 +2902,50 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1505
2902
|
|
|
1506
2903
|
const model = typeof patch.model === "string" ? patch.model : undefined;
|
|
1507
2904
|
const flags = (patch.flags && typeof patch.flags === "object") ? patch.flags as Record<string, unknown> : {};
|
|
2905
|
+
// Narrow untrusted `channels` at the boundary (typeof + allowlist +
|
|
2906
|
+
// dedup + case-fold), and distinguish two very different cases
|
|
2907
|
+
// that both narrow to `[]`:
|
|
2908
|
+
// (a) `patch.channels === []` (explicit "disable all editable
|
|
2909
|
+
// channels"). Downstream must proceed and write channels=[]
|
|
2910
|
+
// so the node's next restart forks no workers.
|
|
2911
|
+
// (b) `patch.channels === ["commhub"]` or similar — the caller
|
|
2912
|
+
// sent items but every single one was invalid (dashboard PR
|
|
2913
|
+
// #31 still ships commhub, and a typo like "telegarm" would
|
|
2914
|
+
// hit the same path). Downstream MUST NOT treat this as
|
|
2915
|
+
// (a) — silently converting to disable-all would nuke the
|
|
2916
|
+
// user's existing telegram/feishu workers.
|
|
2917
|
+
//
|
|
2918
|
+
// The reject error is `channels_all_invalid` so the dashboard
|
|
2919
|
+
// can surface the mismatch instead of showing a false success.
|
|
2920
|
+
let channels: string[] | undefined = undefined;
|
|
2921
|
+
if (patch.channels !== undefined) {
|
|
2922
|
+
if (!Array.isArray(patch.channels)) {
|
|
2923
|
+
// Zod already permits arrays only; belt+braces if it drifts.
|
|
2924
|
+
return {
|
|
2925
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_patch", field: "channels", reason: "must be an array" }) }],
|
|
2926
|
+
};
|
|
2927
|
+
}
|
|
2928
|
+
const narrowed = narrowChannelsPatch(patch.channels) ?? [];
|
|
2929
|
+
if (patch.channels.length === 0) {
|
|
2930
|
+
channels = []; // (a) explicit disable-all
|
|
2931
|
+
} else if (narrowed.length === 0) {
|
|
2932
|
+
// (b) every entry was invalid — refuse to write. Distinct
|
|
2933
|
+
// error so the dashboard can surface the failure.
|
|
2934
|
+
return {
|
|
2935
|
+
content: [{
|
|
2936
|
+
type: "text" as const,
|
|
2937
|
+
text: JSON.stringify({
|
|
2938
|
+
ok: false,
|
|
2939
|
+
error: "channels_all_invalid",
|
|
2940
|
+
requested: patch.channels,
|
|
2941
|
+
message: "every requested channel is unknown or unsupported; use an explicit empty array to disable-all",
|
|
2942
|
+
}),
|
|
2943
|
+
}],
|
|
2944
|
+
};
|
|
2945
|
+
} else {
|
|
2946
|
+
channels = narrowed;
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
1508
2949
|
|
|
1509
2950
|
// SEC-2 (final policy 2026-06-28) — security-sensitive flags
|
|
1510
2951
|
// (permissionMode / dangerouslySkipPermissions / teammateMode)
|
|
@@ -1531,7 +2972,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1531
2972
|
};
|
|
1532
2973
|
}
|
|
1533
2974
|
|
|
1534
|
-
const validationFail = validatePatch(model, flags);
|
|
2975
|
+
const validationFail = validatePatch(model, flags, channels);
|
|
1535
2976
|
if (validationFail) {
|
|
1536
2977
|
return {
|
|
1537
2978
|
content: [{
|
|
@@ -1609,8 +3050,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1609
3050
|
|
|
1610
3051
|
// Compute apply_mode + persist + push doorbell.
|
|
1611
3052
|
const updateId = `cu_${uuidv4()}`;
|
|
1612
|
-
const applyMode = computeApplyMode(model, flags);
|
|
1613
|
-
const patchJson = JSON.stringify({
|
|
3053
|
+
const applyMode = computeApplyMode(model, flags, channels);
|
|
3054
|
+
const patchJson = JSON.stringify({
|
|
3055
|
+
...(model !== undefined ? { model } : {}),
|
|
3056
|
+
flags,
|
|
3057
|
+
...(channels !== undefined ? { channels } : {}),
|
|
3058
|
+
});
|
|
1614
3059
|
const networkId = node.network_id || "default";
|
|
1615
3060
|
db.run(
|
|
1616
3061
|
`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)`,
|
|
@@ -1628,6 +3073,51 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1628
3073
|
},
|
|
1629
3074
|
);
|
|
1630
3075
|
|
|
3076
|
+
// One alias can own several `nodes` rows in a network: re-creating a node
|
|
3077
|
+
// under the same alias leaves the old row behind, and `db.get` on
|
|
3078
|
+
// `WHERE alias = ? AND network_id = ?` then returns whichever row SQLite
|
|
3079
|
+
// happens to yield first. A field report showed the result: the client
|
|
3080
|
+
// queued a rules-file read for the live node, the node pulled with its own
|
|
3081
|
+
// token, the hub resolved its alias to the stale row, found nothing pending
|
|
3082
|
+
// and returned `request: null` until the read timed out.
|
|
3083
|
+
//
|
|
3084
|
+
// resolveNodeByAlias: prefer the row the alias's session points at (sessions
|
|
3085
|
+
// are UNIQUE per network+alias and carry the node_id that last reported),
|
|
3086
|
+
// then the newest row. Used where only an alias is known (a client targeting
|
|
3087
|
+
// by alias; unbound legacy node tokens).
|
|
3088
|
+
const resolveNodeByAlias = (alias: string, networkId: string) =>
|
|
3089
|
+
db.get<{ node_id: string; alias: string; network_id: string | null }>(
|
|
3090
|
+
`SELECT n.node_id, n.alias, n.network_id FROM nodes n
|
|
3091
|
+
LEFT JOIN sessions s ON s.alias = n.alias AND s.network_id = n.network_id AND s.node_id = n.node_id
|
|
3092
|
+
WHERE n.alias = ?1 AND n.network_id = ?2
|
|
3093
|
+
ORDER BY (s.node_id IS NOT NULL) DESC, n.created_at DESC, n.rowid DESC
|
|
3094
|
+
LIMIT 1`,
|
|
3095
|
+
alias,
|
|
3096
|
+
networkId,
|
|
3097
|
+
) ?? null;
|
|
3098
|
+
// resolveCallerNode: the node a network token pulls/acks for. A token bound
|
|
3099
|
+
// to a node resolves to exactly that node (and never to another row that
|
|
3100
|
+
// shares its alias); only unbound legacy tokens fall back to the alias.
|
|
3101
|
+
// Callers have already required callerTokenIsNetwork + enforceNetworkId +
|
|
3102
|
+
// callerAlias.
|
|
3103
|
+
const resolveCallerNode = (): { node_id: string; network_id: string | null } | null => {
|
|
3104
|
+
const bound = callerTokenId
|
|
3105
|
+
? db.get<{ bound_node_id: string | null }>(
|
|
3106
|
+
"SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
|
|
3107
|
+
callerTokenId,
|
|
3108
|
+
enforceNetworkId,
|
|
3109
|
+
)?.bound_node_id ?? null
|
|
3110
|
+
: null;
|
|
3111
|
+
if (bound) {
|
|
3112
|
+
return db.get<{ node_id: string; network_id: string | null }>(
|
|
3113
|
+
"SELECT node_id, network_id FROM nodes WHERE node_id = ?1 AND network_id = ?2",
|
|
3114
|
+
bound,
|
|
3115
|
+
enforceNetworkId,
|
|
3116
|
+
) ?? null;
|
|
3117
|
+
}
|
|
3118
|
+
return resolveNodeByAlias(callerAlias!, enforceNetworkId!);
|
|
3119
|
+
};
|
|
3120
|
+
|
|
1631
3121
|
server.tool(
|
|
1632
3122
|
"get_config_update",
|
|
1633
3123
|
"Node pulls its pending config update (called from agent-node when SSE config_update doorbell arrives). RFC-024.",
|
|
@@ -1651,11 +3141,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1651
3141
|
// Unconditional network_id filter (was previously conditional on
|
|
1652
3142
|
// enforceNetworkId being set; the new ntok guard above guarantees
|
|
1653
3143
|
// it's non-null so the filter is always applied).
|
|
1654
|
-
const node =
|
|
1655
|
-
"SELECT node_id, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
1656
|
-
callerAlias,
|
|
1657
|
-
enforceNetworkId,
|
|
1658
|
-
);
|
|
3144
|
+
const node = resolveCallerNode();
|
|
1659
3145
|
if (!node) {
|
|
1660
3146
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update: null }) }] };
|
|
1661
3147
|
}
|
|
@@ -1708,11 +3194,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1708
3194
|
// Cross-tenant guard: the ack-er must own the update being acked.
|
|
1709
3195
|
// Resolve node by caller's alias under the enforced network.
|
|
1710
3196
|
// Network filter is unconditional (guard above guarantees non-null).
|
|
1711
|
-
const node =
|
|
1712
|
-
"SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
1713
|
-
callerAlias,
|
|
1714
|
-
enforceNetworkId,
|
|
1715
|
-
);
|
|
3197
|
+
const node = resolveCallerNode();
|
|
1716
3198
|
if (!node) {
|
|
1717
3199
|
// Silently ignore stale ack — return ok so the node doesn't retry forever.
|
|
1718
3200
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "alias_unknown" }) }] };
|
|
@@ -1748,14 +3230,455 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1748
3230
|
},
|
|
1749
3231
|
);
|
|
1750
3232
|
|
|
3233
|
+
// ── app#225 — 节点规则文件(AGENTS.md / CLAUDE.md)远程读写 ──
|
|
3234
|
+
//
|
|
3235
|
+
// 形状照抄 RFC-024 config-apply:客户端调 read_node_rules_file /
|
|
3236
|
+
// write_node_rules_file 落一行 node_rules_requests + 门铃 {type:"rules_file"};
|
|
3237
|
+
// 节点收到门铃后 get_rules_file_request 拉(和 get_config_update 同一道
|
|
3238
|
+
// F-A 闸:网络 token + alias),做完 ack_rules_file_request 回报;客户端
|
|
3239
|
+
// 轮询 get_rules_file_result。
|
|
3240
|
+
//
|
|
3241
|
+
// 🔴 安全(#225 验收第 5 条):整条链路**没有路径参数**。文件名由节点按
|
|
3242
|
+
// 自己的 RUNTIME 决定(claude → CLAUDE.md,其余 → AGENTS.md),目录固定是
|
|
3243
|
+
// 节点进程的 cwd(agent-node/src/runtime/rules-file.ts)。hub 只传 op +
|
|
3244
|
+
// content,客户端连文件名都指定不了,所以不存在「借路径参数写任意文件」。
|
|
3245
|
+
// 读和写都要 canWrite:规则文件是节点行为的一部分,读它等于读节点配置。
|
|
3246
|
+
const RULES_FILE_MAX_BYTES = 256 * 1024;
|
|
3247
|
+
const RULES_REQUEST_STALE_MS = 60_000;
|
|
3248
|
+
|
|
3249
|
+
// Node skills view — skills_list / skill_read ride the same queue + doorbell.
|
|
3250
|
+
// `content` carries the skill NAME for skill_read (never a path; the node
|
|
3251
|
+
// validates it again against [A-Za-z0-9._-] and resolves it under its own
|
|
3252
|
+
// runtime's skills roots — node-skills.ts).
|
|
3253
|
+
const SKILL_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
3254
|
+
const isSkillsOp = (op: string) => op === "skills_list" || op === "skill_read";
|
|
3255
|
+
|
|
3256
|
+
// Project folder view — files_list / file_read ride the same queue + doorbell.
|
|
3257
|
+
// `content` carries a path RELATIVE to the node's work dir. The hub refuses
|
|
3258
|
+
// absolute paths, `..` segments, backslashes and NUL before any row exists;
|
|
3259
|
+
// the node re-validates, realpath-contains the result in its work dir and
|
|
3260
|
+
// refuses secret-looking files (node-files.ts). Stricter than skills on the
|
|
3261
|
+
// caller: a node token may not browse another node's disk, and only the token
|
|
3262
|
+
// that asked can read the answer.
|
|
3263
|
+
const NODE_FILE_PATH_MAX = 1024;
|
|
3264
|
+
const NODE_FILES_RESULT_MAX_CHARS = 1024 * 1024;
|
|
3265
|
+
const isFilesOp = (op: string) => op === "files_list" || op === "file_read";
|
|
3266
|
+
const normalizeNodeRelPath = (raw: unknown): string | null => {
|
|
3267
|
+
if (raw === undefined || raw === null) return "";
|
|
3268
|
+
if (typeof raw !== "string" || raw.length > NODE_FILE_PATH_MAX) return null;
|
|
3269
|
+
if (raw.includes("\0") || raw.includes("\\")) return null;
|
|
3270
|
+
if (raw.startsWith("/") || raw.startsWith("~") || /^[A-Za-z]:/.test(raw)) return null;
|
|
3271
|
+
const segs = raw.split("/").filter((s) => s !== "" && s !== ".");
|
|
3272
|
+
if (segs.some((s) => s === "..")) return null;
|
|
3273
|
+
return segs.join("/");
|
|
3274
|
+
};
|
|
3275
|
+
|
|
3276
|
+
// Node run-log view — logs_tail rides the same queue + doorbell. `content`
|
|
3277
|
+
// carries the filter parameters as JSON (lines / level / grep / since_ts) and
|
|
3278
|
+
// never a path: the node reads only its own log directory and redacts before
|
|
3279
|
+
// it answers (agent-node/src/runtime/node-logs.ts). Stricter than the project
|
|
3280
|
+
// folder on the caller: user login only, and only the node's owner or a
|
|
3281
|
+
// network owner/admin — a member who may chat with a node does not get to read
|
|
3282
|
+
// its process log. Log bytes are purged on the first terminal read (see
|
|
3283
|
+
// get_rules_file_result) and by the retention sweep after LOGS_CONTENT_TTL_MS.
|
|
3284
|
+
const isLogsOp = (op: string) => op === "logs_tail";
|
|
3285
|
+
const LOGS_MAX_LINES = 2000;
|
|
3286
|
+
const LOGS_GREP_MAX = 200;
|
|
3287
|
+
const logsParamsJson = (p: { lines?: number; level?: string; grep?: string; since_ts?: number }): string =>
|
|
3288
|
+
JSON.stringify({
|
|
3289
|
+
lines: Math.min(Math.max(Math.floor(p.lines ?? 500), 1), LOGS_MAX_LINES),
|
|
3290
|
+
...(p.level ? { level: p.level } : {}),
|
|
3291
|
+
...(p.grep ? { grep: p.grep.slice(0, LOGS_GREP_MAX) } : {}),
|
|
3292
|
+
...(typeof p.since_ts === "number" && p.since_ts > 0 ? { since_ts: p.since_ts } : {}),
|
|
3293
|
+
});
|
|
3294
|
+
// Owner of the node row, or owner/admin of the network. Legacy open mode (no
|
|
3295
|
+
// user identity on the connection) keeps canWrite's allow-all semantics.
|
|
3296
|
+
const canReadNodeLogs = (nodeId: string, networkId: string): boolean => {
|
|
3297
|
+
if (!enforceUserId) return true;
|
|
3298
|
+
const role = getUserNetworkRole(enforceUserId, networkId);
|
|
3299
|
+
if (role === "owner" || role === "admin") return true;
|
|
3300
|
+
if (nodeId.startsWith("session:")) return false;
|
|
3301
|
+
const row = db.get<{ owner_user_id: string | null }>("SELECT owner_user_id FROM nodes WHERE node_id = ?1", nodeId);
|
|
3302
|
+
return !!row?.owner_user_id && row.owner_user_id === enforceUserId;
|
|
3303
|
+
};
|
|
3304
|
+
|
|
3305
|
+
const enqueueRulesFileRequest = (
|
|
3306
|
+
op: "read" | "write" | "skills_list" | "skill_read" | "files_list" | "file_read" | "logs_tail",
|
|
3307
|
+
a: { node_id?: string; child_node_id?: string; alias?: string; network_id?: string; content?: string },
|
|
3308
|
+
) => {
|
|
3309
|
+
const effectiveNetId = getNetworkId(a.network_id);
|
|
3310
|
+
if (isLogsOp(op) && callerTokenIsNetwork) {
|
|
3311
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_cannot_read_logs", message: "a node token cannot read another node's run log; use a user login" }) }] };
|
|
3312
|
+
}
|
|
3313
|
+
if (isFilesOp(op)) {
|
|
3314
|
+
if (callerTokenIsNetwork) {
|
|
3315
|
+
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" }) }] };
|
|
3316
|
+
}
|
|
3317
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, `${op} node files`);
|
|
3318
|
+
const rel = normalizeNodeRelPath(a.content);
|
|
3319
|
+
if (rel === null || (op === "file_read" && rel === "")) {
|
|
3320
|
+
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" }) }] };
|
|
3321
|
+
}
|
|
3322
|
+
a = { ...a, content: rel };
|
|
3323
|
+
}
|
|
3324
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, isLogsOp(op) ? "read node logs" : isSkillsOp(op) ? `${op} node skills` : `${op} rules file`);
|
|
3325
|
+
if (op === "skill_read" && (typeof a.content !== "string" || !SKILL_NAME_RE.test(a.content) || a.content === "." || a.content === "..")) {
|
|
3326
|
+
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 .." }) }] };
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
// Target: a `nodes` row by node_id (original path), or — app#225 follow-up —
|
|
3330
|
+
// an alias. An alias resolves to its `nodes` row when there is one; otherwise
|
|
3331
|
+
// to a live session in the caller's network that advertised
|
|
3332
|
+
// rules_file_capable (claude-code sessions mostly have no `nodes` row). The
|
|
3333
|
+
// queue key for such a session is `session:<alias>`, network-scoped.
|
|
3334
|
+
let node: { node_id: string; alias: string; network_id: string | null } | null = null;
|
|
3335
|
+
if (a.node_id || a.child_node_id || !a.alias) {
|
|
3336
|
+
const idArg = resolveNodeIdArg({ node_id: a.node_id, child_node_id: a.child_node_id });
|
|
3337
|
+
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
3338
|
+
const nodeId = idArg.node_id;
|
|
3339
|
+
const resolved = resolveTargetNode(nodeId, effectiveNetId);
|
|
3340
|
+
if (!resolved.row) {
|
|
3341
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: nodeId }) }] };
|
|
3342
|
+
}
|
|
3343
|
+
if (!resolved.sec1Ok) {
|
|
3344
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node", message: "node belongs to another network" }) }] };
|
|
3345
|
+
}
|
|
3346
|
+
node = resolved.row as any;
|
|
3347
|
+
} else {
|
|
3348
|
+
const scopeNet = effectiveNetId || "default";
|
|
3349
|
+
const byAlias = resolveNodeByAlias(a.alias, scopeNet);
|
|
3350
|
+
if (byAlias) {
|
|
3351
|
+
node = byAlias;
|
|
3352
|
+
} else {
|
|
3353
|
+
const session = db.get<{ alias: string; network_id: string | null }>(
|
|
3354
|
+
isLogsOp(op)
|
|
3355
|
+
? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND logs_capable = 1 ORDER BY updated_at DESC LIMIT 1"
|
|
3356
|
+
: isFilesOp(op)
|
|
3357
|
+
? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND files_capable = 1 ORDER BY updated_at DESC LIMIT 1"
|
|
3358
|
+
: isSkillsOp(op)
|
|
3359
|
+
? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND skills_capable = 1 ORDER BY updated_at DESC LIMIT 1"
|
|
3360
|
+
: "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",
|
|
3361
|
+
a.alias,
|
|
3362
|
+
scopeNet,
|
|
3363
|
+
);
|
|
3364
|
+
if (!session) {
|
|
3365
|
+
if (isLogsOp(op)) {
|
|
3366
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "logs_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 run log (its agent-node may be too old)" }) }] };
|
|
3367
|
+
}
|
|
3368
|
+
if (isFilesOp(op)) {
|
|
3369
|
+
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)" }) }] };
|
|
3370
|
+
}
|
|
3371
|
+
return isSkillsOp(op)
|
|
3372
|
+
? { 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)" }) }] }
|
|
3373
|
+
: { 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)" }) }] };
|
|
3374
|
+
}
|
|
3375
|
+
node = { node_id: `session:${session.alias}`, alias: session.alias, network_id: session.network_id };
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
const nodeId = node.node_id;
|
|
3379
|
+
if (isLogsOp(op) && !canReadNodeLogs(nodeId, node.network_id || "default")) {
|
|
3380
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "logs_permission_denied", message: "only the node's owner or a network owner/admin can read its run log" }) }] };
|
|
3381
|
+
}
|
|
3382
|
+
if (op === "write") {
|
|
3383
|
+
if (typeof a.content !== "string") {
|
|
3384
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_content", reason: "content must be a string" }) }] };
|
|
3385
|
+
}
|
|
3386
|
+
const bytes = Buffer.byteLength(a.content, "utf8");
|
|
3387
|
+
if (bytes > RULES_FILE_MAX_BYTES) {
|
|
3388
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "content_too_large", bytes, max_bytes: RULES_FILE_MAX_BYTES }) }] };
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
// 单飞 + 陈旧回收,同 update_node_config 的 F-B:节点掉线时旧行永远
|
|
3393
|
+
// 非终态,不回收就把这个节点锁死。
|
|
3394
|
+
// Separate single-flight lanes: the client opens the rules file and the
|
|
3395
|
+
// skills list together; one must not block the other.
|
|
3396
|
+
const inFlight = db.get<{ request_id: string; created_at: number; pulled_at: number | null }>(
|
|
3397
|
+
isLogsOp(op)
|
|
3398
|
+
? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op = 'logs_tail' ORDER BY created_at DESC LIMIT 1"
|
|
3399
|
+
: isFilesOp(op)
|
|
3400
|
+
? "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"
|
|
3401
|
+
: isSkillsOp(op)
|
|
3402
|
+
? "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"
|
|
3403
|
+
: "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",
|
|
3404
|
+
nodeId,
|
|
3405
|
+
);
|
|
3406
|
+
if (inFlight) {
|
|
3407
|
+
const age = Date.now() - (inFlight.pulled_at ?? inFlight.created_at);
|
|
3408
|
+
if (age <= RULES_REQUEST_STALE_MS) {
|
|
3409
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_in_flight", existing_request_id: inFlight.request_id, age_ms: age }) }] };
|
|
3410
|
+
}
|
|
3411
|
+
db.run(
|
|
3412
|
+
"UPDATE node_rules_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3",
|
|
3413
|
+
[Date.now(), `superseded after ${age}ms (> ${RULES_REQUEST_STALE_MS}ms) — node did not answer`, inFlight.request_id],
|
|
3414
|
+
);
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
// Opportunistic content retention (bounded, indexed) — node-request-retention.ts.
|
|
3418
|
+
sweepNodeRequestContent();
|
|
3419
|
+
const requestId = `rf_${uuidv4()}`;
|
|
3420
|
+
const networkId = node.network_id || "default";
|
|
3421
|
+
db.run(
|
|
3422
|
+
`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)`,
|
|
3423
|
+
[requestId, nodeId, networkId, op, op === "write" || op === "skill_read" || isFilesOp(op) || isLogsOp(op) ? a.content! : null, Date.now(), callerTokenId || "unknown"],
|
|
3424
|
+
);
|
|
3425
|
+
pushEvent(node.alias, { type: "rules_file", request_id: requestId }, networkId);
|
|
3426
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, op }) }] };
|
|
3427
|
+
};
|
|
3428
|
+
|
|
3429
|
+
server.tool(
|
|
3430
|
+
"read_node_rules_file",
|
|
3431
|
+
"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.",
|
|
3432
|
+
{
|
|
3433
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3434
|
+
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)."),
|
|
3435
|
+
network_id: z.string().max(200).optional(),
|
|
3436
|
+
},
|
|
3437
|
+
async ({ node_id, child_node_id, alias, network_id }) => enqueueRulesFileRequest("read", { node_id, child_node_id, alias, network_id }),
|
|
3438
|
+
);
|
|
3439
|
+
|
|
3440
|
+
server.tool(
|
|
3441
|
+
"write_node_rules_file",
|
|
3442
|
+
"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.",
|
|
3443
|
+
{
|
|
3444
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3445
|
+
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)."),
|
|
3446
|
+
content: z.string().max(RULES_FILE_MAX_BYTES).describe("Full new file content (UTF-8). The node writes it atomically."),
|
|
3447
|
+
network_id: z.string().max(200).optional(),
|
|
3448
|
+
},
|
|
3449
|
+
async ({ node_id, child_node_id, alias, content, network_id }) => enqueueRulesFileRequest("write", { node_id, child_node_id, alias, content, network_id }),
|
|
3450
|
+
);
|
|
3451
|
+
|
|
3452
|
+
server.tool(
|
|
3453
|
+
"list_node_skills",
|
|
3454
|
+
"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:[…]}.",
|
|
3455
|
+
{
|
|
3456
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3457
|
+
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."),
|
|
3458
|
+
network_id: z.string().max(200).optional(),
|
|
3459
|
+
},
|
|
3460
|
+
async ({ node_id, child_node_id, alias, network_id }) => enqueueRulesFileRequest("skills_list", { node_id, child_node_id, alias, network_id }),
|
|
3461
|
+
);
|
|
3462
|
+
|
|
3463
|
+
server.tool(
|
|
3464
|
+
"read_node_skill",
|
|
3465
|
+
"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}.",
|
|
3466
|
+
{
|
|
3467
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3468
|
+
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."),
|
|
3469
|
+
name: z.string().min(1).max(64).describe("Skill directory name, [A-Za-z0-9._-]."),
|
|
3470
|
+
network_id: z.string().max(200).optional(),
|
|
3471
|
+
},
|
|
3472
|
+
async ({ node_id, child_node_id, alias, name, network_id }) => enqueueRulesFileRequest("skill_read", { node_id, child_node_id, alias, content: name, network_id }),
|
|
3473
|
+
);
|
|
3474
|
+
|
|
3475
|
+
server.tool(
|
|
3476
|
+
"list_node_files",
|
|
3477
|
+
"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.",
|
|
3478
|
+
{
|
|
3479
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3480
|
+
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."),
|
|
3481
|
+
path: z.string().max(NODE_FILE_PATH_MAX).optional().describe("Directory relative to the node's work dir; omit or \"\" for the root."),
|
|
3482
|
+
network_id: z.string().max(200).optional(),
|
|
3483
|
+
},
|
|
3484
|
+
async ({ node_id, child_node_id, alias, path: relPath, network_id }) => enqueueRulesFileRequest("files_list", { node_id, child_node_id, alias, content: relPath ?? "", network_id }),
|
|
3485
|
+
);
|
|
3486
|
+
|
|
3487
|
+
server.tool(
|
|
3488
|
+
"read_node_file",
|
|
3489
|
+
"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.",
|
|
3490
|
+
{
|
|
3491
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3492
|
+
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."),
|
|
3493
|
+
path: z.string().min(1).max(NODE_FILE_PATH_MAX).describe("File path relative to the node's work dir."),
|
|
3494
|
+
network_id: z.string().max(200).optional(),
|
|
3495
|
+
},
|
|
3496
|
+
async ({ node_id, child_node_id, alias, path: relPath, network_id }) => enqueueRulesFileRequest("file_read", { node_id, child_node_id, alias, content: relPath, network_id }),
|
|
3497
|
+
);
|
|
3498
|
+
|
|
3499
|
+
server.tool(
|
|
3500
|
+
"tail_node_logs",
|
|
3501
|
+
"Ask a node for the tail of its own agent-node run log (read-only, redacted on the node before it leaves). No path argument: the node reads only its own log directory. User logins only; the node's owner or a network owner/admin. Poll get_rules_file_result — content is JSON {files, lines:[{ts, level, text, key}], truncated, matched, now_ts}; it is handed out once and then purged.",
|
|
3502
|
+
{
|
|
3503
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3504
|
+
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 logs_capable."),
|
|
3505
|
+
lines: z.number().int().min(1).max(LOGS_MAX_LINES).optional().describe("How many of the newest matching lines (default 500, max 2000)."),
|
|
3506
|
+
level: z.enum(["info", "warn", "error"]).optional().describe("Only lines of exactly this level."),
|
|
3507
|
+
grep: z.string().min(1).max(LOGS_GREP_MAX).optional().describe("Case-insensitive substring, matched after redaction."),
|
|
3508
|
+
since_ts: z.number().int().min(0).optional().describe("Only lines at or after this epoch-ms timestamp (follow mode; the client de-duplicates by line key)."),
|
|
3509
|
+
network_id: z.string().max(200).optional(),
|
|
3510
|
+
},
|
|
3511
|
+
async ({ node_id, child_node_id, alias, lines, level, grep, since_ts, network_id }) =>
|
|
3512
|
+
enqueueRulesFileRequest("logs_tail", { node_id, child_node_id, alias, content: logsParamsJson({ lines, level, grep, since_ts }), network_id }),
|
|
3513
|
+
);
|
|
3514
|
+
|
|
3515
|
+
server.tool(
|
|
3516
|
+
"get_rules_file_request",
|
|
3517
|
+
"Node pulls its oldest pending rules-file request (called from agent-node when the SSE rules_file doorbell arrives). app#225.",
|
|
3518
|
+
{},
|
|
3519
|
+
async () => {
|
|
3520
|
+
// 同 get_config_update 的 F-A 闸:必须是网络 token + alias。
|
|
3521
|
+
if (!callerTokenIsNetwork || !enforceNetworkId) {
|
|
3522
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
|
|
3523
|
+
}
|
|
3524
|
+
if (!callerAlias) {
|
|
3525
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
|
|
3526
|
+
}
|
|
3527
|
+
const node = resolveCallerNode();
|
|
3528
|
+
// app#225 follow-up — no `nodes` row: this is a session-only target
|
|
3529
|
+
// (claude-code). Its queue key is `session:<alias>`, always paired with the
|
|
3530
|
+
// caller's network so a same-named alias in another network never matches.
|
|
3531
|
+
const queueKey: string = node ? node.node_id : `session:${callerAlias}`;
|
|
3532
|
+
const req = db.get<any>(
|
|
3533
|
+
"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",
|
|
3534
|
+
queueKey,
|
|
3535
|
+
enforceNetworkId,
|
|
3536
|
+
);
|
|
3537
|
+
if (!req) {
|
|
3538
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request: null }) }] };
|
|
3539
|
+
}
|
|
3540
|
+
db.run(
|
|
3541
|
+
"UPDATE node_rules_requests SET status = 'in_progress', pulled_at = ?1 WHERE request_id = ?2 AND status = 'pending'",
|
|
3542
|
+
[Date.now(), req.request_id],
|
|
3543
|
+
);
|
|
3544
|
+
return {
|
|
3545
|
+
content: [{
|
|
3546
|
+
type: "text" as const,
|
|
3547
|
+
text: JSON.stringify({
|
|
3548
|
+
ok: true,
|
|
3549
|
+
request: {
|
|
3550
|
+
request_id: req.request_id,
|
|
3551
|
+
op: req.op,
|
|
3552
|
+
...(req.op === "write" || req.op === "skill_read" || isFilesOp(req.op) || isLogsOp(req.op) ? { content: req.content ?? "" } : {}),
|
|
3553
|
+
},
|
|
3554
|
+
}),
|
|
3555
|
+
}],
|
|
3556
|
+
};
|
|
3557
|
+
},
|
|
3558
|
+
);
|
|
3559
|
+
|
|
3560
|
+
server.tool(
|
|
3561
|
+
"ack_rules_file_request",
|
|
3562
|
+
"Node reports the outcome of a rules-file request — done (with content for reads) or failed (with error). app#225.",
|
|
3563
|
+
{
|
|
3564
|
+
request_id: z.string().min(1).max(200),
|
|
3565
|
+
status: z.enum(["done", "failed"]),
|
|
3566
|
+
file_name: z.string().max(64).optional(),
|
|
3567
|
+
exists: z.boolean().optional(),
|
|
3568
|
+
// Files results (JSON listing / a 256 KiB file, escaped) may exceed the rules
|
|
3569
|
+
// cap; the per-op cap is enforced below once the row's op is known.
|
|
3570
|
+
content: z.string().max(NODE_FILES_RESULT_MAX_CHARS).optional(),
|
|
3571
|
+
error: z.string().max(2000).optional(),
|
|
3572
|
+
},
|
|
3573
|
+
async ({ request_id: requestId, status, file_name: fileName, exists, content, error: ackError }) => {
|
|
3574
|
+
if (!callerTokenIsNetwork || !enforceNetworkId) {
|
|
3575
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
|
|
3576
|
+
}
|
|
3577
|
+
if (!callerAlias) {
|
|
3578
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
|
|
3579
|
+
}
|
|
3580
|
+
const node = resolveCallerNode();
|
|
3581
|
+
const queueKey: string = node ? node.node_id : `session:${callerAlias}`;
|
|
3582
|
+
const req = db.get<any>(
|
|
3583
|
+
"SELECT request_id, node_id, network_id, status, op FROM node_rules_requests WHERE request_id = ?1",
|
|
3584
|
+
requestId,
|
|
3585
|
+
);
|
|
3586
|
+
// 跨租户闸:只能 ack 自己的请求;别人的当不存在处理。session 键另外核网络。
|
|
3587
|
+
if (!req || req.node_id !== queueKey || req.network_id !== enforceNetworkId) {
|
|
3588
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "unknown_or_foreign_request" }) }] };
|
|
3589
|
+
}
|
|
3590
|
+
if (req.status === "done" || req.status === "failed" || req.status === "timeout") {
|
|
3591
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "already_terminal", current_status: req.status }) }] };
|
|
3592
|
+
}
|
|
3593
|
+
if (typeof content === "string" && !isFilesOp(req.op) && !isLogsOp(req.op) && content.length > RULES_FILE_MAX_BYTES) {
|
|
3594
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "content_too_large", max: RULES_FILE_MAX_BYTES }) }] };
|
|
3595
|
+
}
|
|
3596
|
+
db.run(
|
|
3597
|
+
`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`,
|
|
3598
|
+
[
|
|
3599
|
+
status,
|
|
3600
|
+
Date.now(),
|
|
3601
|
+
fileName ?? null,
|
|
3602
|
+
typeof exists === "boolean" ? (exists ? 1 : 0) : null,
|
|
3603
|
+
status === "done" ? (content ?? null) : null,
|
|
3604
|
+
status === "failed" ? (ackError || "node reported failure without a reason") : null,
|
|
3605
|
+
requestId,
|
|
3606
|
+
],
|
|
3607
|
+
);
|
|
3608
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, status }) }] };
|
|
3609
|
+
},
|
|
3610
|
+
);
|
|
3611
|
+
|
|
3612
|
+
server.tool(
|
|
3613
|
+
"get_rules_file_result",
|
|
3614
|
+
"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.",
|
|
3615
|
+
{ request_id: z.string().min(1).max(200), network_id: z.string().max(200).optional() },
|
|
3616
|
+
async ({ request_id: requestId, network_id: clientNetId }) => {
|
|
3617
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
3618
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "read rules file result");
|
|
3619
|
+
const row = db.get<any>(
|
|
3620
|
+
"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",
|
|
3621
|
+
requestId,
|
|
3622
|
+
);
|
|
3623
|
+
// SEC-1:结果行的网络必须等于调用方作用域,否则当不存在。
|
|
3624
|
+
// Project folder results: only the (user) token that asked may read them.
|
|
3625
|
+
// Run-log results: same rule — only the token that asked.
|
|
3626
|
+
const foreignFilesRow = !!row && (isFilesOp(row.op) || isLogsOp(row.op)) && (callerTokenIsNetwork || row.created_by_token !== (callerTokenId || "unknown"));
|
|
3627
|
+
if (!row || row.network_id !== (effectiveNetId || "default") || foreignFilesRow) {
|
|
3628
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found", request_id: requestId }) }] };
|
|
3629
|
+
}
|
|
3630
|
+
let status: string = row.status;
|
|
3631
|
+
const ageMs = Date.now() - (row.pulled_at ?? row.created_at);
|
|
3632
|
+
if ((status === "pending" || status === "in_progress") && ageMs > RULES_REQUEST_STALE_MS) {
|
|
3633
|
+
status = "timeout";
|
|
3634
|
+
db.run(
|
|
3635
|
+
"UPDATE node_rules_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3 AND status IN ('pending', 'in_progress')",
|
|
3636
|
+
[Date.now(), `node did not answer within ${RULES_REQUEST_STALE_MS}ms (offline, or running an agent-node without app#225 support)`, requestId],
|
|
3637
|
+
);
|
|
3638
|
+
row.error = `node did not answer within ${RULES_REQUEST_STALE_MS}ms (offline, or running an agent-node without app#225 support)`;
|
|
3639
|
+
}
|
|
3640
|
+
// Privacy: node file bytes are handed out for a short grace window after the
|
|
3641
|
+
// first terminal read, then purged (node-request-retention.ts). Only reached
|
|
3642
|
+
// after the SEC-1 scope check above — a foreign caller can't stamp/purge.
|
|
3643
|
+
const terminal = status === "done" || status === "failed" || status === "timeout";
|
|
3644
|
+
const purged = terminal ? noteTerminalResultRead(requestId).purged : false;
|
|
3645
|
+
// Run-log bytes are read-once: purge them in the same call that hands them
|
|
3646
|
+
// out (no grace window — the app never follows someone else's logs request,
|
|
3647
|
+
// it re-asks; see node-request-retention.ts).
|
|
3648
|
+
if (terminal && isLogsOp(row.op) && !purged) purgeLogsResultNow(requestId);
|
|
3649
|
+
return {
|
|
3650
|
+
content: [{
|
|
3651
|
+
type: "text" as const,
|
|
3652
|
+
text: JSON.stringify({
|
|
3653
|
+
ok: true,
|
|
3654
|
+
request_id: row.request_id,
|
|
3655
|
+
node_id: row.node_id,
|
|
3656
|
+
op: row.op,
|
|
3657
|
+
status,
|
|
3658
|
+
file_name: row.file_name ?? null,
|
|
3659
|
+
exists: row.file_exists === null || row.file_exists === undefined ? null : row.file_exists === 1,
|
|
3660
|
+
...(status === "done" && row.op !== "write" && !purged ? { content: row.result_content ?? "" } : {}),
|
|
3661
|
+
...(purged ? { content_purged: true } : {}),
|
|
3662
|
+
error: row.error ?? null,
|
|
3663
|
+
age_ms: ageMs,
|
|
3664
|
+
}),
|
|
3665
|
+
}],
|
|
3666
|
+
};
|
|
3667
|
+
},
|
|
3668
|
+
);
|
|
3669
|
+
|
|
3670
|
+
|
|
1751
3671
|
server.tool(
|
|
1752
3672
|
"restart_node",
|
|
1753
3673
|
"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).",
|
|
1754
3674
|
{
|
|
1755
|
-
|
|
3675
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
1756
3676
|
network_id: z.string().max(200).optional(),
|
|
1757
3677
|
},
|
|
1758
|
-
async ({ node_id
|
|
3678
|
+
async ({ node_id, child_node_id, network_id: clientNetId }) => {
|
|
3679
|
+
const idArg = resolveNodeIdArg({ node_id, child_node_id });
|
|
3680
|
+
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
3681
|
+
const nodeId = idArg.node_id;
|
|
1759
3682
|
const effectiveNetId = getNetworkId(clientNetId);
|
|
1760
3683
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
1761
3684
|
|
|
@@ -1787,10 +3710,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1787
3710
|
}
|
|
1788
3711
|
const updateId = `cu_${uuidv4()}`;
|
|
1789
3712
|
const networkId = node.network_id || "default";
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
3713
|
+
// RFC-027 PR1.2a latent fix (#346 ack): restart_node must reset
|
|
3714
|
+
// lifecycle_state to 'active'. Otherwise a node that was previously
|
|
3715
|
+
// stop_node'd → 'stopped' would, after restart, stay marked
|
|
3716
|
+
// 'stopped' in the nodes table → the 6 MCP + 2 REST inbox guards
|
|
3717
|
+
// would refuse every routing attempt → node silently unreachable
|
|
3718
|
+
// (no error to operator, just no traffic). Pair the schema flip
|
|
3719
|
+
// with the config_updates INSERT inside one tx so we don't
|
|
3720
|
+
// half-commit if the dispatch INSERT throws.
|
|
3721
|
+
db.transaction(() => {
|
|
3722
|
+
db.run(
|
|
3723
|
+
`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)`,
|
|
3724
|
+
[updateId, nodeId, networkId, node.config_revision || 0, Date.now(), callerTokenId || "unknown"],
|
|
3725
|
+
);
|
|
3726
|
+
db.run(
|
|
3727
|
+
`UPDATE nodes SET lifecycle_state = 'active' WHERE node_id = ?1`,
|
|
3728
|
+
[nodeId],
|
|
3729
|
+
);
|
|
3730
|
+
});
|
|
1794
3731
|
pushEvent(node.alias, { type: "restart", update_id: updateId }, networkId);
|
|
1795
3732
|
return {
|
|
1796
3733
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update_id: updateId, apply_mode: "restart_only" }) }],
|
|
@@ -1895,7 +3832,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1895
3832
|
// schema-promoted columns runtimes_supported/allowed_secret_keys
|
|
1896
3833
|
// are pre-extracted but role isn't a first-class column).
|
|
1897
3834
|
const nowMs = Date.now();
|
|
1898
|
-
|
|
3835
|
+
// 心跳周期是 3 分钟(agent-node/src/cli.ts 的 report_status 定时器),
|
|
3836
|
+
// 窗口必须大于它,否则每次心跳后的 60s~180s 之间必然抖成 offline
|
|
3837
|
+
// (通信评审牛 #1279 独审④)。取 5min,与仓内既有 stale 口径一致。
|
|
3838
|
+
const ONLINE_MS = 5 * 60_000;
|
|
1899
3839
|
const daemons = rows
|
|
1900
3840
|
.map(r => {
|
|
1901
3841
|
let snapRole: string | null = null;
|
|
@@ -1914,7 +3854,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1914
3854
|
let lastSeenAt: string | null = null;
|
|
1915
3855
|
if (r.session_last_seen) {
|
|
1916
3856
|
lastSeenAt = r.session_last_seen;
|
|
1917
|
-
const t =
|
|
3857
|
+
const t = parseDbTimestampMs(r.session_last_seen);
|
|
1918
3858
|
if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
|
|
1919
3859
|
}
|
|
1920
3860
|
// Parse self-declare arrays (default to [] for pre-PR2 daemons)
|
|
@@ -1941,6 +3881,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1941
3881
|
telemetry.mem_gb = r.session_mem_total_gb ?? null;
|
|
1942
3882
|
telemetry.ip_internal = r.session_ip ?? null;
|
|
1943
3883
|
}
|
|
3884
|
+
// 工作目录默认根会暴露那台机器的家目录路径 —— 与 IP 同级,只给 admin/owner
|
|
3885
|
+
// (也只有他们能 create_node)。见 create-node-validate.ts daemonDefaultWorkdirRoot。
|
|
3886
|
+
const workdirRoot = isPrivileged ? daemonDefaultWorkdirRoot(r.config_snapshot) : null;
|
|
1944
3887
|
return {
|
|
1945
3888
|
daemon_node_id: r.node_id,
|
|
1946
3889
|
alias: r.alias,
|
|
@@ -1950,6 +3893,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1950
3893
|
runtimes_supported: runtimes,
|
|
1951
3894
|
allowed_secret_keys: secrets,
|
|
1952
3895
|
host_telemetry: telemetry,
|
|
3896
|
+
...(workdirRoot ? { default_workdir_root: workdirRoot } : {}),
|
|
1953
3897
|
};
|
|
1954
3898
|
});
|
|
1955
3899
|
|
|
@@ -1968,10 +3912,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1968
3912
|
node_spec: z.object({
|
|
1969
3913
|
name: z.string().min(1).max(64),
|
|
1970
3914
|
runtime: z.string().min(1).max(64),
|
|
1971
|
-
model: z.string().min(1).max(100),
|
|
1972
|
-
flags: z.record(z.unknown()).optional(),
|
|
3915
|
+
model: z.string().min(1).max(100).optional().nullable(),
|
|
3916
|
+
flags: z.record(z.string(), z.unknown()).optional(),
|
|
1973
3917
|
env_refs: z.array(z.string().max(64)).optional(),
|
|
1974
3918
|
channels: z.array(z.unknown()).optional(),
|
|
3919
|
+
// app「新建节点」确认页的工作目录(绝对路径或 ~/…,daemon 侧展开并校验)。
|
|
3920
|
+
// 缺席 = 老行为:落 daemon 的 cwd。
|
|
3921
|
+
workdir: z.string().max(1024).optional().nullable(),
|
|
1975
3922
|
}),
|
|
1976
3923
|
network_id: z.string().max(200).optional(),
|
|
1977
3924
|
},
|
|
@@ -1998,23 +3945,116 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1998
3945
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
|
|
1999
3946
|
}
|
|
2000
3947
|
|
|
2001
|
-
//
|
|
2002
|
-
//
|
|
2003
|
-
//
|
|
2004
|
-
//
|
|
2005
|
-
//
|
|
2006
|
-
|
|
2007
|
-
|
|
3948
|
+
// #1353 Fix ① — daemon self-declared "I can't create nodes right now"
|
|
3949
|
+
// is now a dispatch-time gate, not just a stored fact. Before this,
|
|
3950
|
+
// daemon reported `daemon_capabilities.can_create_nodes=false` (via
|
|
3951
|
+
// #1371 + #1377), hub stored it in config_snapshot, and then no
|
|
3952
|
+
// consumer read it — dispatch still returned `{ok:true, request_id}`
|
|
3953
|
+
// and pushed an SSE doorbell to the broken daemon. That's the exact
|
|
3954
|
+
// "在线但静默失败" case the issue title reports.
|
|
3955
|
+
//
|
|
3956
|
+
// Vincent's own 2026-08-28 comment on #1353 authorizes this shape:
|
|
3957
|
+
// "daemon 侧确知不可用时,hub 应当**拒绝派发**并返回可操作的错误,
|
|
3958
|
+
// 而不是发一个注定失败的 doorbell".
|
|
3959
|
+
//
|
|
3960
|
+
// 🔴 Pre-#1371 compat: a daemon that has NOT reported
|
|
3961
|
+
// `can_create_nodes` at all leaves the field UNDEFINED in the
|
|
3962
|
+
// snapshot. Do NOT fail-closed on undefined — that would silently
|
|
3963
|
+
// start rejecting every daemon on the old (preview.10 through .55
|
|
3964
|
+
// roughly) shape. Only `=== false` gates; `=== true` and `undefined`
|
|
3965
|
+
// both pass through (new-guard-matches-existing-stance).
|
|
2008
3966
|
try {
|
|
2009
3967
|
const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
3968
|
+
const caps = snap?.daemon_capabilities;
|
|
3969
|
+
if (caps && caps.can_create_nodes === false) {
|
|
3970
|
+
const reason: string = (typeof caps.create_nodes_blocked_reason === "string"
|
|
3971
|
+
&& caps.create_nodes_blocked_reason.length > 0)
|
|
3972
|
+
? caps.create_nodes_blocked_reason
|
|
3973
|
+
: "anet_bin_unknown";
|
|
3974
|
+
// #1545 —— 光说 `blocked_reason` 不够:**那个判断是什么时候做出来的**
|
|
3975
|
+
// 决定了下一步完全不同的两件事。
|
|
3976
|
+
// 刚测的 blocked ⇒ 去那台机器按 reason 修
|
|
3977
|
+
// 三周前测的 blocked ⇒ 那台 daemon 是**开机算一次就永久缓存**的旧版本
|
|
3978
|
+
// (agent-node ≤ 2.5.0-preview.54),它可能早就好了 ——
|
|
3979
|
+
// 先重启/升级它,再谈修 pin
|
|
3980
|
+
// 而在拒绝这一刻,用户正好在决定"去修哪台机器"。
|
|
3981
|
+
//
|
|
3982
|
+
// 🔴 年龄未知时给的是**显式 null + 一个说明为什么的枚举**,不是省略这两个键。
|
|
3983
|
+
// 省略读起来像"没有这个问题",而调用方(常常是个 agent)会默认它是刚测的
|
|
3984
|
+
// —— 那是朝「更可信」方向撒谎,比不给更糟。
|
|
3985
|
+
const rawAge = caps.create_capability_observed_ms_ago;
|
|
3986
|
+
const reportedAgeOk = typeof rawAge === "number" && Number.isFinite(rawAge)
|
|
3987
|
+
&& rawAge >= 0 && rawAge <= 365 * 24 * 60 * 60 * 1000;
|
|
3988
|
+
// 🔴 daemon 报的是「测完到**这份 report 发出**之间隔了多久」,不是「到现在」。
|
|
3989
|
+
// 两者差一个心跳间隔(3 分钟),而对一个已经失联的 daemon 差多少都可能。
|
|
3990
|
+
// 直接把它当成"多久以前测的"渲染,会**朝更新鲜的方向**低报 ——
|
|
3991
|
+
// 正是这一格要防的那个方向。所以在这里补上心跳那一段:
|
|
3992
|
+
// 绝对年龄 = (now − last_seen_at) + daemon 报的时长
|
|
3993
|
+
// hub 的钟出绝对时间,daemon 只提供时长(它自己的钟偏移污染不了这个数)。
|
|
3994
|
+
//
|
|
3995
|
+
// 这是冷路径(只有被拒时才走),多一次小查询不影响心跳路径。
|
|
3996
|
+
let heartbeatAgeMs: number | null = null;
|
|
3997
|
+
try {
|
|
3998
|
+
const sess = db.get<{ last_seen_at: string | null }>(
|
|
3999
|
+
"SELECT last_seen_at FROM sessions WHERE node_id = ?1 ORDER BY last_seen_at DESC LIMIT 1",
|
|
4000
|
+
daemon_node_id,
|
|
4001
|
+
);
|
|
4002
|
+
// #1650 — 这一列是 TEXT(`datetime('now')`),UTC 但不带时区标记。
|
|
4003
|
+
// Date.parse 会按**本机时区**解析,误差 = 主机偏移(生产 hub 在 UTC+8
|
|
4004
|
+
// 时这个诊断数字整整偏 8 小时)。走 parseHubTimestamp 显式按 UTC 解。
|
|
4005
|
+
const t = parseHubTimestamp(sess?.last_seen_at);
|
|
4006
|
+
if (t !== null) heartbeatAgeMs = Math.max(0, Date.now() - t);
|
|
4007
|
+
} catch { /* 取不到就是取不到 —— 下面如实说 unknown,不猜 0 */ }
|
|
4008
|
+
|
|
4009
|
+
// 🔴 三种取值,不能塌成两种:
|
|
4010
|
+
// known 两半都有,可以给绝对年龄
|
|
4011
|
+
// unknown_legacy_daemon 那台 daemon 根本没报这一格(旧版本)
|
|
4012
|
+
// unknown_no_heartbeat_time 它报了,但 hub 这边没有可用的心跳时间
|
|
4013
|
+
// ⇒ **补不出绝对年龄**,而不是"年龄是 0"
|
|
4014
|
+
const ageKnown = reportedAgeOk && heartbeatAgeMs !== null;
|
|
4015
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
4016
|
+
ok: false,
|
|
4017
|
+
error: "daemon_cannot_create_nodes",
|
|
4018
|
+
blocked_reason: reason,
|
|
4019
|
+
// 到**现在**为止,这个判断做出来有多久了(毫秒)。null = 补不出来。
|
|
4020
|
+
capability_observed_ms_ago: ageKnown ? heartbeatAgeMs! + (rawAge as number) : null,
|
|
4021
|
+
capability_age: ageKnown
|
|
4022
|
+
? "known"
|
|
4023
|
+
: (reportedAgeOk ? "unknown_no_heartbeat_time" : "unknown_legacy_daemon"),
|
|
4024
|
+
// 🔴 这里**不复制那张 code → 修法命令 的表**。它只有一个作者,在
|
|
4025
|
+
// `@sleep2agi/agent-network` 的 daemon-capability-display —— 抄一份到 hub,
|
|
4026
|
+
// 两份就会各自漂移,而「hub 教的修法」和「CLI 教的修法」不一致时,
|
|
4027
|
+
// 没有任何东西会红。这里只把人指到那一份。
|
|
4028
|
+
// (同 `blocked_reason` 是 z.enum 而不是自由文本的立场:hub 出**代码**,
|
|
4029
|
+
// 渲染留给客户端。)
|
|
4030
|
+
remediation_hint: "run `anet daemon list` where that daemon is configured — it prints the exact, paste-able fix command for this blocked_reason",
|
|
4031
|
+
daemon_node_id,
|
|
4032
|
+
}) }] };
|
|
4033
|
+
}
|
|
4034
|
+
} catch { /* malformed snapshot: permissive, mirrors L3230 fallthrough */ }
|
|
4035
|
+
|
|
4036
|
+
// Read daemon's host_supervisor capability + allowlist from its
|
|
4037
|
+
// last reported config_snapshot. PR3 (#338) canonical path is
|
|
4038
|
+
// `daemon_capabilities.runtimes_supported` (RFC-026 §9.3).
|
|
4039
|
+
// Pre-PR3 daemons (preview.10 and earlier) place these at the
|
|
4040
|
+
// TOP level of the snapshot rather than nested — those reads
|
|
4041
|
+
// return undefined here and `daemonAllowedRuntimes` stays null
|
|
4042
|
+
// (permissive — no allowlist enforcement); they fall back to
|
|
4043
|
+
// §4.2.2 structural validation only, identical to pre-PR3
|
|
4044
|
+
// behavior. No regression on in-flight daemons.
|
|
4045
|
+
let daemonAllowList = new Set<string>();
|
|
4046
|
+
let daemonAllowedRuntimes: string[] | null = null;
|
|
4047
|
+
try {
|
|
4048
|
+
const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
|
|
4049
|
+
const caps = snap?.daemon_capabilities;
|
|
4050
|
+
if (Array.isArray(caps?.allowed_secret_keys)) {
|
|
4051
|
+
daemonAllowList = new Set(caps.allowed_secret_keys);
|
|
4052
|
+
}
|
|
4053
|
+
if (Array.isArray(caps?.runtimes_supported)) {
|
|
4054
|
+
daemonAllowedRuntimes = caps.runtimes_supported;
|
|
4055
|
+
}
|
|
4056
|
+
} catch { /* permissive fallback */ }
|
|
4057
|
+
|
|
2018
4058
|
// §4.2.2 — structural validation (catches name/runtime/model/
|
|
2019
4059
|
// flag injection at the hub edge). Daemon repeats this; double
|
|
2020
4060
|
// layer per RFC §4.2.2.
|
|
@@ -2023,6 +4063,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2023
4063
|
validateRuntime(node_spec.runtime);
|
|
2024
4064
|
validateModel(node_spec.model);
|
|
2025
4065
|
validateChannelsP1((node_spec as any).channels);
|
|
4066
|
+
validateWorkdir(node_spec.workdir);
|
|
2026
4067
|
for (const [k, v] of Object.entries(node_spec.flags || {})) {
|
|
2027
4068
|
if (!(FLAG_KEYS as readonly string[]).includes(k)) throw new ValidationError("flag_key_unknown", { field: k });
|
|
2028
4069
|
validateFlagValue(k, v);
|
|
@@ -2030,6 +4071,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2030
4071
|
} catch (e) {
|
|
2031
4072
|
return validationFailReply(e);
|
|
2032
4073
|
}
|
|
4074
|
+
// 🔴 老 daemon 会**静默忽略** node_spec.workdir,把节点建在它自己的 cwd 里 ——
|
|
4075
|
+
// 用户以为指定了目录,实际落到了别处(常常就是 $HOME)。所以没自报支持的 daemon
|
|
4076
|
+
// 直接拒,而不是派一个会被悄悄改写的请求。app 在这种 daemon 上本就不显示那一行。
|
|
4077
|
+
const requestedWorkdir = typeof node_spec.workdir === "string" ? node_spec.workdir.trim() : null;
|
|
4078
|
+
if (requestedWorkdir && !daemonDefaultWorkdirRoot(daemon.config_snapshot)) {
|
|
4079
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
4080
|
+
ok: false,
|
|
4081
|
+
error: "workdir_not_supported_by_daemon",
|
|
4082
|
+
hint: "this daemon's agent-node predates create-node workdir support; upgrade it, or omit workdir to use its current directory",
|
|
4083
|
+
daemon_node_id,
|
|
4084
|
+
}) }] };
|
|
4085
|
+
}
|
|
2033
4086
|
|
|
2034
4087
|
// P1 daemon-side allowlist: if daemon publishes allowed_runtimes,
|
|
2035
4088
|
// enforce at hub for fast-fail; daemon repeats.
|
|
@@ -2115,12 +4168,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2115
4168
|
const envKeys = Object.keys(envBlob);
|
|
2116
4169
|
db.run(
|
|
2117
4170
|
`INSERT INTO node_create_requests
|
|
2118
|
-
(request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
|
|
2119
|
-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
|
|
4171
|
+
(request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token, workdir)
|
|
4172
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11, ?12)`,
|
|
2120
4173
|
[
|
|
2121
4174
|
requestId, daemon_node_id, node_spec.name, networkIdForChild,
|
|
2122
|
-
node_spec.runtime, node_spec.model, JSON.stringify(node_spec.flags || {}),
|
|
4175
|
+
node_spec.runtime, node_spec.model ?? null, JSON.stringify(node_spec.flags || {}),
|
|
2123
4176
|
JSON.stringify(envKeys), childTokenId, Date.now(), callerTokenId || "unknown",
|
|
4177
|
+
requestedWorkdir,
|
|
2124
4178
|
],
|
|
2125
4179
|
);
|
|
2126
4180
|
|
|
@@ -2129,9 +4183,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2129
4183
|
// SSE handler resolves the rest via MCP call.
|
|
2130
4184
|
pushEvent(daemon.alias, { type: "create_node", request_id: requestId }, networkIdForChild);
|
|
2131
4185
|
|
|
2132
|
-
// §4.5 audit —
|
|
4186
|
+
// §4.5 audit — request queued and doorbell attempted. pushEvent is
|
|
4187
|
+
// fire-and-forget, so actual SSE delivery is intentionally not claimed.
|
|
2133
4188
|
auditCreateNode({
|
|
2134
|
-
action: "
|
|
4189
|
+
action: "create_node_dispatch_attempted",
|
|
2135
4190
|
user_id: enforceUserId,
|
|
2136
4191
|
network_id: networkIdForChild,
|
|
2137
4192
|
target_id: requestId,
|
|
@@ -2139,9 +4194,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2139
4194
|
daemon_node_id,
|
|
2140
4195
|
child_name: node_spec.name,
|
|
2141
4196
|
runtime: node_spec.runtime,
|
|
2142
|
-
model: node_spec.model,
|
|
4197
|
+
model: node_spec.model ?? null,
|
|
2143
4198
|
flag_keys: Object.keys(node_spec.flags || {}),
|
|
2144
4199
|
env_keys: envKeys,
|
|
4200
|
+
workdir: requestedWorkdir,
|
|
2145
4201
|
},
|
|
2146
4202
|
});
|
|
2147
4203
|
|
|
@@ -2151,6 +4207,78 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2151
4207
|
},
|
|
2152
4208
|
);
|
|
2153
4209
|
|
|
4210
|
+
// #1362 — daemon-side compensation for missed SSE doorbells. pushEvent is
|
|
4211
|
+
// intentionally best-effort; after a daemon connects or reconnects, it pulls
|
|
4212
|
+
// any still-pending requests bound to its own daemon token.
|
|
4213
|
+
server.tool(
|
|
4214
|
+
"list_my_pending_create_requests",
|
|
4215
|
+
"Daemon lists pending create-node requests bound to itself for SSE reconnect compensation. RFC-026/#1362.",
|
|
4216
|
+
{},
|
|
4217
|
+
async () => {
|
|
4218
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
4219
|
+
if (!callerDaemon.ok) {
|
|
4220
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
4221
|
+
}
|
|
4222
|
+
const rows = db.all<{ request_id: string; child_name: string; created_at: number }>(
|
|
4223
|
+
`SELECT request_id, child_name, created_at
|
|
4224
|
+
FROM node_create_requests
|
|
4225
|
+
WHERE daemon_node_id = ?1
|
|
4226
|
+
AND network_id = ?2
|
|
4227
|
+
AND status = 'pending'
|
|
4228
|
+
ORDER BY created_at ASC`,
|
|
4229
|
+
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
4230
|
+
);
|
|
4231
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: rows.length, requests: rows }) }] };
|
|
4232
|
+
},
|
|
4233
|
+
);
|
|
4234
|
+
|
|
4235
|
+
// #1448 — stop/delete/start 门铃的 SSE 重连补偿,镜像 create 的
|
|
4236
|
+
// list_my_pending_create_requests(#1394)。pushEvent(push.ts) 是纯内存
|
|
4237
|
+
// fan-out:派发门铃那刻若 daemon 无 live SSE 订阅者(hub 重启/网络抖/boot
|
|
4238
|
+
// 窗口)就静默丢弃、不入队,于是 node_stop_requests/node_start_requests 的行
|
|
4239
|
+
// 永远停在 pending、节点卡死 stopping/deleting/starting。daemon 每次 SSE
|
|
4240
|
+
// connected 调本工具把漏掉的门铃拉回来重放(reconcilePendingLifecycleRequestsOnConnect)。
|
|
4241
|
+
//
|
|
4242
|
+
// 统一一个工具返回两张表的 pending 行(stop 表用 action 区分 stop/delete),
|
|
4243
|
+
// 省一次往返。鉴权同 create:token-bound daemon 身份(alias 不是安全边界),
|
|
4244
|
+
// 只返回绑定到本 daemon + 本网络的 pending 行。
|
|
4245
|
+
server.tool(
|
|
4246
|
+
"list_my_pending_lifecycle_requests",
|
|
4247
|
+
"Daemon lists pending stop/delete/start requests bound to itself for SSE reconnect compensation. Mirrors list_my_pending_create_requests (#1394/#1448).",
|
|
4248
|
+
{},
|
|
4249
|
+
async () => {
|
|
4250
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
4251
|
+
if (!callerDaemon.ok) {
|
|
4252
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
4253
|
+
}
|
|
4254
|
+
const stop_requests = db.all<{ request_id: string; action: string; child_node_id: string; child_alias: string; created_at: number }>(
|
|
4255
|
+
`SELECT request_id, action, child_node_id, child_alias, created_at
|
|
4256
|
+
FROM node_stop_requests
|
|
4257
|
+
WHERE daemon_node_id = ?1
|
|
4258
|
+
AND network_id = ?2
|
|
4259
|
+
AND status = 'pending'
|
|
4260
|
+
ORDER BY created_at ASC`,
|
|
4261
|
+
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
4262
|
+
);
|
|
4263
|
+
const start_requests = db.all<{ request_id: string; child_node_id: string; child_alias: string; created_at: number }>(
|
|
4264
|
+
`SELECT request_id, child_node_id, child_alias, created_at
|
|
4265
|
+
FROM node_start_requests
|
|
4266
|
+
WHERE daemon_node_id = ?1
|
|
4267
|
+
AND network_id = ?2
|
|
4268
|
+
AND status = 'pending'
|
|
4269
|
+
ORDER BY created_at ASC`,
|
|
4270
|
+
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
4271
|
+
);
|
|
4272
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
4273
|
+
ok: true,
|
|
4274
|
+
stop_count: stop_requests.length,
|
|
4275
|
+
start_count: start_requests.length,
|
|
4276
|
+
stop_requests,
|
|
4277
|
+
start_requests,
|
|
4278
|
+
}) }] };
|
|
4279
|
+
},
|
|
4280
|
+
);
|
|
4281
|
+
|
|
2154
4282
|
// §2.5 step 3 — daemon-facing pull. Returns full spec + env_blob +
|
|
2155
4283
|
// child_ntok in one shot. Map evicted on take (one-shot consume).
|
|
2156
4284
|
//
|
|
@@ -2203,8 +4331,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2203
4331
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "env_blob_unavailable" }) }] };
|
|
2204
4332
|
}
|
|
2205
4333
|
// Hydrate spec from row.
|
|
2206
|
-
const specRow = db.get<{ child_name: string; runtime: string; model: string; flags_json: string }>(
|
|
2207
|
-
`SELECT child_name, runtime, model, flags_json FROM node_create_requests WHERE request_id = ?1`,
|
|
4334
|
+
const specRow = db.get<{ child_name: string; runtime: string; model: string | null; flags_json: string; workdir: string | null }>(
|
|
4335
|
+
`SELECT child_name, runtime, model, flags_json, workdir FROM node_create_requests WHERE request_id = ?1`,
|
|
2208
4336
|
request_id,
|
|
2209
4337
|
);
|
|
2210
4338
|
if (!specRow) {
|
|
@@ -2227,6 +4355,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2227
4355
|
model: specRow.model,
|
|
2228
4356
|
flags: JSON.parse(specRow.flags_json),
|
|
2229
4357
|
channels: [],
|
|
4358
|
+
// 只在请求真带了时出现:老 daemon 看不到多余的键,新 daemon 缺席即走老布局。
|
|
4359
|
+
...(specRow.workdir ? { workdir: specRow.workdir } : {}),
|
|
2230
4360
|
},
|
|
2231
4361
|
child_token: blob.child_token,
|
|
2232
4362
|
env_blob: blob.env_blob,
|
|
@@ -2243,14 +4373,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2243
4373
|
// record fork-side info.
|
|
2244
4374
|
server.tool(
|
|
2245
4375
|
"ack_create_request",
|
|
2246
|
-
"Daemon acks a create-node request (called after fork). status='started'
|
|
4376
|
+
"Daemon acks a create-node request (called after fork). status='started' | 'failed' | 'rejected' | 'runtime_capability_check_failed'. RFC-026 §9.3 D2.",
|
|
2247
4377
|
{
|
|
2248
4378
|
request_id: z.string().min(1).max(200),
|
|
2249
|
-
|
|
4379
|
+
// RFC-026 §9.3 D2 — runtime_capability_check_failed signals the
|
|
4380
|
+
// daemon spawned the child OK but it died within FAIL_FAST_MS
|
|
4381
|
+
// (5s in agent-node v2.5.0-preview.11+), indicating a
|
|
4382
|
+
// declaration↔reality gap on this daemon's runtimes_supported.
|
|
4383
|
+
// Treated terminal like 'failed' but fires a distinct audit_log
|
|
4384
|
+
// action so dashboards can highlight "lying daemons" separately
|
|
4385
|
+
// from generic spawn failures.
|
|
4386
|
+
status: z.enum(["started", "failed", "rejected", "runtime_capability_check_failed"]),
|
|
2250
4387
|
error: z.string().max(1000).optional(),
|
|
2251
4388
|
child_pid: z.number().int().optional(),
|
|
4389
|
+
runtime: z.string().max(64).optional(), // populated by daemon when status=runtime_capability_check_failed
|
|
2252
4390
|
},
|
|
2253
|
-
async ({ request_id, status, error: ackError, child_pid: _pid }) => {
|
|
4391
|
+
async ({ request_id, status, error: ackError, child_pid: _pid, runtime: ackRuntime }) => {
|
|
2254
4392
|
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
2255
4393
|
if (!callerDaemon.ok) {
|
|
2256
4394
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
@@ -2276,7 +4414,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2276
4414
|
);
|
|
2277
4415
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "awaiting_register" }) }] };
|
|
2278
4416
|
}
|
|
2279
|
-
// failed / rejected — revoke
|
|
4417
|
+
// failed / rejected / runtime_capability_check_failed — revoke
|
|
4418
|
+
// child-ntok + mark request terminal.
|
|
2280
4419
|
if (row.child_token_id) {
|
|
2281
4420
|
db.run(`UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = ?1 AND revoked_at IS NULL`, [row.child_token_id]);
|
|
2282
4421
|
}
|
|
@@ -2284,10 +4423,857 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2284
4423
|
`UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3 WHERE request_id = ?4 AND status IN ('pending', 'delivered')`,
|
|
2285
4424
|
[status, ackError || null, ackedAt, request_id],
|
|
2286
4425
|
);
|
|
4426
|
+
// RFC-026 §9.3 D2 — surface declaration↔reality gap on a
|
|
4427
|
+
// distinct audit_log action so dashboards / operators can spot
|
|
4428
|
+
// chronically-lying daemons separate from generic spawn-failed.
|
|
4429
|
+
if (status === "runtime_capability_check_failed") {
|
|
4430
|
+
auditCreateNode({
|
|
4431
|
+
action: "daemon_capability_lied",
|
|
4432
|
+
user_id: null,
|
|
4433
|
+
network_id: row.network_id,
|
|
4434
|
+
target_id: request_id,
|
|
4435
|
+
detail: {
|
|
4436
|
+
daemon_node_id: row.daemon_node_id,
|
|
4437
|
+
runtime: ackRuntime || null,
|
|
4438
|
+
error: ackError ? ackError.slice(0, 500) : null,
|
|
4439
|
+
acked_at: ackedAt,
|
|
4440
|
+
},
|
|
4441
|
+
});
|
|
4442
|
+
}
|
|
2287
4443
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
|
|
2288
4444
|
},
|
|
2289
4445
|
);
|
|
2290
4446
|
|
|
4447
|
+
// ─── RFC-027 §2 — stop/delete node lifecycle ───────────────────────
|
|
4448
|
+
//
|
|
4449
|
+
// Two user-facing MCP tools (stop_node, delete_node) + two daemon-
|
|
4450
|
+
// facing tools (get_stop_request, ack_stop_request). State machine
|
|
4451
|
+
// per §2.3 — nodes.lifecycle_state ∈ {active, stopping, stopped,
|
|
4452
|
+
// deleting}. row-gone implies deleted. Security per §4:
|
|
4453
|
+
// §4.1 SEC-1: trust-root SQL join, not getNetworkId
|
|
4454
|
+
// §4.2 D6: delete_node refuses target.role==host_supervisor
|
|
4455
|
+
// §4.3 D4: in-flight inbox default-refuse + force+audit
|
|
4456
|
+
// §4.4 D7: daemon writes backup chmod 700, sweeper真删
|
|
4457
|
+
// §4.5 D8: audit_log in same SQLite tx as state UPDATE
|
|
4458
|
+
//
|
|
4459
|
+
// The dispatcher logic shared by stop_node + delete_node lives in
|
|
4460
|
+
// dispatchStopOrDelete; the two tool handlers thin-wrap it with
|
|
4461
|
+
// their respective action discriminator.
|
|
4462
|
+
type DispatchAction = "stop" | "delete";
|
|
4463
|
+
type DispatchArgs = {
|
|
4464
|
+
action: DispatchAction;
|
|
4465
|
+
child_node_id: string;
|
|
4466
|
+
daemon_node_id: string;
|
|
4467
|
+
force: boolean;
|
|
4468
|
+
grace_seconds: number;
|
|
4469
|
+
delete_config: boolean;
|
|
4470
|
+
confirm_alias?: string;
|
|
4471
|
+
};
|
|
4472
|
+
// RFC-027 PR2 prereq — auto-resolve daemon_node_id from child_node_id.
|
|
4473
|
+
// The dashboard (and most callers of stop_node / delete_node) shouldn't
|
|
4474
|
+
// need to track the daemon→child mapping themselves; the hub has it on
|
|
4475
|
+
// node_create_requests at child creation time. Returns null when no
|
|
4476
|
+
// creation record exists (orphan node row OR pre-RFC-026 node) — caller
|
|
4477
|
+
// must then pass daemon_node_id explicitly. node_id derivation:
|
|
4478
|
+
// `node_${request_id.replace(/^cr_/,"")}` (see create-node-daemon.ts
|
|
4479
|
+
// and PR1 BLOCKER-1 fix), so we reverse it: `cr_${node_id.slice(5)}`.
|
|
4480
|
+
const resolveDaemonForChild = (child_node_id: string): string | null => {
|
|
4481
|
+
if (!child_node_id.startsWith("node_")) return null;
|
|
4482
|
+
const requestId = `cr_${child_node_id.slice(5)}`;
|
|
4483
|
+
const row = db.get<{ daemon_node_id: string }>(
|
|
4484
|
+
`SELECT daemon_node_id FROM node_create_requests WHERE request_id = ?1`,
|
|
4485
|
+
requestId,
|
|
4486
|
+
);
|
|
4487
|
+
return row?.daemon_node_id ?? null;
|
|
4488
|
+
};
|
|
4489
|
+
|
|
4490
|
+
/**
|
|
4491
|
+
* 为什么这里要分两种「解析不到 daemon」(#196 / app 仓)。
|
|
4492
|
+
*
|
|
4493
|
+
* `resolveDaemonForChild` 对两件完全不同的事都返回 `null`:
|
|
4494
|
+
* ① id 不以 `node_` 开头 —— 这个节点**根本不是 daemon 创建的**,
|
|
4495
|
+
* 是有人在某台机器上直接 `anet node start` 起来的。hub 上没有任何
|
|
4496
|
+
* daemon 可以代它执行停止,**这条路径对它在概念上就不成立**。
|
|
4497
|
+
* ② id 以 `node_` 开头,但 `node_create_requests` 里查不到那一行 ——
|
|
4498
|
+
* 它可能确实是 daemon 建的,只是记录缺失,这时「显式传 daemon_node_id」
|
|
4499
|
+
* 是一条真的走得通的路。
|
|
4500
|
+
*
|
|
4501
|
+
* 🔴 原先两种情况打印同一句 `pass daemon_node_id explicitly`。对 ① 来说
|
|
4502
|
+
* **这句建议的前提是假的** —— 没有 daemon 可传,用户按它做只会走进死路。
|
|
4503
|
+
* 2026-08-28 对生产 hub 实测:218 个节点里 **207 个**是 ① 这种。
|
|
4504
|
+
*
|
|
4505
|
+
* 信息在源头就存在(那个 `startsWith("node_")` 判断),只是被丢在了返回值里。
|
|
4506
|
+
*/
|
|
4507
|
+
const explainUnresolvableDaemon = (child_node_id: string) =>
|
|
4508
|
+
child_node_id.startsWith("node_")
|
|
4509
|
+
? {
|
|
4510
|
+
ok: false, error: "daemon_not_resolvable",
|
|
4511
|
+
message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
|
|
4512
|
+
}
|
|
4513
|
+
: {
|
|
4514
|
+
ok: false, error: "not_daemon_managed",
|
|
4515
|
+
message:
|
|
4516
|
+
`node ${child_node_id} was not created by a daemon (it was started by hand with ` +
|
|
4517
|
+
`\`anet node start\` on some machine), so no daemon on the Hub can stop it. ` +
|
|
4518
|
+
`Run \`anet node stop <alias>\` on that machine instead. ` +
|
|
4519
|
+
`Daemon-created children have ids beginning with \`node_\`; this one does not.`,
|
|
4520
|
+
};
|
|
4521
|
+
|
|
4522
|
+
const dispatchStopOrDelete = (args: DispatchArgs, clientNetId?: string | null) => {
|
|
4523
|
+
// §4.1 — SEC-1 trust-root join: resolve target node row WITHIN the
|
|
4524
|
+
// caller's scope. resolveReadScope returns 'denied' if the caller
|
|
4525
|
+
// doesn't belong to clientNetId; for the row-existence test we
|
|
4526
|
+
// re-join with member's networks so an attacker can't probe by
|
|
4527
|
+
// node_id from a network they don't belong to.
|
|
4528
|
+
const scope = resolveReadScope(clientNetId);
|
|
4529
|
+
if (scope.denied) {
|
|
4530
|
+
return { ok: false, error: "forbidden_cross_tenant", message: scope.denied };
|
|
4531
|
+
}
|
|
4532
|
+
const nodeQ: any[] = [args.child_node_id];
|
|
4533
|
+
let nodeSql = `SELECT node_id, alias, network_id, lifecycle_state, config_snapshot, runtimes_supported
|
|
4534
|
+
FROM nodes WHERE node_id = ?1`;
|
|
4535
|
+
nodeSql = addReadScope(nodeSql, nodeQ, scope);
|
|
4536
|
+
const node = db.get<{
|
|
4537
|
+
node_id: string;
|
|
4538
|
+
alias: string;
|
|
4539
|
+
network_id: string;
|
|
4540
|
+
lifecycle_state: string | null;
|
|
4541
|
+
config_snapshot: string | null;
|
|
4542
|
+
}>(nodeSql, ...nodeQ);
|
|
4543
|
+
if (!node) return { ok: false, error: "forbidden_cross_tenant", message: "node not found in your networks" };
|
|
4544
|
+
|
|
4545
|
+
// Caller must hold a write-capable role on the resolved network
|
|
4546
|
+
// (admin/owner/member; viewer denied). canWrite walks getUserNetworkRole.
|
|
4547
|
+
if (!canWrite(node.network_id)) {
|
|
4548
|
+
return { ok: false, error: "permission_denied", message: "viewer role cannot stop/delete nodes" };
|
|
4549
|
+
}
|
|
4550
|
+
|
|
4551
|
+
// §4.2 D6 — delete refuses targets whose role is host_supervisor.
|
|
4552
|
+
// Stop is allowed against any node per RFC table (host_supervisor's
|
|
4553
|
+
// stop is functionally a no-op anyway: the daemon's children_map
|
|
4554
|
+
// only tracks its child nodes, never the daemon itself).
|
|
4555
|
+
//
|
|
4556
|
+
// PR1 SF-4 (#345 review) — fail-CLOSED on role read. The first cut
|
|
4557
|
+
// parsed config_snapshot and treated parse-fail / missing-field as
|
|
4558
|
+
// role=null which slipped through the gate. A daemon row with a
|
|
4559
|
+
// corrupt snapshot would then be deletable via delete_node. Fix:
|
|
4560
|
+
// - read role via two independent paths (snapshot.role + the
|
|
4561
|
+
// indexed `runtimes_supported` column whose presence ≈ daemon)
|
|
4562
|
+
// - if EITHER path indicates host_supervisor → refuse
|
|
4563
|
+
// - if snapshot parsed but role missing AND the node has any
|
|
4564
|
+
// daemon-shape evidence (non-null runtimes_supported column,
|
|
4565
|
+
// present in any node_create_requests as daemon_node_id) →
|
|
4566
|
+
// refuse defensively. The defense-in-depth daemon-side
|
|
4567
|
+
// children_map miss is the second layer; the hub gate must
|
|
4568
|
+
// itself fail-closed when role is ambiguous.
|
|
4569
|
+
if (args.action === "delete") {
|
|
4570
|
+
let role: string | null = null;
|
|
4571
|
+
let snapshotParseFailed = false;
|
|
4572
|
+
try {
|
|
4573
|
+
const snap = node.config_snapshot ? JSON.parse(node.config_snapshot) : null;
|
|
4574
|
+
role = typeof snap?.role === "string" ? snap.role : null;
|
|
4575
|
+
} catch { snapshotParseFailed = true; }
|
|
4576
|
+
const ambiguous = snapshotParseFailed || (role == null);
|
|
4577
|
+
// Independent corroborating check: nodes flagged as daemons by
|
|
4578
|
+
// create_node dispatchers will show up as daemon_node_id in
|
|
4579
|
+
// node_create_requests. Costs one indexed query.
|
|
4580
|
+
let looksLikeDaemon = false;
|
|
4581
|
+
if (ambiguous) {
|
|
4582
|
+
const m = db.get<{ n: number }>(
|
|
4583
|
+
`SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1`,
|
|
4584
|
+
node.node_id,
|
|
4585
|
+
);
|
|
4586
|
+
if ((m?.n ?? 0) > 0) looksLikeDaemon = true;
|
|
4587
|
+
}
|
|
4588
|
+
if (role === "host_supervisor" || (ambiguous && looksLikeDaemon)) {
|
|
4589
|
+
return {
|
|
4590
|
+
ok: false, error: "cannot_delete_daemon_via_delete_node",
|
|
4591
|
+
message: snapshotParseFailed
|
|
4592
|
+
? "node config_snapshot unreadable; node is referenced as a daemon — refuse delete defensively (use delete_daemon path)"
|
|
4593
|
+
: "host_supervisor daemons must be removed via the dedicated delete_daemon path (RFC-027.5)",
|
|
4594
|
+
};
|
|
4595
|
+
}
|
|
4596
|
+
}
|
|
4597
|
+
|
|
4598
|
+
// delete_node confirm_alias gate: dashboard's二次确认 input must
|
|
4599
|
+
// match the actual alias byte-for-byte. Hub rejects mismatched
|
|
4600
|
+
// input even if dashboard UI claims it's disabled.
|
|
4601
|
+
if (args.action === "delete") {
|
|
4602
|
+
if (typeof args.confirm_alias !== "string" || args.confirm_alias !== node.alias) {
|
|
4603
|
+
return { ok: false, error: "confirm_alias_mismatch", message: "confirm_alias must equal the node's alias" };
|
|
4604
|
+
}
|
|
4605
|
+
}
|
|
4606
|
+
|
|
4607
|
+
// Daemon must exist + be in the same network. We do NOT enforce
|
|
4608
|
+
// daemon.role==host_supervisor here: a child whose creator daemon
|
|
4609
|
+
// got demoted should still be stoppable. The daemon will refuse
|
|
4610
|
+
// with noop_not_my_child if children_map doesn't know it.
|
|
4611
|
+
//
|
|
4612
|
+
// PR1.2 e2e catch (BLOCKER): pushEvent keys SSE by clientKey ≈
|
|
4613
|
+
// `${networkId}:${sessionName}`, and agent-node registers its SSE
|
|
4614
|
+
// connection under its ALIAS (not its node_id). The create_node
|
|
4615
|
+
// doorbell at line ~2197 correctly pushes via `daemon.alias`;
|
|
4616
|
+
// stop_node was pushing via `daemon_node_id` so every doorbell
|
|
4617
|
+
// missed every SSE client → daemon never woke up → request_id
|
|
4618
|
+
// stayed 'pending' forever → child never reaped. The 24 unit tests
|
|
4619
|
+
// in stop-delete-node.test.ts all mock pushEvent and inject the
|
|
4620
|
+
// post-doorbell handler call directly, so single-unit coverage
|
|
4621
|
+
// couldn't surface this — exactly the failure class 通信龙 said
|
|
4622
|
+
// the docker e2e gate exists to catch (BLOCKER-1 同源). Load the
|
|
4623
|
+
// alias along with node_id + network_id and dispatch to the alias.
|
|
4624
|
+
const daemon = db.get<{ node_id: string; alias: string; network_id: string }>(
|
|
4625
|
+
`SELECT node_id, alias, network_id FROM nodes WHERE node_id = ?1`, args.daemon_node_id,
|
|
4626
|
+
);
|
|
4627
|
+
if (!daemon) return { ok: false, error: "daemon_not_found" };
|
|
4628
|
+
if (daemon.network_id !== node.network_id) {
|
|
4629
|
+
// Cross-tenant child↔daemon mismatch shouldn't be possible in
|
|
4630
|
+
// normal flow but is a SEC-1 hardening — refuse loud.
|
|
4631
|
+
return { ok: false, error: "daemon_cross_tenant", message: "daemon and child are in different networks" };
|
|
4632
|
+
}
|
|
4633
|
+
|
|
4634
|
+
// #1448 finding-4 — daemon 必须是该 child 的**权威创建者**,镜像 start_node 的
|
|
4635
|
+
// daemon_child_mismatch。此前 stop/delete 接受调用方传入的 daemon_node_id,只校验
|
|
4636
|
+
// 它存在 + 同网,不验它真的创建了这个 child。同网调用方传错 daemon_node_id → 门铃
|
|
4637
|
+
// 路由到错 daemon。🔴 f3(#1453) 合入后更糟:错 daemon 无该 child 的 map entry → 走
|
|
4638
|
+
// 收敛分支 sweep(错机器 pgrep 空) + ack stopped → hub 假收敛 stopped、child 仍在
|
|
4639
|
+
// 正确机器上跑(旧 noop 至少留下可见的卡死态)。网络 scope 只挡跨租户,网内是 footgun。
|
|
4640
|
+
//
|
|
4641
|
+
// 用「确定性不匹配」判据:authority 已知(child 有 create 记录)且不等 → 拒。不对
|
|
4642
|
+
// authority==null(create 记录被裁剪的旧 child)加拒,避免给它们的 stop/delete 引入
|
|
4643
|
+
// 新失败面——handler 的 daemon_node_id??resolveDaemonForChild 已兜住无法解析的情形。
|
|
4644
|
+
const authoritativeDaemonId = resolveDaemonForChild(args.child_node_id);
|
|
4645
|
+
if (authoritativeDaemonId && authoritativeDaemonId !== args.daemon_node_id) {
|
|
4646
|
+
return { ok: false, error: "daemon_child_mismatch", message: "daemon_node_id is not this child's authoritative creator daemon" };
|
|
4647
|
+
}
|
|
4648
|
+
|
|
4649
|
+
// State machine gate.
|
|
4650
|
+
const state = node.lifecycle_state ?? "active";
|
|
4651
|
+
if (args.action === "stop" && state !== "active") {
|
|
4652
|
+
return { ok: false, error: state === "stopping" ? "node_already_stopping" : "node_not_active", current_state: state };
|
|
4653
|
+
}
|
|
4654
|
+
if (args.action === "delete" && state === "deleting") {
|
|
4655
|
+
// #1286 —— stale-deleting 重派出口。
|
|
4656
|
+
//
|
|
4657
|
+
// 在 .40 埋点修复之前,daemon 丢失 ack 会让行**永远**停在 deleting:
|
|
4658
|
+
// 重发 delete 被这里挡住,force=true 也一样 —— 状态机没有出口,
|
|
4659
|
+
// 2026-08-28 实测两行卡死(acc-…-3373 / probe-tl2),API 层面无解。
|
|
4660
|
+
//
|
|
4661
|
+
// 出口的三个条件,缺一不放行:
|
|
4662
|
+
// ① 调用方显式 force=true(不改默认行为 —— 正常并发的重复 delete 仍被拒)
|
|
4663
|
+
// ② 该 child 最近一条 delete 请求不是终态(pending/dispatched)
|
|
4664
|
+
// —— 终态说明 daemon 刚 ack 过,行马上会转移,不该重派
|
|
4665
|
+
// ③ 那条请求已经晾了超过 STALE_DELETING_MS —— 🔴 不是"刚派发就能 force":
|
|
4666
|
+
// 给正常的 doorbell→ack 往返留时间,否则 force 变成竞态放大器。
|
|
4667
|
+
const STALE_DELETING_MS = 5 * 60_000;
|
|
4668
|
+
const last = db.get<{ request_id: string; status: string; created_at: number }>(
|
|
4669
|
+
`SELECT request_id, status, created_at FROM node_stop_requests
|
|
4670
|
+
WHERE child_node_id = ?1 AND action = 'delete'
|
|
4671
|
+
ORDER BY created_at DESC LIMIT 1`, node.node_id,
|
|
4672
|
+
);
|
|
4673
|
+
const lastIsStale = !!last
|
|
4674
|
+
&& (last.status === "pending" || last.status === "dispatched")
|
|
4675
|
+
&& (Date.now() - last.created_at) > STALE_DELETING_MS;
|
|
4676
|
+
if (!(args.force === true && (lastIsStale || !last))) {
|
|
4677
|
+
return {
|
|
4678
|
+
ok: false, error: "node_already_deleting", current_state: state,
|
|
4679
|
+
// 🔴 告诉调用方出口在哪,而不是让他反复撞同一面墙。
|
|
4680
|
+
...(last ? { last_request_id: last.request_id, last_request_status: last.status } : {}),
|
|
4681
|
+
hint: last && (last.status === "pending" || last.status === "dispatched")
|
|
4682
|
+
? `再等一下;若超过 ${STALE_DELETING_MS / 60000} 分钟仍未收敛,带 force=true 重试可重新派发`
|
|
4683
|
+
: "上一条请求已终态,行应即将转移;稍后重查",
|
|
4684
|
+
};
|
|
4685
|
+
}
|
|
4686
|
+
// 放行:走正常派发路径,生成新 request_id。旧请求行保留(审计线索),
|
|
4687
|
+
// daemon 侧对未知 request 的 get_stop_request 会拿到 request_not_found,无副作用。
|
|
4688
|
+
}
|
|
4689
|
+
if (args.action === "delete" && state === "stopping") {
|
|
4690
|
+
// Can't start delete while a stop is mid-flight — race-prone.
|
|
4691
|
+
return { ok: false, error: "node_stopping_in_progress", current_state: state };
|
|
4692
|
+
}
|
|
4693
|
+
|
|
4694
|
+
// §4.3 D4 — in-flight inbox check. Count unacked tasks routed to
|
|
4695
|
+
// this node's alias (inbox routing uses session_name == alias).
|
|
4696
|
+
// Default refuse with the count surfaced; force=true overrides
|
|
4697
|
+
// and triggers the forced_stop_with_in_flight audit row.
|
|
4698
|
+
// SF-5 (#345 review): legacy inbox rows may have network_id=NULL
|
|
4699
|
+
// and would silently miss a network_id=?2 strict equality match.
|
|
4700
|
+
// Use COALESCE so a NULL inbox row is counted against the same
|
|
4701
|
+
// network as the target node (which is the only safe default
|
|
4702
|
+
// — pre-multi-network rows existed in single-network mode).
|
|
4703
|
+
//
|
|
4704
|
+
// #2022 — "unacked" alone is not "in flight". Nothing acks a row
|
|
4705
|
+
// whose task already reached a terminal state (report_completion,
|
|
4706
|
+
// send_reply, send_ack, the TTL patrol all leave inbox alone) and
|
|
4707
|
+
// retention sweeps only acked=1 rows, so those rows were counted
|
|
4708
|
+
// forever: one dead row pinned this gate to node_busy_in_flight
|
|
4709
|
+
// and force=true was the only way past it.
|
|
4710
|
+
//
|
|
4711
|
+
// Counting rule (#2029 review): a row counts while it can still be
|
|
4712
|
+
// work this node is doing — and the age ceiling must NOT be applied
|
|
4713
|
+
// to a task row whose tasks row we can see. agent-node acks the
|
|
4714
|
+
// inbox row only once the task is done, so a 90-minute (or 3-hour,
|
|
4715
|
+
// or ttl_seconds>3600) task keeps acked=0 for its whole run;
|
|
4716
|
+
// capping it by age would open this gate mid-run, which is exactly
|
|
4717
|
+
// what the gate exists to prevent. So:
|
|
4718
|
+
// 1. type='task' row with a matching tasks row — LEFT JOIN on
|
|
4719
|
+
// COALESCE(task_id, id), the key cancel_task / ack_inbox use
|
|
4720
|
+
// (first deliveries set id == task_id; retry/reassign write a
|
|
4721
|
+
// fresh transport id). Status alone decides: terminal
|
|
4722
|
+
// (replied / failed / cancelled / expired) → not counted;
|
|
4723
|
+
// anything else → counted, no age ceiling. Expiry belongs to
|
|
4724
|
+
// patrolExpiredTasks, which now acks the inbox row in the same
|
|
4725
|
+
// transaction, so an expired task leaves this count together
|
|
4726
|
+
// with its rows.
|
|
4727
|
+
// 2. every other row — a task row whose tasks row is gone
|
|
4728
|
+
// (reaped by retention, or a legacy row whose id resolves to
|
|
4729
|
+
// nothing) and non-task rows (reply / broadcast / message):
|
|
4730
|
+
// these carry no status of their own and nothing else will
|
|
4731
|
+
// ever retire them, so they get the age ceiling
|
|
4732
|
+
// IN_FLIGHT_MAX_AGE_MINUTES on inbox.created_at.
|
|
4733
|
+
// The count stays a per-alias inbox count: one PK join per row.
|
|
4734
|
+
const IN_FLIGHT_MAX_AGE_MINUTES = 60;
|
|
4735
|
+
const inFlightRow = db.get<{ n: number }>(
|
|
4736
|
+
`SELECT COUNT(*) AS n FROM inbox i
|
|
4737
|
+
LEFT JOIN tasks t
|
|
4738
|
+
ON i.type = 'task'
|
|
4739
|
+
AND t.task_id = COALESCE(i.task_id, i.id)
|
|
4740
|
+
WHERE i.session_name = ?1 AND i.acked = 0
|
|
4741
|
+
AND COALESCE(i.network_id, ?2) = ?2
|
|
4742
|
+
AND ((t.task_id IS NOT NULL
|
|
4743
|
+
AND t.status NOT IN ('replied', 'failed', 'cancelled', 'expired'))
|
|
4744
|
+
OR (t.task_id IS NULL
|
|
4745
|
+
AND i.created_at >= datetime('now', ?3)))`,
|
|
4746
|
+
node.alias, node.network_id, `-${IN_FLIGHT_MAX_AGE_MINUTES} minutes`,
|
|
4747
|
+
);
|
|
4748
|
+
const inFlight = inFlightRow?.n ?? 0;
|
|
4749
|
+
if (inFlight > 0 && !args.force) {
|
|
4750
|
+
return { ok: false, error: "node_busy_in_flight", in_flight_count: inFlight, hint: "set force=true to override (audit logged)" };
|
|
4751
|
+
}
|
|
4752
|
+
|
|
4753
|
+
// ── all gates passed; create request + transition state + push doorbell, transactionally ──
|
|
4754
|
+
const requestId = generateId("sr");
|
|
4755
|
+
const now = Date.now();
|
|
4756
|
+
const newState = args.action === "stop" ? "stopping" : "deleting";
|
|
4757
|
+
|
|
4758
|
+
// #1448 finding-5 — 记下该 child **当前活着的** ntok 的 token_id,让 ack 精确按
|
|
4759
|
+
// token_id 撤销,而非按 name='node:<alias>' 广撤。后者会在「同 alias 节点在这次
|
|
4760
|
+
// delete 未收敛的窗口内被重建(拿到同名新 token)」时,把新 token 一并误撤。这里
|
|
4761
|
+
// 在派发时刻定住身份;若此刻没有活 token(异常),存 null,ack 退回按 name 撤(旧语义)。
|
|
4762
|
+
const childTokenRow = db.get<{ token_id: string }>(
|
|
4763
|
+
`SELECT token_id FROM api_tokens WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
|
|
4764
|
+
node.network_id, `node:${node.alias}`,
|
|
4765
|
+
);
|
|
4766
|
+
const childTokenId = childTokenRow?.token_id ?? null;
|
|
4767
|
+
|
|
4768
|
+
// §4.5 D8 — lifecycle UPDATE + audit INSERTs + request INSERT atomic.
|
|
4769
|
+
// PR1.1: use db.transaction() (SQLiteAdapter wraps better-sqlite3's
|
|
4770
|
+
// native transaction; PgAdapter ships a real BEGIN/COMMIT/ROLLBACK
|
|
4771
|
+
// shim). Lets us drop the open-coded BEGIN/COMMIT + makes the
|
|
4772
|
+
// SF-2 failure-injection test (mock db.run throw inside callback)
|
|
4773
|
+
// reliable across both backends.
|
|
4774
|
+
try {
|
|
4775
|
+
db.transaction(() => {
|
|
4776
|
+
db.run(
|
|
4777
|
+
`INSERT INTO node_stop_requests
|
|
4778
|
+
(request_id, network_id, daemon_node_id, child_node_id, child_alias, child_token_id, action,
|
|
4779
|
+
delete_config, grace_seconds, force, in_flight_at_dispatch, created_by_token,
|
|
4780
|
+
status, created_at)
|
|
4781
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'pending', ?13)`,
|
|
4782
|
+
[
|
|
4783
|
+
requestId, node.network_id, args.daemon_node_id, node.node_id, node.alias, childTokenId, args.action,
|
|
4784
|
+
args.delete_config ? 1 : 0, args.grace_seconds, args.force ? 1 : 0, inFlight,
|
|
4785
|
+
callerTokenId || "unknown", now,
|
|
4786
|
+
],
|
|
4787
|
+
);
|
|
4788
|
+
db.run(
|
|
4789
|
+
`UPDATE nodes SET lifecycle_state = ?1 WHERE node_id = ?2`,
|
|
4790
|
+
[newState, node.node_id],
|
|
4791
|
+
);
|
|
4792
|
+
// §4.5 D8 — audit dispatch action. Uses the STRICT variant so a
|
|
4793
|
+
// failed audit INSERT propagates and triggers ROLLBACK (PR1 SF-2
|
|
4794
|
+
// review catch — auditCreateNode's swallow-and-warn would have
|
|
4795
|
+
// committed the lifecycle UPDATE without an audit row, leaving
|
|
4796
|
+
// exactly the "deleted but no audit" window §4.5 closes).
|
|
4797
|
+
auditCreateNodeStrict({
|
|
4798
|
+
action: args.action === "stop" ? "stop_node_dispatched" : "delete_node_dispatched",
|
|
4799
|
+
user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
|
|
4800
|
+
detail: {
|
|
4801
|
+
child_node_id: node.node_id, child_alias: node.alias,
|
|
4802
|
+
daemon_node_id: args.daemon_node_id, action: args.action,
|
|
4803
|
+
force: args.force, delete_config: args.delete_config,
|
|
4804
|
+
grace_seconds: args.grace_seconds, in_flight_at_dispatch: inFlight,
|
|
4805
|
+
lifecycle_state_before: state, lifecycle_state_after: newState,
|
|
4806
|
+
ts_request: now,
|
|
4807
|
+
},
|
|
4808
|
+
});
|
|
4809
|
+
if (args.force && inFlight > 0) {
|
|
4810
|
+
auditCreateNodeStrict({
|
|
4811
|
+
action: "forced_stop_with_in_flight",
|
|
4812
|
+
user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
|
|
4813
|
+
detail: { in_flight_count: inFlight, child_alias: node.alias },
|
|
4814
|
+
});
|
|
4815
|
+
}
|
|
4816
|
+
}); // end db.transaction
|
|
4817
|
+
} catch (e: any) {
|
|
4818
|
+
return { ok: false, error: "dispatch_tx_failed", message: e?.message || String(e) };
|
|
4819
|
+
}
|
|
4820
|
+
|
|
4821
|
+
// SSE doorbell — daemon will pull via get_stop_request.
|
|
4822
|
+
// PR1.2 e2e fix: route by daemon.alias (see SELECT above for the
|
|
4823
|
+
// BLOCKER explanation). Mirrors create_node's pushEvent target at
|
|
4824
|
+
// line ~2197.
|
|
4825
|
+
pushEvent(daemon.alias, { type: "stop_node", request_id: requestId }, node.network_id);
|
|
4826
|
+
return {
|
|
4827
|
+
ok: true, request_id: requestId, action: args.action,
|
|
4828
|
+
lifecycle_state: newState, in_flight_at_dispatch: inFlight,
|
|
4829
|
+
};
|
|
4830
|
+
};
|
|
4831
|
+
|
|
4832
|
+
server.tool(
|
|
4833
|
+
"stop_node",
|
|
4834
|
+
"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).",
|
|
4835
|
+
{
|
|
4836
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
4837
|
+
// PR2 prereq: dashboard rarely knows daemon_node_id directly.
|
|
4838
|
+
// When omitted, hub resolves from the original creation record
|
|
4839
|
+
// (node_create_requests.daemon_node_id keyed by this child).
|
|
4840
|
+
daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
|
|
4841
|
+
force: z.boolean().optional().default(false),
|
|
4842
|
+
grace_seconds: z.number().int().min(5).max(60).optional().default(10),
|
|
4843
|
+
network_id: z.string().max(200).optional(),
|
|
4844
|
+
},
|
|
4845
|
+
async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, force, grace_seconds, network_id: clientNetId }) => {
|
|
4846
|
+
const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
|
|
4847
|
+
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
4848
|
+
const child_node_id = idArg.node_id;
|
|
4849
|
+
const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
|
|
4850
|
+
if (!resolved) {
|
|
4851
|
+
return { content: [{ type: "text" as const,
|
|
4852
|
+
text: JSON.stringify(explainUnresolvableDaemon(child_node_id)) }] };
|
|
4853
|
+
}
|
|
4854
|
+
const r = dispatchStopOrDelete(
|
|
4855
|
+
{ action: "stop", child_node_id, daemon_node_id: resolved, force: force ?? false,
|
|
4856
|
+
grace_seconds: grace_seconds ?? 10, delete_config: false },
|
|
4857
|
+
clientNetId,
|
|
4858
|
+
);
|
|
4859
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
|
|
4860
|
+
},
|
|
4861
|
+
);
|
|
4862
|
+
|
|
4863
|
+
server.tool(
|
|
4864
|
+
"delete_node",
|
|
4865
|
+
"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.",
|
|
4866
|
+
{
|
|
4867
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
4868
|
+
daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
|
|
4869
|
+
confirm_alias: z.string().min(1).max(200),
|
|
4870
|
+
force: z.boolean().optional().default(false),
|
|
4871
|
+
grace_seconds: z.number().int().min(5).max(60).optional().default(10),
|
|
4872
|
+
delete_config: z.boolean().optional().default(true),
|
|
4873
|
+
network_id: z.string().max(200).optional(),
|
|
4874
|
+
},
|
|
4875
|
+
async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, confirm_alias, force, grace_seconds, delete_config, network_id: clientNetId }) => {
|
|
4876
|
+
const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
|
|
4877
|
+
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
4878
|
+
const child_node_id = idArg.node_id;
|
|
4879
|
+
const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
|
|
4880
|
+
if (!resolved) {
|
|
4881
|
+
return { content: [{ type: "text" as const,
|
|
4882
|
+
text: JSON.stringify(explainUnresolvableDaemon(child_node_id)) }] };
|
|
4883
|
+
}
|
|
4884
|
+
const r = dispatchStopOrDelete(
|
|
4885
|
+
{ action: "delete", child_node_id, daemon_node_id: resolved, force: force ?? false,
|
|
4886
|
+
grace_seconds: grace_seconds ?? 10, delete_config: delete_config ?? true, confirm_alias },
|
|
4887
|
+
clientNetId,
|
|
4888
|
+
);
|
|
4889
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
|
|
4890
|
+
},
|
|
4891
|
+
);
|
|
4892
|
+
|
|
4893
|
+
server.tool(
|
|
4894
|
+
"get_stop_request",
|
|
4895
|
+
"Daemon pulls a pending stop/delete request (called when SSE stop_node doorbell arrives). RFC-027 §2.4.",
|
|
4896
|
+
{
|
|
4897
|
+
request_id: z.string().min(1).max(200),
|
|
4898
|
+
},
|
|
4899
|
+
async ({ request_id }) => {
|
|
4900
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
4901
|
+
if (!callerDaemon.ok) {
|
|
4902
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
4903
|
+
}
|
|
4904
|
+
const row = db.get<{
|
|
4905
|
+
daemon_node_id: string; status: string; network_id: string;
|
|
4906
|
+
child_node_id: string; child_alias: string; action: string;
|
|
4907
|
+
delete_config: number; grace_seconds: number; force: number;
|
|
4908
|
+
}>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, action,
|
|
4909
|
+
delete_config, grace_seconds, force
|
|
4910
|
+
FROM node_stop_requests WHERE request_id = ?1`, request_id);
|
|
4911
|
+
if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
4912
|
+
if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
|
|
4913
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
4914
|
+
}
|
|
4915
|
+
if (row.network_id !== callerDaemon.networkId) {
|
|
4916
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
|
|
4917
|
+
}
|
|
4918
|
+
// Stamp delivered_at on first pull (idempotent — only if still pending).
|
|
4919
|
+
db.run(
|
|
4920
|
+
`UPDATE node_stop_requests SET status = 'delivered', delivered_at = ?1
|
|
4921
|
+
WHERE request_id = ?2 AND status = 'pending'`,
|
|
4922
|
+
[Date.now(), request_id],
|
|
4923
|
+
);
|
|
4924
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
4925
|
+
ok: true,
|
|
4926
|
+
request_id,
|
|
4927
|
+
child_node_id: row.child_node_id,
|
|
4928
|
+
child_alias: row.child_alias,
|
|
4929
|
+
action: row.action,
|
|
4930
|
+
delete_config: row.delete_config === 1,
|
|
4931
|
+
grace_seconds: row.grace_seconds,
|
|
4932
|
+
force: row.force === 1,
|
|
4933
|
+
}) }] };
|
|
4934
|
+
},
|
|
4935
|
+
);
|
|
4936
|
+
|
|
4937
|
+
server.tool(
|
|
4938
|
+
"ack_stop_request",
|
|
4939
|
+
"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.",
|
|
4940
|
+
{
|
|
4941
|
+
request_id: z.string().min(1).max(200),
|
|
4942
|
+
status: z.enum(["stopped", "stop_failed", "noop_not_my_child"]),
|
|
4943
|
+
exit_signal: z.string().max(16).optional(),
|
|
4944
|
+
backup_path: z.string().max(500).optional(),
|
|
4945
|
+
error: z.string().max(1000).optional(),
|
|
4946
|
+
},
|
|
4947
|
+
async ({ request_id, status, exit_signal, backup_path, error: ackError }) => {
|
|
4948
|
+
// #1358 —— 🔴 在此之前,这个工具的**每一条出口都是静默的**:到达不记、
|
|
4949
|
+
// 三条拒绝不记、成功收尾也不记。而隔壁 create-node 的收尾**是记的**
|
|
4950
|
+
// (`✓ create-node finalize`)。这种不对称最坑人:读日志的人看到 create 有、
|
|
4951
|
+
// delete 没有,第一反应是「delete 没发生」—— 但两者的日志覆盖面本来就不同,
|
|
4952
|
+
// 那条推论没有依据。排查 #1286 时我自己就差点这样读。
|
|
4953
|
+
console.log(`[commhub] ← ack_stop_request request=${request_id} status=${status}${exit_signal ? ` exit_signal=${exit_signal}` : ""}`);
|
|
4954
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
4955
|
+
if (!callerDaemon.ok) {
|
|
4956
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
4957
|
+
}
|
|
4958
|
+
const row = db.get<{
|
|
4959
|
+
daemon_node_id: string; status: string; network_id: string;
|
|
4960
|
+
child_node_id: string; child_alias: string; child_token_id: string | null; action: string;
|
|
4961
|
+
in_flight_at_dispatch: number; force: number;
|
|
4962
|
+
}>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, child_token_id, action,
|
|
4963
|
+
in_flight_at_dispatch, force
|
|
4964
|
+
FROM node_stop_requests WHERE request_id = ?1`, request_id);
|
|
4965
|
+
if (!row) {
|
|
4966
|
+
console.warn(`[commhub] ✕ ack_stop_request rejected: request_not_found request=${request_id}`);
|
|
4967
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
4968
|
+
}
|
|
4969
|
+
if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
|
|
4970
|
+
console.warn(`[commhub] ✕ ack_stop_request rejected: not_your_request request=${request_id} row_daemon=${row.daemon_node_id} caller=${callerDaemon.daemonNodeId}`);
|
|
4971
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
4972
|
+
}
|
|
4973
|
+
if (row.network_id !== callerDaemon.networkId) {
|
|
4974
|
+
console.warn(`[commhub] ✕ ack_stop_request rejected: cross_network_request request=${request_id} row_net=${row.network_id} caller_net=${callerDaemon.networkId}`);
|
|
4975
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
|
|
4976
|
+
}
|
|
4977
|
+
const now = Date.now();
|
|
4978
|
+
|
|
4979
|
+
// §4.5 D8 — lifecycle UPDATE + request UPDATE + audit INSERT
|
|
4980
|
+
// (+ DELETE for delete action) atomic via db.transaction().
|
|
4981
|
+
// PR1.1: replaces hand-rolled BEGIN..COMMIT so PG adapter and
|
|
4982
|
+
// SQLite both get correct rollback semantics from one code path.
|
|
4983
|
+
try {
|
|
4984
|
+
db.transaction(() => {
|
|
4985
|
+
db.run(
|
|
4986
|
+
`UPDATE node_stop_requests SET status = ?1, error = ?2, exit_signal = ?3,
|
|
4987
|
+
backup_path = ?4, acked_at = ?5
|
|
4988
|
+
WHERE request_id = ?6`,
|
|
4989
|
+
[status, ackError || null, exit_signal || null, backup_path || null, now, request_id],
|
|
4990
|
+
);
|
|
4991
|
+
if (status === "stopped" && row.action === "stop") {
|
|
4992
|
+
db.run(`UPDATE nodes SET lifecycle_state = 'stopped' WHERE node_id = ?1`, [row.child_node_id]);
|
|
4993
|
+
auditCreateNodeStrict({
|
|
4994
|
+
action: "stop_node_completed",
|
|
4995
|
+
user_id: null, network_id: row.network_id, target_id: request_id,
|
|
4996
|
+
detail: {
|
|
4997
|
+
child_node_id: row.child_node_id, child_alias: row.child_alias,
|
|
4998
|
+
exit_signal: exit_signal || null, ts_daemon_ack: now,
|
|
4999
|
+
lifecycle_state_after: "stopped",
|
|
5000
|
+
},
|
|
5001
|
+
});
|
|
5002
|
+
} else if (status === "stopped" && row.action === "delete") {
|
|
5003
|
+
// Revoke the child's ntok, then DELETE the nodes row.
|
|
5004
|
+
// #1448 finding-5 — 精确按派发时定住的 child_token_id 撤(镜像
|
|
5005
|
+
// ack_create_request 的 token_id 精撤)。旧行(升级前派发,child_token_id
|
|
5006
|
+
// 为 NULL)fallback 回按 name='node:<alias>' 撤——旧语义,不引入回归。
|
|
5007
|
+
// 按 token_id 撤避免了「同 alias 在本次 delete 窗口内被重建、拿到同名新
|
|
5008
|
+
// token」时把新 token 一并误撤。
|
|
5009
|
+
if (row.child_token_id) {
|
|
5010
|
+
db.run(
|
|
5011
|
+
`UPDATE api_tokens SET revoked_at = datetime('now')
|
|
5012
|
+
WHERE token_id = ?1 AND revoked_at IS NULL`,
|
|
5013
|
+
[row.child_token_id],
|
|
5014
|
+
);
|
|
5015
|
+
} else {
|
|
5016
|
+
db.run(
|
|
5017
|
+
`UPDATE api_tokens SET revoked_at = datetime('now')
|
|
5018
|
+
WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
|
|
5019
|
+
[row.network_id, `node:${row.child_alias}`],
|
|
5020
|
+
);
|
|
5021
|
+
}
|
|
5022
|
+
db.run(`DELETE FROM nodes WHERE node_id = ?1`, [row.child_node_id]);
|
|
5023
|
+
auditCreateNodeStrict({
|
|
5024
|
+
action: "delete_node_completed",
|
|
5025
|
+
user_id: null, network_id: row.network_id, target_id: request_id,
|
|
5026
|
+
detail: {
|
|
5027
|
+
child_node_id: row.child_node_id, child_alias: row.child_alias,
|
|
5028
|
+
exit_signal: exit_signal || null, backup_path: backup_path || null,
|
|
5029
|
+
ts_daemon_ack: now, lifecycle_state_after: "deleted",
|
|
5030
|
+
},
|
|
5031
|
+
});
|
|
5032
|
+
} else if (status === "stop_failed") {
|
|
5033
|
+
db.run(`UPDATE nodes SET lifecycle_state = 'stop_failed' WHERE node_id = ?1`, [row.child_node_id]);
|
|
5034
|
+
// No completion audit; the failure error is on the request row.
|
|
5035
|
+
}
|
|
5036
|
+
// noop_not_my_child: leave lifecycle_state as-is; the hub-side
|
|
5037
|
+
// sweeper / next reconciliation will pick it up. Don't transition.
|
|
5038
|
+
});
|
|
5039
|
+
} catch (e: any) {
|
|
5040
|
+
console.warn(`[commhub] ✕ ack_stop_request finalize_tx_failed request=${request_id} action=${row.action} child=${row.child_alias}: ${e?.message || e}`);
|
|
5041
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "finalize_tx_failed", message: e?.message || String(e) }) }] };
|
|
5042
|
+
}
|
|
5043
|
+
|
|
5044
|
+
// 🔴 关键的是最后那半句「据此做了什么」。#1286 里 hub 的代码路径经核对是
|
|
5045
|
+
// 正确的,缺的正是「它到底走没走那条分支」—— 只说「收到了 ack」回答不了。
|
|
5046
|
+
const did = status === "stopped" && row.action === "delete"
|
|
5047
|
+
? "revoked ntok + DELETE FROM nodes"
|
|
5048
|
+
: status === "stopped" && row.action === "stop"
|
|
5049
|
+
? "lifecycle_state=stopped"
|
|
5050
|
+
: status === "stop_failed"
|
|
5051
|
+
? "lifecycle_state=stop_failed"
|
|
5052
|
+
: "no lifecycle transition (noop_not_my_child)";
|
|
5053
|
+
console.log(`[commhub] ✓ ${row.action}-node finalize: request=${request_id} child=${row.child_alias} (${row.child_node_id}) status=${status} → ${did}`);
|
|
5054
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
|
|
5055
|
+
},
|
|
5056
|
+
);
|
|
5057
|
+
|
|
5058
|
+
// start_node is deliberately daemon-mediated. A stopped child has no SSE
|
|
5059
|
+
// consumer of its own, so restart_node's self-doorbell cannot revive it.
|
|
5060
|
+
server.tool(
|
|
5061
|
+
"start_node",
|
|
5062
|
+
"Start a stopped child through its host supervisor daemon. daemon_node_id is auto-resolved from the original create request.",
|
|
5063
|
+
{
|
|
5064
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
5065
|
+
daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
|
|
5066
|
+
network_id: z.string().max(200).optional(),
|
|
5067
|
+
},
|
|
5068
|
+
async ({ node_id, child_node_id: child_node_id_arg, daemon_node_id, network_id: clientNetId }) => {
|
|
5069
|
+
const idArg = resolveNodeIdArg({ node_id, child_node_id: child_node_id_arg });
|
|
5070
|
+
if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
|
|
5071
|
+
const child_node_id = idArg.node_id;
|
|
5072
|
+
const resolvedDaemonId = daemon_node_id ?? resolveDaemonForChild(child_node_id);
|
|
5073
|
+
if (!resolvedDaemonId) {
|
|
5074
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
5075
|
+
ok: false, error: "daemon_not_resolvable",
|
|
5076
|
+
message: "no create request maps this child to a daemon",
|
|
5077
|
+
}) }] };
|
|
5078
|
+
}
|
|
5079
|
+
const scope = resolveReadScope(clientNetId);
|
|
5080
|
+
if (scope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "forbidden_cross_tenant" }) }] };
|
|
5081
|
+
const q: any[] = [child_node_id];
|
|
5082
|
+
const child = db.get<{ node_id: string; alias: string; network_id: string; lifecycle_state: string | null }>(
|
|
5083
|
+
addReadScope(`SELECT node_id, alias, network_id, lifecycle_state FROM nodes WHERE node_id = ?1`, q, scope), ...q,
|
|
5084
|
+
);
|
|
5085
|
+
if (!child) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "forbidden_cross_tenant" }) }] };
|
|
5086
|
+
if (!canWrite(child.network_id)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
|
|
5087
|
+
// #1448 finding-2 — stale-starting reaper,对齐 update_node_config / restart_node
|
|
5088
|
+
// 的 60s single-flight stale-supersede。start 只受理 'stopped';一旦卡 'starting'
|
|
5089
|
+
// (门铃丢 / daemon 在 UPDATE 'starting' 后死),config/restart 有 reaper、delete 有
|
|
5090
|
+
// 5min force,唯独 start 裸奔 → 下面的 gate 永远拦、也无人把节点拉回 stopped →
|
|
5091
|
+
// 永久卡 'starting'。出口:'starting' 且最近一条 in-flight start 请求晾过阈值 → 标
|
|
5092
|
+
// 旧请求 timeout 超越、放行重派;未过阈值则拒 node_already_starting(带 age)。
|
|
5093
|
+
const priorState = child.lifecycle_state ?? "active";
|
|
5094
|
+
if (priorState !== "stopped") {
|
|
5095
|
+
if (priorState !== "starting") {
|
|
5096
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_stopped", current_state: priorState }) }] };
|
|
5097
|
+
}
|
|
5098
|
+
const STALE_STARTING_MS = 60_000;
|
|
5099
|
+
const inFlight = db.get<{ request_id: string; created_at: number; delivered_at: number | null; acked_at: number | null }>(
|
|
5100
|
+
`SELECT request_id, created_at, delivered_at, acked_at FROM node_start_requests
|
|
5101
|
+
WHERE child_node_id = ?1 AND status IN ('pending', 'delivered')
|
|
5102
|
+
ORDER BY created_at DESC LIMIT 1`, child.node_id,
|
|
5103
|
+
);
|
|
5104
|
+
if (inFlight) {
|
|
5105
|
+
// Age anchor = COALESCE(acked_at, delivered_at, created_at),同 config reaper:
|
|
5106
|
+
// 已 pull/已 ack 的活着但慢的 start 会刷新存活时钟,不被误 reap。
|
|
5107
|
+
const ageAnchor = inFlight.acked_at ?? inFlight.delivered_at ?? inFlight.created_at;
|
|
5108
|
+
const age = Date.now() - ageAnchor;
|
|
5109
|
+
if (age <= STALE_STARTING_MS) {
|
|
5110
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
5111
|
+
ok: false, error: "node_already_starting",
|
|
5112
|
+
existing_request_id: inFlight.request_id, age_ms: age,
|
|
5113
|
+
hint: `再等一下;超过 ${STALE_STARTING_MS / 1000}s 仍卡 starting,重试 start 会重新派发`,
|
|
5114
|
+
}) }] };
|
|
5115
|
+
}
|
|
5116
|
+
// Stale — 标旧请求 timeout(autocommit,先于下面 tx 的新 INSERT,解除
|
|
5117
|
+
// node_start_requests 对 pending/delivered 的 child 唯一约束),放行重派。
|
|
5118
|
+
db.run(
|
|
5119
|
+
`UPDATE node_start_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id = ?3`,
|
|
5120
|
+
[Date.now(), `superseded by new start after ${age}ms stale (> ${STALE_STARTING_MS}ms threshold)`, inFlight.request_id],
|
|
5121
|
+
);
|
|
5122
|
+
}
|
|
5123
|
+
// else: 'starting' 但无 in-flight 行(异常残留态)——照样放行重派以自愈。
|
|
5124
|
+
}
|
|
5125
|
+
// The caller may provide daemon_node_id, but it cannot override the
|
|
5126
|
+
// authoritative child->daemon relationship recorded at creation.
|
|
5127
|
+
const authoritativeDaemonId = resolveDaemonForChild(child_node_id);
|
|
5128
|
+
if (!authoritativeDaemonId || authoritativeDaemonId !== resolvedDaemonId) {
|
|
5129
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_child_mismatch" }) }] };
|
|
5130
|
+
}
|
|
5131
|
+
const daemon = db.get<{ node_id: string; alias: string; network_id: string }>(
|
|
5132
|
+
`SELECT node_id, alias, network_id FROM nodes WHERE node_id = ?1`, resolvedDaemonId,
|
|
5133
|
+
);
|
|
5134
|
+
if (!daemon) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found" }) }] };
|
|
5135
|
+
if (daemon.network_id !== child.network_id) {
|
|
5136
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_cross_tenant" }) }] };
|
|
5137
|
+
}
|
|
5138
|
+
const requestId = generateId("str");
|
|
5139
|
+
const now = Date.now();
|
|
5140
|
+
try {
|
|
5141
|
+
db.transaction(() => {
|
|
5142
|
+
db.run(
|
|
5143
|
+
`INSERT INTO node_start_requests
|
|
5144
|
+
(request_id, network_id, daemon_node_id, child_node_id, child_alias,
|
|
5145
|
+
created_by_token, status, created_at)
|
|
5146
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7)`,
|
|
5147
|
+
[requestId, child.network_id, daemon.node_id, child.node_id, child.alias, callerTokenId || "unknown", now],
|
|
5148
|
+
);
|
|
5149
|
+
// #1448 finding-2 — 也接受从 'starting' 重派(stale-supersede 后原态仍是
|
|
5150
|
+
// starting);两种入态都合法地转/留到 'starting'。
|
|
5151
|
+
db.run(`UPDATE nodes SET lifecycle_state = 'starting' WHERE node_id = ?1 AND lifecycle_state IN ('stopped', 'starting')`, [child.node_id]);
|
|
5152
|
+
auditCreateNodeStrict({
|
|
5153
|
+
action: "start_node_dispatched", user_id: enforceUserId,
|
|
5154
|
+
network_id: child.network_id, target_id: requestId,
|
|
5155
|
+
detail: { child_node_id: child.node_id, child_alias: child.alias, daemon_node_id: daemon.node_id,
|
|
5156
|
+
lifecycle_state_before: priorState, lifecycle_state_after: "starting", ts_request: now },
|
|
5157
|
+
});
|
|
5158
|
+
});
|
|
5159
|
+
} catch (e: any) {
|
|
5160
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "dispatch_tx_failed", message: e?.message || String(e) }) }] };
|
|
5161
|
+
}
|
|
5162
|
+
pushEvent(daemon.alias, { type: "start_node", request_id: requestId }, child.network_id);
|
|
5163
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, lifecycle_state: "starting" }) }] };
|
|
5164
|
+
},
|
|
5165
|
+
);
|
|
5166
|
+
|
|
5167
|
+
server.tool(
|
|
5168
|
+
"get_start_request",
|
|
5169
|
+
"Host supervisor pulls an authenticated pending start request.",
|
|
5170
|
+
{ request_id: z.string().min(1).max(200) },
|
|
5171
|
+
async ({ request_id }) => {
|
|
5172
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
5173
|
+
if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
5174
|
+
const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
|
|
5175
|
+
`SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id = ?1`, request_id,
|
|
5176
|
+
);
|
|
5177
|
+
if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
5178
|
+
if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
5179
|
+
if (row.network_id !== callerDaemon.networkId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
|
|
5180
|
+
if (!['pending', 'delivered'].includes(row.status)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_startable", status: row.status }) }] };
|
|
5181
|
+
db.run(`UPDATE node_start_requests SET status='delivered', delivered_at=?1 WHERE request_id=?2 AND status='pending'`, [Date.now(), request_id]);
|
|
5182
|
+
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 }) }] };
|
|
5183
|
+
},
|
|
5184
|
+
);
|
|
5185
|
+
|
|
5186
|
+
server.tool(
|
|
5187
|
+
"ack_start_request",
|
|
5188
|
+
"Host supervisor reports start completion or failure.",
|
|
5189
|
+
{
|
|
5190
|
+
request_id: z.string().min(1).max(200),
|
|
5191
|
+
status: z.enum(["started", "start_failed"]),
|
|
5192
|
+
child_pid: z.number().int().positive().optional(),
|
|
5193
|
+
error: z.string().max(1000).optional(),
|
|
5194
|
+
},
|
|
5195
|
+
async ({ request_id, status, child_pid, error: ackError }) => {
|
|
5196
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
5197
|
+
if (!callerDaemon.ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
5198
|
+
const row = db.get<{ daemon_node_id: string; network_id: string; child_node_id: string; child_alias: string; status: string }>(
|
|
5199
|
+
`SELECT daemon_node_id, network_id, child_node_id, child_alias, status FROM node_start_requests WHERE request_id=?1`, request_id,
|
|
5200
|
+
);
|
|
5201
|
+
if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
5202
|
+
if (row.daemon_node_id !== callerDaemon.daemonNodeId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
5203
|
+
if (row.network_id !== callerDaemon.networkId) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
|
|
5204
|
+
if (row.status === "started" || row.status === "start_failed") {
|
|
5205
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: row.status, idempotent: true }) }] };
|
|
5206
|
+
}
|
|
5207
|
+
if (!['pending', 'delivered'].includes(row.status)) {
|
|
5208
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_ackable", status: row.status }) }] };
|
|
5209
|
+
}
|
|
5210
|
+
if (status === "started" && !child_pid) {
|
|
5211
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "child_pid_required" }) }] };
|
|
5212
|
+
}
|
|
5213
|
+
const now = Date.now();
|
|
5214
|
+
try {
|
|
5215
|
+
db.transaction(() => {
|
|
5216
|
+
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]);
|
|
5217
|
+
db.run(`UPDATE nodes SET lifecycle_state=?1 WHERE node_id=?2`, [status === "started" ? "active" : "stopped", row.child_node_id]);
|
|
5218
|
+
if (status === "started") auditCreateNodeStrict({
|
|
5219
|
+
action: "start_node_completed", network_id: row.network_id, target_id: request_id,
|
|
5220
|
+
detail: { child_node_id: row.child_node_id, child_alias: row.child_alias, child_pid, lifecycle_state_after: "active", ts_daemon_ack: now },
|
|
5221
|
+
});
|
|
5222
|
+
});
|
|
5223
|
+
} catch (e: any) {
|
|
5224
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "finalize_tx_failed", message: e?.message || String(e) }) }] };
|
|
5225
|
+
}
|
|
5226
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
|
|
5227
|
+
},
|
|
5228
|
+
);
|
|
5229
|
+
|
|
5230
|
+
// RFC-027 PR1.1 — list_my_children: daemon-only query. Returns the
|
|
5231
|
+
// {child_node_id, alias, lifecycle_state} tuples for nodes whose
|
|
5232
|
+
// active create-request has this daemon as daemon_node_id. Used at
|
|
5233
|
+
// daemon boot to rebuild the in-memory childrenMap (PR1 dropped
|
|
5234
|
+
// every entry on daemon restart → stop/delete silently no-op'd).
|
|
5235
|
+
//
|
|
5236
|
+
// Network-scope is the daemon's own (resolveCallerDaemonTokenBound
|
|
5237
|
+
// already binds tokenIsNetwork to a single network). No SEC-1 leak:
|
|
5238
|
+
// returns ONLY children whose request landed under this daemon's
|
|
5239
|
+
// token + network.
|
|
5240
|
+
server.tool(
|
|
5241
|
+
"list_my_children",
|
|
5242
|
+
"Daemon pulls the alias + node_id list of children it spawned (for childrenMap rebuild after restart). RFC-027 PR1.1.",
|
|
5243
|
+
{},
|
|
5244
|
+
async () => {
|
|
5245
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
5246
|
+
if (!callerDaemon.ok) {
|
|
5247
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
5248
|
+
}
|
|
5249
|
+
// node_create_requests carries the canonical authoritative
|
|
5250
|
+
// child→daemon mapping (the row id IS the request, and the
|
|
5251
|
+
// child node_id derives from it deterministically via
|
|
5252
|
+
// `node_${request_id.replace(/^cr_/, "")}`). Filter to children
|
|
5253
|
+
// that completed registration (have a nodes row) so we don't
|
|
5254
|
+
// ask the daemon to pgrep for children that never came up.
|
|
5255
|
+
const rows = db.all<{ request_id: string; child_name: string; child_node_id: string; lifecycle_state: string | null }>(
|
|
5256
|
+
`SELECT ncr.request_id, ncr.child_name,
|
|
5257
|
+
('node_' || substr(ncr.request_id, 4)) AS child_node_id,
|
|
5258
|
+
n.lifecycle_state
|
|
5259
|
+
FROM node_create_requests ncr
|
|
5260
|
+
LEFT JOIN nodes n ON n.node_id = ('node_' || substr(ncr.request_id, 4))
|
|
5261
|
+
WHERE ncr.daemon_node_id = ?1
|
|
5262
|
+
AND ncr.network_id = ?2
|
|
5263
|
+
AND ncr.status IN ('succeeded', 'delivered')
|
|
5264
|
+
AND n.node_id IS NOT NULL
|
|
5265
|
+
AND COALESCE(n.lifecycle_state, 'active') NOT IN ('stopped', 'stop_failed')`,
|
|
5266
|
+
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
5267
|
+
);
|
|
5268
|
+
const children = rows.map(r => ({
|
|
5269
|
+
child_node_id: r.child_node_id,
|
|
5270
|
+
alias: r.child_name,
|
|
5271
|
+
lifecycle_state: r.lifecycle_state ?? "active",
|
|
5272
|
+
}));
|
|
5273
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: children.length, children }) }] };
|
|
5274
|
+
},
|
|
5275
|
+
);
|
|
5276
|
+
|
|
2291
5277
|
// ── RFC-028 P1 — Provider & Model Registry + connectivity probe ──
|
|
2292
5278
|
// Background timers (probe GC + sweeper) idempotent; safe to call
|
|
2293
5279
|
// per registerTools.
|
|
@@ -2516,6 +5502,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2516
5502
|
const willReplaceModels = patch.models !== undefined;
|
|
2517
5503
|
const newModelIds: string[] = [];
|
|
2518
5504
|
|
|
5505
|
+
// No-op early return (通信牛 nit): all patch fields matched the
|
|
5506
|
+
// existing row values + no models supplied → no real change to
|
|
5507
|
+
// persist. Returning here means we don't write an empty-diff
|
|
5508
|
+
// audit row (audit noise). Audit_log INSERT must only fire when
|
|
5509
|
+
// something actually changed.
|
|
5510
|
+
if (sets.length === 0 && !willReplaceModels) {
|
|
5511
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id, no_changes: true, hint: "patch fields matched existing row, no-op" }) }] };
|
|
5512
|
+
}
|
|
5513
|
+
|
|
2519
5514
|
try {
|
|
2520
5515
|
db.exec("BEGIN");
|
|
2521
5516
|
if (sets.length > 0) {
|
|
@@ -2554,10 +5549,6 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2554
5549
|
throw e;
|
|
2555
5550
|
}
|
|
2556
5551
|
|
|
2557
|
-
if (Object.keys(diff).length === 0 && !willReplaceModels) {
|
|
2558
|
-
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id, no_changes: true, hint: "patch fields matched existing row, no-op" }) }] };
|
|
2559
|
-
}
|
|
2560
|
-
|
|
2561
5552
|
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
2562
5553
|
ok: true, provider_id,
|
|
2563
5554
|
fields_changed: Object.keys(diff),
|
|
@@ -2750,6 +5741,111 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2750
5741
|
}
|
|
2751
5742
|
},
|
|
2752
5743
|
);
|
|
5744
|
+
|
|
5745
|
+
// ── 需求池 / 任务看板(Agent 读写任务,例如把 GitHub issue 同步成任务) ──
|
|
5746
|
+
// 全部走 REST 同一个处理函数(requirements.ts handleRequirementsRequest):权限、校验、谁做的记录只有一份。
|
|
5747
|
+
// 节点令牌只在它绑定的网络里读 / 建 / 改 / 勾子任务 / upsert / 读项目;删除和管理项目只给人。
|
|
5748
|
+
const requirementsCall = async (method: string, path: string, clientNetId?: string | null, body?: Record<string, unknown>) => {
|
|
5749
|
+
const scope = resolveRestNetworkScope(enforceNetworkId ? null : (clientNetId ?? null), mcpAuthCtx, false);
|
|
5750
|
+
if (scope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "access_denied", message: scope.denied }) }] };
|
|
5751
|
+
const url = new URL(`http://mcp.internal${path}`);
|
|
5752
|
+
const req = new Request(url, { method, headers: { "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
|
|
5753
|
+
const res = await handleRequirementsRequest({
|
|
5754
|
+
req, url,
|
|
5755
|
+
auth: mcpAuthCtx ? { ...mcpAuthCtx, username: "", tokenId: callerTokenId ?? null, tokenName: callerTokenIsNetwork && callerAlias ? `node:${callerAlias}` : null } : null,
|
|
5756
|
+
isAdmin: false,
|
|
5757
|
+
isNodeToken: callerTokenIsNetwork,
|
|
5758
|
+
scope,
|
|
5759
|
+
});
|
|
5760
|
+
const data = res ? await res.json() : { ok: false, error: "not_found" };
|
|
5761
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(res && !res.ok && data && typeof data === "object" ? { ...data, status: res.status } : data) }] };
|
|
5762
|
+
};
|
|
5763
|
+
const reqPerson = z.object({ kind: z.enum(["user", "node"]), id: z.string().min(1).max(200) });
|
|
5764
|
+
const reqChecklistItem = z.object({ id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/).optional(), text: z.string().min(1).max(500), done: z.boolean().optional() });
|
|
5765
|
+
const reqFields = {
|
|
5766
|
+
tags: z.array(z.string()).max(10).optional().describe("Task labels; each 1–20 Unicode characters. Omit to preserve, [] to clear."),
|
|
5767
|
+
name: z.string().min(1).max(80).optional(),
|
|
5768
|
+
priority: z.enum(["high", "normal", "low", "lowest"]).optional().describe("high = P0 最高, normal = P1 普通, low = P2 低, lowest = P3 极低"),
|
|
5769
|
+
column: z.enum(["pool", "doing", "done"]).optional(),
|
|
5770
|
+
due: z.string().max(40).optional().describe("YYYY-MM-DD (all day) or ISO 8601 with Z / ±HH:MM (stored as UTC seconds); \"\" clears"),
|
|
5771
|
+
start: z.string().max(40).optional().describe("start date (Gantt): same shapes as due; \"\" clears"),
|
|
5772
|
+
description: z.string().max(20_000).optional().describe("markdown"),
|
|
5773
|
+
checklist: z.array(reqChecklistItem).max(100).optional().describe("replaces the whole list"),
|
|
5774
|
+
project_id: z.string().max(200).nullable().optional(),
|
|
5775
|
+
owner: reqPerson.nullable().optional().describe("负责人: must be {kind:'user'}"),
|
|
5776
|
+
agent_owner: reqPerson.nullable().optional().describe("负责 Agent: must be {kind:'node'}"),
|
|
5777
|
+
participants: z.array(reqPerson).max(100).optional(),
|
|
5778
|
+
external_url: z.string().max(500).nullable().optional(),
|
|
5779
|
+
parent_id: z.string().regex(/^[A-Za-z0-9_-]{1,200}$/).nullable().optional().describe("parent requirement (same network, no cycles, ≤ 5 levels); null detaches"),
|
|
5780
|
+
};
|
|
5781
|
+
const pick = (args: Record<string, unknown>, keys: string[]) => Object.fromEntries(keys.filter(k => args[k] !== undefined).map(k => [k, args[k]]));
|
|
5782
|
+
const REQ_WRITE_KEYS = [...Object.keys(reqFields), "external_ref", "archived"];
|
|
5783
|
+
|
|
5784
|
+
server.tool(
|
|
5785
|
+
"requirements_list",
|
|
5786
|
+
"List requirement tasks in your network. Filters: status (pool/doing/done), project_id ('none' = no project), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), updated_since (ISO), external_ref, parent_id / top_level, include_archived. Newest first, max 500. Each task carries children {total, done}.",
|
|
5787
|
+
{
|
|
5788
|
+
network_id: z.string().max(200).optional(),
|
|
5789
|
+
status: z.enum(["pool", "doing", "done"]).optional(),
|
|
5790
|
+
project_id: z.string().max(200).optional(),
|
|
5791
|
+
owner: z.string().max(210).optional(),
|
|
5792
|
+
agent_owner: z.string().max(210).optional(),
|
|
5793
|
+
updated_since: z.string().max(40).optional(),
|
|
5794
|
+
external_ref: z.string().max(200).optional(),
|
|
5795
|
+
include_archived: z.boolean().optional(),
|
|
5796
|
+
parent_id: z.string().max(200).optional().describe("children of this requirement ('none' = top level)"),
|
|
5797
|
+
top_level: z.boolean().optional(),
|
|
5798
|
+
},
|
|
5799
|
+
async (args) => {
|
|
5800
|
+
const q = new URLSearchParams();
|
|
5801
|
+
for (const k of ["status", "project_id", "owner", "agent_owner", "updated_since", "external_ref", "parent_id"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
|
|
5802
|
+
if (args.include_archived) q.set("include_archived", "1");
|
|
5803
|
+
if (args.top_level) q.set("top_level", "1");
|
|
5804
|
+
return requirementsCall("GET", `/api/requirements${q.size ? `?${q}` : ""}`, args.network_id);
|
|
5805
|
+
},
|
|
5806
|
+
);
|
|
5807
|
+
|
|
5808
|
+
server.tool(
|
|
5809
|
+
"requirements_get",
|
|
5810
|
+
"Get one requirement task by id (with description, checklist, owners, project, external_ref).",
|
|
5811
|
+
{ id: z.string().min(1).max(200), network_id: z.string().max(200).optional() },
|
|
5812
|
+
async ({ id, network_id }) => requirementsCall("GET", `/api/requirements/${encodeURIComponent(id)}`, network_id),
|
|
5813
|
+
);
|
|
5814
|
+
|
|
5815
|
+
server.tool(
|
|
5816
|
+
"requirements_create",
|
|
5817
|
+
"Create a requirement task. name is required. A duplicate external_ref in the network returns 409 external_ref_exists with existing_id — use requirements_upsert_by_external_ref for syncing.",
|
|
5818
|
+
{ network_id: z.string().max(200).optional(), ...reqFields, name: z.string().min(1).max(80), external_ref: z.string().max(200).optional() },
|
|
5819
|
+
async (args) => requirementsCall("POST", "/api/requirements", args.network_id, pick(args, REQ_WRITE_KEYS)),
|
|
5820
|
+
);
|
|
5821
|
+
|
|
5822
|
+
server.tool(
|
|
5823
|
+
"requirements_update",
|
|
5824
|
+
"Patch a requirement task; omitted fields keep their value. archived=true hides it from the default list (agents cannot delete).",
|
|
5825
|
+
{ id: z.string().min(1).max(200), network_id: z.string().max(200).optional(), ...reqFields, external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
|
|
5826
|
+
async (args) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(args.id)}`, args.network_id, pick(args, REQ_WRITE_KEYS)),
|
|
5827
|
+
);
|
|
5828
|
+
|
|
5829
|
+
server.tool(
|
|
5830
|
+
"requirements_checklist_toggle",
|
|
5831
|
+
"Set one checklist item done / not done (only that item is written, so concurrent edits of other items are kept).",
|
|
5832
|
+
{ id: z.string().min(1).max(200), item_id: z.string().regex(/^[A-Za-z0-9_-]{1,40}$/), done: z.boolean(), network_id: z.string().max(200).optional() },
|
|
5833
|
+
async ({ id, item_id, done, network_id }) => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(id)}/checklist/${encodeURIComponent(item_id)}`, network_id, { done }),
|
|
5834
|
+
);
|
|
5835
|
+
|
|
5836
|
+
server.tool(
|
|
5837
|
+
"requirements_upsert_by_external_ref",
|
|
5838
|
+
"Idempotent sync: create the task for external_ref (e.g. github:owner/repo#123) or, if it already exists in the network, patch it (omitted fields — including status — are kept). Returns { requirement, created }.",
|
|
5839
|
+
{ network_id: z.string().max(200).optional(), external_ref: z.string().min(1).max(200), ...reqFields, archived: z.boolean().optional() },
|
|
5840
|
+
async (args) => requirementsCall("POST", "/api/requirements/upsert", args.network_id, pick(args, REQ_WRITE_KEYS)),
|
|
5841
|
+
);
|
|
5842
|
+
|
|
5843
|
+
server.tool(
|
|
5844
|
+
"projects_list",
|
|
5845
|
+
"List requirement projects in your network (id, name, color, sort, archived).",
|
|
5846
|
+
{ network_id: z.string().max(200).optional() },
|
|
5847
|
+
async ({ network_id }) => requirementsCall("GET", "/api/requirements/projects", network_id),
|
|
5848
|
+
);
|
|
2753
5849
|
}
|
|
2754
5850
|
|
|
2755
5851
|
// ────────────────────────────────────────────────────────────────────
|
|
@@ -2767,6 +5863,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2767
5863
|
export interface UpsertNodeWithSec1GuardInput {
|
|
2768
5864
|
node_id: string;
|
|
2769
5865
|
callerNetworkId: string | null;
|
|
5866
|
+
callerUserId?: string | null;
|
|
5867
|
+
callerTokenId?: string | null;
|
|
2770
5868
|
node_name?: string | null;
|
|
2771
5869
|
alias?: string | null;
|
|
2772
5870
|
runtime?: string | null;
|
|
@@ -2779,19 +5877,60 @@ export interface UpsertNodeWithSec1GuardInput {
|
|
|
2779
5877
|
}
|
|
2780
5878
|
export type UpsertNodeOutcome =
|
|
2781
5879
|
| { result: "inserted" | "updated"; node_id: string }
|
|
2782
|
-
| { result: "refused"; reason: "cross_network"; existingNet: string | null; callerNet: string | null }
|
|
5880
|
+
| { result: "refused"; reason: "cross_network" | "token_node_mismatch" | "owner_mismatch"; existingNet: string | null; callerNet: string | null }
|
|
2783
5881
|
| { result: "skipped"; reason: "missing_node_id" };
|
|
2784
5882
|
|
|
2785
5883
|
const _norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x);
|
|
2786
5884
|
|
|
5885
|
+
/**
|
|
5886
|
+
* A capability that changes peer delivery semantics is trusted only when the
|
|
5887
|
+
* authenticating ntok is immutably bound to the exact reported node. Legacy
|
|
5888
|
+
* alias-only tokens may still heartbeat, but cannot opt a node into #698.
|
|
5889
|
+
*/
|
|
5890
|
+
export function trustedConfigSnapshotForNode(
|
|
5891
|
+
snapshot: unknown | null | undefined,
|
|
5892
|
+
callerTokenId: string | null | undefined,
|
|
5893
|
+
nodeId: string,
|
|
5894
|
+
): unknown | null | undefined {
|
|
5895
|
+
if (!snapshot || typeof snapshot !== "object") return snapshot;
|
|
5896
|
+
const copy = { ...(snapshot as Record<string, unknown>) };
|
|
5897
|
+
const token = callerTokenId
|
|
5898
|
+
? db.get<{ bound_node_id: string | null }>(
|
|
5899
|
+
"SELECT bound_node_id FROM api_tokens WHERE token_id = ?1",
|
|
5900
|
+
callerTokenId,
|
|
5901
|
+
)
|
|
5902
|
+
: null;
|
|
5903
|
+
if (token?.bound_node_id !== nodeId) delete copy.peer_reply_inbox_capable;
|
|
5904
|
+
return copy;
|
|
5905
|
+
}
|
|
5906
|
+
|
|
2787
5907
|
export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): UpsertNodeOutcome {
|
|
2788
5908
|
if (!input.node_id) return { result: "skipped", reason: "missing_node_id" };
|
|
2789
|
-
const existing = db.get<{ network_id: string | null }>(
|
|
2790
|
-
"SELECT network_id FROM nodes WHERE node_id = ?1",
|
|
5909
|
+
const existing = db.get<{ network_id: string | null; owner_user_id: string | null }>(
|
|
5910
|
+
"SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1",
|
|
2791
5911
|
input.node_id,
|
|
2792
5912
|
);
|
|
2793
5913
|
const callerNet = input.callerNetworkId;
|
|
2794
5914
|
|
|
5915
|
+
// RFC-036 — when a token was minted with a node_id binding, a heartbeat may
|
|
5916
|
+
// report only that exact node. Legacy unbound ntok rows remain compatible,
|
|
5917
|
+
// but they can never consume owner-gated schedule intents.
|
|
5918
|
+
if (input.callerTokenId) {
|
|
5919
|
+
const token = db.get<{ bound_node_id: string | null; user_id: string; network_id: string | null }>(
|
|
5920
|
+
"SELECT bound_node_id, user_id, network_id FROM api_tokens WHERE token_id = ?1",
|
|
5921
|
+
input.callerTokenId,
|
|
5922
|
+
);
|
|
5923
|
+
if (token?.bound_node_id && token.bound_node_id !== input.node_id) {
|
|
5924
|
+
return { result: "refused", reason: "token_node_mismatch", existingNet: existing?.network_id ?? null, callerNet };
|
|
5925
|
+
}
|
|
5926
|
+
if (token?.network_id && _norm(token.network_id) !== _norm(callerNet)) {
|
|
5927
|
+
return { result: "refused", reason: "cross_network", existingNet: existing?.network_id ?? null, callerNet };
|
|
5928
|
+
}
|
|
5929
|
+
}
|
|
5930
|
+
if (existing?.owner_user_id && input.callerUserId !== existing.owner_user_id) {
|
|
5931
|
+
return { result: "refused", reason: "owner_mismatch", existingNet: existing.network_id, callerNet };
|
|
5932
|
+
}
|
|
5933
|
+
|
|
2795
5934
|
// Legacy / first-write paths: row missing OR network_id NULL → claim.
|
|
2796
5935
|
const isLegacy = !existing
|
|
2797
5936
|
|| existing.network_id === null
|
|
@@ -2837,17 +5976,29 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
|
|
|
2837
5976
|
callerNet ?? null,
|
|
2838
5977
|
],
|
|
2839
5978
|
);
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
//
|
|
2847
|
-
//
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
5979
|
+
const trustedSnapshot = trustedConfigSnapshotForNode(
|
|
5980
|
+
input.config_snapshot,
|
|
5981
|
+
input.callerTokenId,
|
|
5982
|
+
input.node_id,
|
|
5983
|
+
);
|
|
5984
|
+
if (trustedSnapshot) {
|
|
5985
|
+
// RFC-026 §9.3 / #338 PR2+PR3 — promote daemon self-declare fields
|
|
5986
|
+
// to first-class indexable columns alongside the snapshot blob.
|
|
5987
|
+
// The snapshot stays the source of truth for non-list reads; the
|
|
5988
|
+
// columns exist so `list_host_supervisors` doesn't JSON.parse on
|
|
5989
|
+
// every call. typeof-narrow per
|
|
5990
|
+
// per team rule (typeof-narrow extracted JSON fields at the boundary) — zod narrowed but
|
|
5991
|
+
// input.config_snapshot is typed `unknown` here.
|
|
5992
|
+
//
|
|
5993
|
+
// PR3 nit ①: read from nested `daemon_capabilities.*` (canonical
|
|
5994
|
+
// per RFC §9.3 + matches existing hub create_node reads at
|
|
5995
|
+
// tools.ts:2010/2075). PR2 ate from top-level keys, which the
|
|
5996
|
+
// hub create_node path never read → max_concurrent_children
|
|
5997
|
+
// backpressure was dead config + allowlist enforcement bypassed.
|
|
5998
|
+
const snap = trustedSnapshot as Record<string, unknown> | null;
|
|
5999
|
+
const caps = (snap?.daemon_capabilities ?? null) as Record<string, unknown> | null;
|
|
6000
|
+
const runtimesRaw = caps?.runtimes_supported;
|
|
6001
|
+
const allowedRaw = caps?.allowed_secret_keys;
|
|
2851
6002
|
const runtimesJson = Array.isArray(runtimesRaw) && runtimesRaw.every(s => typeof s === "string")
|
|
2852
6003
|
? JSON.stringify(runtimesRaw)
|
|
2853
6004
|
: null;
|
|
@@ -2856,7 +6007,7 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
|
|
|
2856
6007
|
: null;
|
|
2857
6008
|
db.run(
|
|
2858
6009
|
`UPDATE nodes SET config_snapshot = ?1, runtimes_supported = ?2, allowed_secret_keys = ?3 WHERE node_id = ?4`,
|
|
2859
|
-
[JSON.stringify(
|
|
6010
|
+
[JSON.stringify(trustedSnapshot), runtimesJson, allowedJson, input.node_id],
|
|
2860
6011
|
);
|
|
2861
6012
|
// RFC-024 — finalize any pending/restarting update whose target
|
|
2862
6013
|
// patch is now reflected in the snapshot. Closes the
|
|
@@ -2866,7 +6017,7 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
|
|
|
2866
6017
|
// update_id to call ack_config_update(applied) itself. Hub does
|
|
2867
6018
|
// it on the new child's behalf by content-matching the patch
|
|
2868
6019
|
// against the reported snapshot.
|
|
2869
|
-
finalizePendingMatchingUpdates(input.node_id,
|
|
6020
|
+
finalizePendingMatchingUpdates(input.node_id, trustedSnapshot);
|
|
2870
6021
|
}
|
|
2871
6022
|
// RFC-026 §2.5 step 4 — on every register/report_status, opportunistically
|
|
2872
6023
|
// close out any pending create_node request whose child_name matches
|
|
@@ -2926,6 +6077,15 @@ export function finalizePendingMatchingUpdates(
|
|
|
2926
6077
|
|
|
2927
6078
|
const snapModel: string | null | undefined = snapshot?.model;
|
|
2928
6079
|
const snapFlags: Record<string, unknown> = (snapshot?.flags && typeof snapshot.flags === "object") ? snapshot.flags : {};
|
|
6080
|
+
// #260 P5 — snapshot.channels is the (sorted, bare-type) channel set
|
|
6081
|
+
// the node currently has forked. agent-node's buildConfigSnapshot
|
|
6082
|
+
// always emits it (even as []), so the "absent field" case here means
|
|
6083
|
+
// an older pre-#260-P5 agent-node — for those, don't finalize a
|
|
6084
|
+
// channels-carrying patch (would false-positive on the OTHER fields).
|
|
6085
|
+
const snapChannelsPresent = Array.isArray(snapshot?.channels);
|
|
6086
|
+
const snapChannels: string[] = snapChannelsPresent
|
|
6087
|
+
? (snapshot.channels as unknown[]).filter((c): c is string => typeof c === "string")
|
|
6088
|
+
: [];
|
|
2929
6089
|
|
|
2930
6090
|
const finalizedIds: string[] = [];
|
|
2931
6091
|
|
|
@@ -2950,6 +6110,35 @@ export function finalizePendingMatchingUpdates(
|
|
|
2950
6110
|
if (snapFlags[k] !== v) { matches = false; break; }
|
|
2951
6111
|
}
|
|
2952
6112
|
}
|
|
6113
|
+
// #260 P5 — channels field content-match. Without this the
|
|
6114
|
+
// node's very first startup report_status matches (patch.flags
|
|
6115
|
+
// is `{}` for a channels-only update, so the flags loop is
|
|
6116
|
+
// trivially satisfied) and hub prematurely marks the update
|
|
6117
|
+
// applied, deleting the pending row + bumping config_revision
|
|
6118
|
+
// BEFORE the doorbell reaches the child. Codex catch on PR #411.
|
|
6119
|
+
//
|
|
6120
|
+
// Set-equality is enough because the patch side comes from
|
|
6121
|
+
// narrowChannelsPatch → already deduped + case-folded + in
|
|
6122
|
+
// EDITABLE_CHANNELS iteration order; the snapshot side comes
|
|
6123
|
+
// from buildConfigSnapshot which mirrors the same shape.
|
|
6124
|
+
if (matches && Array.isArray(patch.channels)) {
|
|
6125
|
+
if (!snapChannelsPresent) {
|
|
6126
|
+
// Pre-#260-P5 agent-node — no channels field in snapshot,
|
|
6127
|
+
// so we can't prove the apply landed. Skip finalize; hub's
|
|
6128
|
+
// F-B reaper still supersedes this eventually.
|
|
6129
|
+
matches = false;
|
|
6130
|
+
} else {
|
|
6131
|
+
const wanted = new Set<string>(patch.channels);
|
|
6132
|
+
const have = new Set<string>(snapChannels);
|
|
6133
|
+
if (wanted.size !== have.size) {
|
|
6134
|
+
matches = false;
|
|
6135
|
+
} else {
|
|
6136
|
+
for (const w of wanted) {
|
|
6137
|
+
if (!have.has(w)) { matches = false; break; }
|
|
6138
|
+
}
|
|
6139
|
+
}
|
|
6140
|
+
}
|
|
6141
|
+
}
|
|
2953
6142
|
}
|
|
2954
6143
|
|
|
2955
6144
|
if (!matches) continue;
|