@sleep2agi/commhub-server 0.9.0-preview.110 → 0.9.0-preview.112
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/package.json +1 -1
- package/src/auth.ts +18 -14
- package/src/binding-generation-http.test.ts +121 -0
- package/src/create-node.ts +74 -23
- package/src/daemon-token-owner-http.test.ts +231 -0
- package/src/db.ts +3 -0
- package/src/node-caller-identity-http.test.ts +714 -0
- package/src/node-daemon-bindings.test.ts +1 -1
- package/src/node-lifecycle-read-http.test.ts +2 -1
- package/src/node-lifecycle-read.ts +1 -0
- package/src/node-permissions.ts +45 -12
- package/src/node-token-ownership.ts +36 -0
- package/src/project-dir-warn-clear-http.test.ts +132 -0
- package/src/requirement-events-http.test.ts +12 -1
- package/src/requirements-last-event-http.test.ts +6 -1
- package/src/requirements.ts +3 -7
- package/src/rest-identity.ts +29 -4
- package/src/scheduled-tasks.ts +5 -3
- package/src/server.ts +63 -21
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -0
- package/src/tools.ts +91 -29
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { SESSION_TASK_PREVIEW_MAX, TASK_CONTENT_MAX, sessionTaskPreview } from "./task-content-limit.js";
|
|
5
|
+
|
|
6
|
+
// Board #668 — the node reports the whole task so the hub can match it
|
|
7
|
+
// against tasks.content. Both sides must agree on the cap.
|
|
8
|
+
const HUB_PATH = join(import.meta.dir, "task-content-limit.ts");
|
|
9
|
+
const NODE_PATH = join(import.meta.dir, "..", "..", "..", "agent-node", "src", "shared", "task-content-limit.ts");
|
|
10
|
+
|
|
11
|
+
describe("#668 — hub/node task-content-limit drift guard", () => {
|
|
12
|
+
test("byte-identical source files", () => {
|
|
13
|
+
expect(readFileSync(NODE_PATH, "utf-8")).toBe(readFileSync(HUB_PATH, "utf-8"));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("constants equal at runtime", async () => {
|
|
17
|
+
const node = await import(NODE_PATH);
|
|
18
|
+
expect(node.TASK_CONTENT_MAX).toBe(TASK_CONTENT_MAX);
|
|
19
|
+
expect(node.SESSION_TASK_PREVIEW_MAX).toBe(SESSION_TASK_PREVIEW_MAX);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("preview is the first 200 chars", () => {
|
|
23
|
+
expect(SESSION_TASK_PREVIEW_MAX).toBe(200);
|
|
24
|
+
const long = "字".repeat(TASK_CONTENT_MAX);
|
|
25
|
+
expect(sessionTaskPreview(long)).toBe(long.slice(0, 200));
|
|
26
|
+
expect(sessionTaskPreview("short")).toBe("short");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Board #668 — task text limits shared by hub and node.
|
|
2
|
+
//
|
|
3
|
+
// This file is byte-identical in server/src/shared/task-content-limit.ts and
|
|
4
|
+
// agent-node/src/shared/task-content-limit.ts (task-content-limit-drift.test.ts
|
|
5
|
+
// fails the build on any difference).
|
|
6
|
+
//
|
|
7
|
+
// TASK_CONTENT_MAX is the cap every dispatch entry (send_task, REST /api/task,
|
|
8
|
+
// scheduled tasks) and report_status.task accept. The node reports the whole
|
|
9
|
+
// task text so the hub can match it against tasks.content and write
|
|
10
|
+
// started_at. If the dispatch cap grew without the node and report_status
|
|
11
|
+
// following, long tasks would silently stop matching again.
|
|
12
|
+
//
|
|
13
|
+
// SESSION_TASK_PREVIEW_MAX is what the hub keeps in sessions.task. That column
|
|
14
|
+
// is readable by get_all_status, the full /api/status and members who may see
|
|
15
|
+
// the agent but not its conversations, so only a short preview is stored —
|
|
16
|
+
// the full text is used for matching, never persisted on the session row.
|
|
17
|
+
|
|
18
|
+
export const TASK_CONTENT_MAX = 10_000;
|
|
19
|
+
|
|
20
|
+
export const SESSION_TASK_PREVIEW_MAX = 200;
|
|
21
|
+
|
|
22
|
+
/** The preview stored in sessions.task — same cut as the dispatch preview. */
|
|
23
|
+
export function sessionTaskPreview(task: string): string {
|
|
24
|
+
return task.slice(0, SESSION_TASK_PREVIEW_MAX);
|
|
25
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { requestAdopt, getAdopt, ackAdopt, unadopt, resolveManagedDaemon, activeBinding, createdDaemon } from "./node-daemon-bindings.js";
|
|
3
3
|
import { runtimeReadinessSchema } from "./runtime-readiness.js";
|
|
4
|
+
import { lockNodeAlias } from "./node-token-ownership.js";
|
|
4
5
|
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
5
6
|
import { z } from "zod/v4";
|
|
6
7
|
import { nodeHealthSchema, normalizeNodeHealth, recordNodeHealth } from "./node-health-store.js";
|
|
@@ -9,6 +10,8 @@ import { noteModelAuthHealth } from "./model-auth-notify.js";
|
|
|
9
10
|
import { numberedAliasInClause, parseAliasFilter } from "./alias-filter.js";
|
|
10
11
|
import { createHash } from "node:crypto";
|
|
11
12
|
import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
|
|
13
|
+
import { sessionTaskOnDispatch } from "./session-task-on-dispatch.js";
|
|
14
|
+
import { TASK_CONTENT_MAX, sessionTaskPreview } from "./shared/task-content-limit.js";
|
|
12
15
|
import { getSSEStats, hasSubscribers, hasUserSubscribers, pushEvent, pushNetworkObserverEvent, pushUserEvent } from "./push.js";
|
|
13
16
|
import { deferOfflineIfAnotherCopyConnected } from "./node-identity-conflict.js";
|
|
14
17
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
@@ -264,7 +267,7 @@ function guardNodePermissionTools(server: McpServer, tokenId: string, networkId:
|
|
|
264
267
|
}
|
|
265
268
|
}
|
|
266
269
|
|
|
267
|
-
export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null, listing: { includeProtocol?: boolean } = {}) {
|
|
270
|
+
export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null, listing: { includeProtocol?: boolean } = {}, callerIdentityRejected = false) {
|
|
268
271
|
// 多用户 Agent 权限:用户令牌调用者在哪些网络里是受限成员(只看授权 Agent)。
|
|
269
272
|
// 网络令牌不会走到这里 —— 受限成员的网络令牌在 resolveToken 就被拒了。
|
|
270
273
|
const restrictedNets = enforceUserId && !callerTokenIsNetwork && !enforceNetworkId ? restrictedNetworkIds(enforceUserId) : [];
|
|
@@ -275,9 +278,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
275
278
|
// an agent's send_task call always claimed from='hub' and peer agents
|
|
276
279
|
// couldn't tell who actually asked them. Network-bound node tokens are an
|
|
277
280
|
// identity boundary: they must not spoof another node via from_session.
|
|
278
|
-
const defaultFrom = (clientFrom?: string) =>
|
|
281
|
+
const defaultFrom = (clientFrom?: string) => {
|
|
282
|
+
if (callerTokenIsNetwork && callerIdentityRejected) return "";
|
|
283
|
+
return (callerTokenIsNetwork && callerAlias) ? callerAlias : (clientFrom || callerAlias || "hub");
|
|
284
|
+
};
|
|
279
285
|
const fromIdentityMismatchReply = (clientFrom?: string) => {
|
|
280
286
|
const requestedFrom = clientFrom?.trim();
|
|
287
|
+
if (callerTokenIsNetwork && callerIdentityRejected) {
|
|
288
|
+
return {
|
|
289
|
+
content: [{
|
|
290
|
+
type: "text" as const,
|
|
291
|
+
text: JSON.stringify({
|
|
292
|
+
ok: false,
|
|
293
|
+
error: "from_session_identity_mismatch",
|
|
294
|
+
message: "network token from_session does not match token-bound node alias",
|
|
295
|
+
token_alias: "",
|
|
296
|
+
requested_from_session: requestedFrom ?? "",
|
|
297
|
+
}),
|
|
298
|
+
}],
|
|
299
|
+
};
|
|
300
|
+
}
|
|
281
301
|
if (!callerTokenIsNetwork || !callerAlias || !requestedFrom || requestedFrom === callerAlias) return null;
|
|
282
302
|
return {
|
|
283
303
|
content: [{
|
|
@@ -680,7 +700,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
680
700
|
resume_id: z.string().min(1).max(200).describe("Claude Code session UUID (unique per session)"),
|
|
681
701
|
alias: z.string().min(1).max(200).describe("Human-readable session name for dispatching (e.g. 指挥室/知识哥)"),
|
|
682
702
|
status: z.enum(["working", "idle", "blocked", "error", "waiting_input", "offline"]),
|
|
683
|
-
task: z.string().max(
|
|
703
|
+
task: z.string().max(TASK_CONTENT_MAX).optional().describe("Current task description (the hub stores only the first 200 chars on the session row; the full text is matched against tasks.content)"),
|
|
684
704
|
output: z.string().max(50000).optional().describe("Recent output (max 4000 chars stored)"),
|
|
685
705
|
score: z.number().min(0).max(10).optional().describe("Self-score 1-10"),
|
|
686
706
|
progress: z.number().min(0).max(100).optional().describe("Progress 0-100"),
|
|
@@ -913,6 +933,21 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
913
933
|
if (!canWrite(effectiveNetId)) {
|
|
914
934
|
return writeDeniedReply(effectiveNetId);
|
|
915
935
|
}
|
|
936
|
+
// A rejected network token must not heartbeat, rename its token, or open a session.
|
|
937
|
+
if (callerIdentityRejected) {
|
|
938
|
+
return {
|
|
939
|
+
content: [{
|
|
940
|
+
type: "text" as const,
|
|
941
|
+
text: JSON.stringify({
|
|
942
|
+
ok: false,
|
|
943
|
+
error: "alias_identity_mismatch",
|
|
944
|
+
message: "report_status alias does not match the token-bound node alias; use anet node rename to change identity",
|
|
945
|
+
token_alias: "",
|
|
946
|
+
reported_alias: alias,
|
|
947
|
+
}),
|
|
948
|
+
}],
|
|
949
|
+
};
|
|
950
|
+
}
|
|
916
951
|
const canonical = resolveCanonicalAlias(sessionNetId, alias);
|
|
917
952
|
let effectiveAlias = canonical.alias;
|
|
918
953
|
if (canonical.renamed) {
|
|
@@ -966,11 +1001,6 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
966
1001
|
};
|
|
967
1002
|
}
|
|
968
1003
|
console.log(`[${ts()}] ${effectiveAlias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
|
|
969
|
-
if (callerTokenIsNetwork && callerTokenId) {
|
|
970
|
-
try {
|
|
971
|
-
db.run("UPDATE api_tokens SET name = ?1 WHERE token_id = ?2", [`node:${effectiveAlias}`, callerTokenId]);
|
|
972
|
-
} catch {}
|
|
973
|
-
}
|
|
974
1004
|
// #507 — a second copy of this node (same token + alias) may still be connected;
|
|
975
1005
|
// its sibling's shutdown offline must not take the node offline under it.
|
|
976
1006
|
const offlineDeferred = deferOfflineIfAnotherCopyConnected(effectiveAlias, sessionNetId, status, resume_id);
|
|
@@ -1048,6 +1078,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1048
1078
|
) ?? null;
|
|
1049
1079
|
const keep = <T,>(fresh: T | null | undefined, column: string): T | null =>
|
|
1050
1080
|
(fresh ?? (handover?.[column] as T | null | undefined) ?? null);
|
|
1081
|
+
// #668 — the node reports the whole task so the tasks UPDATE below can match
|
|
1082
|
+
// tasks.content. sessions.task is readable far wider than the task row
|
|
1083
|
+
// (get_all_status, full /api/status, members who see the agent but not its
|
|
1084
|
+
// conversations), so only the dispatch-sized preview is stored here.
|
|
1085
|
+
const sessionTask = task == null ? null : sessionTaskPreview(task); // board668-session-preview
|
|
1051
1086
|
db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [effectiveAlias, resume_id, sessionNetId]);
|
|
1052
1087
|
db.run(
|
|
1053
1088
|
`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)
|
|
@@ -1079,7 +1114,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1079
1114
|
external_schedules = COALESCE(?34, sessions.external_schedules),
|
|
1080
1115
|
peer_reply_inbox_capable = ?35,
|
|
1081
1116
|
last_seen_at = datetime('now'), updated_at = datetime('now')`,
|
|
1082
|
-
[resume_id, effectiveAlias, tmux ?? null, srv ?? null, hostIp, hostHostname, ag ?? null, pd ?? null, ver ?? null, storedStatus,
|
|
1117
|
+
[resume_id, effectiveAlias, tmux ?? null, srv ?? null, hostIp, hostHostname, ag ?? null, pd ?? null, ver ?? null, storedStatus, sessionTask, 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]
|
|
1083
1118
|
);
|
|
1084
1119
|
if (handover) {
|
|
1085
1120
|
// 换 resume_id 那条路径上 ON CONFLICT 不会触发;INSERT 完再把没上报(仍为 NULL)的
|
|
@@ -1154,6 +1189,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1154
1189
|
[uuidv4(), sessionNetId, resume_id, effectiveAlias, hostHostname, hostIp, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processRssMb, processCpuPct, processUptimeSeconds, processInFlightCount]
|
|
1155
1190
|
);
|
|
1156
1191
|
}
|
|
1192
|
+
if (callerTokenIsNetwork && callerTokenId) {
|
|
1193
|
+
try {
|
|
1194
|
+
db.run("UPDATE api_tokens SET name = ?1 WHERE token_id = ?2", [`node:${effectiveAlias}`, callerTokenId]);
|
|
1195
|
+
} catch {}
|
|
1196
|
+
}
|
|
1157
1197
|
});
|
|
1158
1198
|
// #448 — only the node token bound to this alias may speak for its health
|
|
1159
1199
|
// (same rule as the *_capable flags above).
|
|
@@ -1196,10 +1236,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1196
1236
|
} catch {}
|
|
1197
1237
|
}
|
|
1198
1238
|
|
|
1199
|
-
//
|
|
1200
|
-
//
|
|
1201
|
-
//
|
|
1202
|
-
// exact same code path. See helper below registerTools.
|
|
1239
|
+
// A copied node_id must not relabel the row. The heartbeat itself still
|
|
1240
|
+
// succeeds: only a token rejected as impersonation fails the whole call.
|
|
1241
|
+
// identity_mismatch must not leave this session pointing at the other row.
|
|
1203
1242
|
if (node_id) {
|
|
1204
1243
|
try {
|
|
1205
1244
|
const nodeRuntime = ag?.includes(":") ? ag.split(":")[1] + "-sdk" : ag ?? null;
|
|
@@ -1218,8 +1257,6 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1218
1257
|
hostname: hn ?? null,
|
|
1219
1258
|
config_snapshot: cfgSnap ?? null,
|
|
1220
1259
|
});
|
|
1221
|
-
// The session row above already took node_id; a refused identity claim
|
|
1222
|
-
// must not leave this session pointing at another node's row.
|
|
1223
1260
|
if (upserted.result === "refused" && upserted.reason === "identity_mismatch") {
|
|
1224
1261
|
db.run("UPDATE sessions SET node_id = NULL WHERE resume_id = ?1 AND node_id = ?2", [resume_id, node_id]);
|
|
1225
1262
|
}
|
|
@@ -1739,7 +1776,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1739
1776
|
"Dispatch a task to a session's inbox (by alias). The reply carries queue_ahead (open tasks already on the target), target_busy, est_wait_minutes (rough, null = no history) and, when the queue is long, a warning: queued, never refused — consider an idle node.",
|
|
1740
1777
|
{
|
|
1741
1778
|
alias: z.string().min(1).max(200).describe("Target session alias"),
|
|
1742
|
-
task: z.string().min(1).max(
|
|
1779
|
+
task: z.string().min(1).max(TASK_CONTENT_MAX).describe("Task content"),
|
|
1743
1780
|
priority: z.enum(["high", "normal", "low"]).optional().default("normal"),
|
|
1744
1781
|
context: z.string().max(10000).optional(),
|
|
1745
1782
|
from_session: z.string().max(200).optional(),
|
|
@@ -1771,8 +1808,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1771
1808
|
}
|
|
1772
1809
|
|
|
1773
1810
|
// Resolve parent_task_id: explicit > inferred (caller's most recent
|
|
1774
|
-
// delivered
|
|
1775
|
-
//
|
|
1811
|
+
// delivered inbox task). Inference is the safety net for when the LLM
|
|
1812
|
+
// forgets to pass parent_task_id.
|
|
1813
|
+
//
|
|
1814
|
+
// #668 — only 'delivered'. 'started' was never a tasks status. A task
|
|
1815
|
+
// the caller has acked or is running is NOT inferred: an unrelated
|
|
1816
|
+
// dispatch without parent_task_id would hang under it, the child's
|
|
1817
|
+
// reply would close it early (chainReplyToParent), and the node's own
|
|
1818
|
+
// reply would then be refused as reply_task_terminal.
|
|
1776
1819
|
//
|
|
1777
1820
|
// round5 F1 fix: the inference SELECT MUST be network-scoped. Without
|
|
1778
1821
|
// it, a caller in network B can pick up the parent_task_id of a
|
|
@@ -1782,7 +1825,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1782
1825
|
if (!parentTaskId && from_session && from_session !== "hub" && from_session !== "api") {
|
|
1783
1826
|
try {
|
|
1784
1827
|
const recentParams: any[] = [from_session];
|
|
1785
|
-
let recentSql = "SELECT task_id FROM tasks WHERE to_name = ?1 AND status IN ('delivered'
|
|
1828
|
+
let recentSql = "SELECT task_id FROM tasks WHERE to_name = ?1 AND status IN ('delivered')"; // board668-parent-delivered-only
|
|
1786
1829
|
recentSql = addScope(recentSql, recentParams, effectiveNetId);
|
|
1787
1830
|
recentSql += " ORDER BY created_at DESC LIMIT 1";
|
|
1788
1831
|
const recent = db.get<{ task_id: string }>(recentSql, ...recentParams);
|
|
@@ -1930,8 +1973,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1930
1973
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'delivered', ?7, 'reply', datetime('now'), datetime('now'), datetime('now', ?8), ?9, ?10, ?11)`,
|
|
1931
1974
|
[id, fromNodeId, from_session, targetNodeId, targetAlias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null, parentTaskId, metaJson]
|
|
1932
1975
|
);
|
|
1933
|
-
const touchParams: any[] = [task
|
|
1934
|
-
let touchSql =
|
|
1976
|
+
const touchParams: any[] = [sessionTaskPreview(task), targetAlias];
|
|
1977
|
+
let touchSql = `UPDATE sessions SET task = ${sessionTaskOnDispatch("?1")}, updated_at = datetime('now') WHERE alias = ?2`;
|
|
1935
1978
|
touchSql = addScope(touchSql, touchParams, effectiveNetId);
|
|
1936
1979
|
db.run(touchSql, touchParams);
|
|
1937
1980
|
// #1548 —— 能派活的发送方不可能真的 blocked;此处出 blocked → idle。
|
|
@@ -4353,7 +4396,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4353
4396
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "auth_required" }) }] };
|
|
4354
4397
|
}
|
|
4355
4398
|
db.run(
|
|
4356
|
-
`INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, role, request_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)`,
|
|
4399
|
+
`INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, role, request_id, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 2)`,
|
|
4357
4400
|
[childTokenId, hashToken(childToken), enforceUserId, networkIdForChild, `node:${node_spec.name}`, "network", "child", requestId]
|
|
4358
4401
|
);
|
|
4359
4402
|
|
|
@@ -5409,7 +5452,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5409
5452
|
"Daemon pulls the alias + node_id list of children it spawned (for childrenMap rebuild after restart). RFC-027 PR1.1.",
|
|
5410
5453
|
{},
|
|
5411
5454
|
async () => {
|
|
5412
|
-
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
5455
|
+
const callerDaemon = resolveCallerDaemonTokenBound(); // board #674: token-bound snapshot, not a lease
|
|
5413
5456
|
if (!callerDaemon.ok) {
|
|
5414
5457
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
5415
5458
|
}
|
|
@@ -5432,18 +5475,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5432
5475
|
AND COALESCE(n.lifecycle_state, 'active') NOT IN ('stopped', 'stop_failed')`,
|
|
5433
5476
|
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
5434
5477
|
);
|
|
5435
|
-
const children: Array<{ child_node_id: string; alias: string; lifecycle_state: string; managed?: "adopted" }> = rows.map(r => ({
|
|
5478
|
+
const children: Array<{ child_node_id: string; alias: string; lifecycle_state: string; managed?: "adopted"; binding_request_id?: string }> = rows.map(r => ({
|
|
5436
5479
|
child_node_id: r.child_node_id,
|
|
5437
5480
|
alias: r.child_name,
|
|
5438
5481
|
lifecycle_state: r.lifecycle_state ?? "active",
|
|
5439
5482
|
}));
|
|
5440
|
-
const adopted = db.all<{ node_id: string; alias: string; lifecycle_state: string | null }>(
|
|
5441
|
-
`SELECT n.node_id,n.alias,n.lifecycle_state FROM node_daemon_bindings b
|
|
5483
|
+
const adopted = db.all<{ node_id: string; alias: string; lifecycle_state: string | null; request_id: string }>(
|
|
5484
|
+
`SELECT n.node_id,n.alias,n.lifecycle_state,b.request_id FROM node_daemon_bindings b
|
|
5442
5485
|
JOIN nodes n ON n.node_id=b.node_id AND n.network_id=b.network_id
|
|
5443
5486
|
WHERE b.daemon_node_id=?1 AND b.network_id=?2 AND b.status='active'`,
|
|
5444
5487
|
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
5445
5488
|
);
|
|
5446
|
-
for (const n of adopted) children.push({ child_node_id: n.node_id, alias: n.alias, lifecycle_state: n.lifecycle_state ?? "active", managed: "adopted" });
|
|
5489
|
+
for (const n of adopted) children.push({ child_node_id: n.node_id, alias: n.alias, lifecycle_state: n.lifecycle_state ?? "active", managed: "adopted", binding_request_id: n.request_id });
|
|
5447
5490
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: children.length, children }) }] };
|
|
5448
5491
|
},
|
|
5449
5492
|
);
|
|
@@ -6269,6 +6312,13 @@ export function trustedConfigSnapshotForNode(
|
|
|
6269
6312
|
}
|
|
6270
6313
|
|
|
6271
6314
|
export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): UpsertNodeOutcome {
|
|
6315
|
+
return db.transaction(() => {
|
|
6316
|
+
if (input.callerNetworkId && input.alias) lockNodeAlias(input.callerNetworkId, input.alias, input.node_id ?? undefined);
|
|
6317
|
+
return upsertNodeIdentity(input);
|
|
6318
|
+
});
|
|
6319
|
+
}
|
|
6320
|
+
|
|
6321
|
+
function upsertNodeIdentity(input: UpsertNodeWithSec1GuardInput): UpsertNodeOutcome {
|
|
6272
6322
|
if (!input.node_id) return { result: "skipped", reason: "missing_node_id" };
|
|
6273
6323
|
const existing = db.get<{ network_id: string | null; owner_user_id: string | null; alias: string | null; config_path: string | null }>(
|
|
6274
6324
|
"SELECT network_id, owner_user_id, alias, config_path FROM nodes WHERE node_id = ?1",
|
|
@@ -6280,8 +6330,8 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
|
|
|
6280
6330
|
// report only that exact node. Legacy unbound ntok rows remain compatible,
|
|
6281
6331
|
// but they can never consume owner-gated schedule intents.
|
|
6282
6332
|
if (input.callerTokenId) {
|
|
6283
|
-
const token = db.get<{ bound_node_id: string | null; user_id: string; network_id: string | null }>(
|
|
6284
|
-
"SELECT bound_node_id, user_id, network_id FROM api_tokens WHERE token_id = ?1",
|
|
6333
|
+
const token = db.get<{ bound_node_id: string | null; user_id: string; network_id: string | null; node_identity_epoch: number; name: string }>(
|
|
6334
|
+
"SELECT bound_node_id, user_id, network_id, node_identity_epoch, name FROM api_tokens WHERE token_id = ?1",
|
|
6285
6335
|
input.callerTokenId,
|
|
6286
6336
|
);
|
|
6287
6337
|
if (token?.bound_node_id && token.bound_node_id !== input.node_id) {
|
|
@@ -6290,6 +6340,16 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
|
|
|
6290
6340
|
if (token?.network_id && _norm(token.network_id) !== _norm(callerNet)) {
|
|
6291
6341
|
return { result: "refused", reason: "cross_network", existingNet: existing?.network_id ?? null, callerNet };
|
|
6292
6342
|
}
|
|
6343
|
+
// New name-only node tokens bind once, at their first registration. General
|
|
6344
|
+
// tokens renamed by report_status are deliberately NOT upgraded to epoch 2.
|
|
6345
|
+
if (token?.node_identity_epoch === 2 && !token.bound_node_id) {
|
|
6346
|
+
const conflict = db.get("SELECT node_id FROM nodes WHERE network_id=?1 AND alias=?2 AND node_id<>?3 LIMIT 1",
|
|
6347
|
+
callerNet, input.alias ?? "", input.node_id);
|
|
6348
|
+
if (conflict || token.name !== `node:${input.alias}` || token.user_id !== input.callerUserId || existing) {
|
|
6349
|
+
console.warn(`[commhub] 🚫 report_status node upsert refused (identity_mismatch): node_id=${input.node_id} first-binding conflict`);
|
|
6350
|
+
return { result: "refused", reason: "identity_mismatch", existingNet: existing?.network_id ?? null, callerNet };
|
|
6351
|
+
}
|
|
6352
|
+
}
|
|
6293
6353
|
}
|
|
6294
6354
|
if (existing?.owner_user_id && input.callerUserId !== existing.owner_user_id) {
|
|
6295
6355
|
return { result: "refused", reason: "owner_mismatch", existingNet: existing.network_id, callerNet };
|
|
@@ -6354,6 +6414,8 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
|
|
|
6354
6414
|
callerNet ?? null,
|
|
6355
6415
|
],
|
|
6356
6416
|
);
|
|
6417
|
+
if (input.callerTokenId) db.run(`UPDATE api_tokens SET bound_node_id=?1
|
|
6418
|
+
WHERE token_id=?2 AND node_identity_epoch=2 AND bound_node_id IS NULL`, [input.node_id, input.callerTokenId]);
|
|
6357
6419
|
const trustedSnapshot = trustedConfigSnapshotForNode(
|
|
6358
6420
|
input.config_snapshot,
|
|
6359
6421
|
input.callerTokenId,
|