@sleep2agi/commhub-server 0.9.0-preview.111 → 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.
@@ -48,7 +48,7 @@ test("two-phase binding, daemon-bound pull, controllable map, adopted delete ref
48
48
  expect(await call(handlers("daemon").get_adopt_request, { request_id: r.request_id })).toMatchObject({ ok: true, node_id: CHILD, alias: "adoption-child", workdir: "/workspace/project" });
49
49
  expect(await call(handlers().get_adopt_request, { request_id: r.request_id })).toMatchObject({ ok: false });
50
50
  expect(await call(handlers("daemon").ack_adopt_request, { request_id: r.request_id, status: "adopted" })).toMatchObject({ ok: true });
51
- expect((await call(handlers("daemon").list_my_children)).children).toContainEqual({ child_node_id: CHILD, alias: "adoption-child", lifecycle_state: "active", managed: "adopted" });
51
+ expect((await call(handlers("daemon").list_my_children)).children).toContainEqual({ child_node_id: CHILD, alias: "adoption-child", lifecycle_state: "active", managed: "adopted", binding_request_id: r.request_id });
52
52
  expect(buildControllableMap([], [{ node_id: CHILD, daemon_node_id: DAEMON }]).get(CHILD)).toBe(DAEMON);
53
53
  expect(await call(handlers().delete_node, { child_node_id: CHILD, confirm_alias: "adoption-child", network_id: NET })).toMatchObject({ ok: false, error: "adopted_node_delete_unsupported" });
54
54
  expect(db.get<any>("SELECT lifecycle_state FROM nodes WHERE node_id=?1", CHILD).lifecycle_state).toBe("active");
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { db } from "./db.js";
18
18
  import { canMessageAgent, type AgentRef } from "./agent-access.js";
19
+ import { resolveNodeCaller } from "./create-node.js";
19
20
 
20
21
  export type NodePermissionMode = "normal" | "readonly" | "restricted";
21
22
  export const NODE_PERMISSION_MODES: readonly NodePermissionMode[] = ["normal", "readonly", "restricted"];
@@ -71,23 +72,55 @@ function normalizeMode(raw: unknown): NodePermissionMode {
71
72
  /** 节点令牌 → 节点身份(一次带索引的查询)。不是节点令牌 / 查不到 → null。 */
72
73
  export function nodeIdentity(tokenId: string | null | undefined, networkId: string | null | undefined): NodeIdentity | null {
73
74
  if (!tokenId || !networkId) return null;
74
- const row = db.get<{ user_id: string | null; bound_node_id: string | null; name: string | null; owner_user_id: string | null; permission_mode: string | null; alias: string | null }>(
75
- `SELECT t.user_id, t.bound_node_id, t.name, n.owner_user_id, n.permission_mode, n.alias
76
- FROM api_tokens t LEFT JOIN nodes n ON n.node_id = t.bound_node_id
77
- WHERE t.token_id = ?1 AND t.network_id = ?2`,
75
+ const row = db.get<{ user_id: string | null; name: string | null }>(
76
+ `SELECT user_id, name FROM api_tokens WHERE token_id = ?1 AND network_id = ?2`,
78
77
  tokenId, networkId,
79
78
  );
80
79
  if (!row) return null;
81
- const aliases = new Set<string>();
82
- if (row.alias) aliases.add(row.alias);
83
- if (row.name?.startsWith("node:")) aliases.add(row.name.slice(5));
80
+ const resolved = resolveNodeCaller(tokenId);
81
+ // Revoked or not node-shaped: this lookup is not a second revocation gate.
82
+ if (!resolved.ok && resolved.reason === "not_a_node_token") {
83
+ const aliases = row.name?.startsWith("node:") ? [row.name.slice(5)] : [];
84
+ return {
85
+ networkId,
86
+ nodeId: null,
87
+ logKey: `token:${tokenId}`,
88
+ aliases,
89
+ ownerUserId: row.user_id,
90
+ mode: "normal",
91
+ };
92
+ }
93
+ if (!resolved.ok || resolved.networkId !== networkId) {
94
+ return {
95
+ networkId,
96
+ nodeId: null,
97
+ logKey: `token:${tokenId}`,
98
+ aliases: [],
99
+ ownerUserId: row.user_id,
100
+ mode: "normal",
101
+ };
102
+ }
103
+ if (resolved.kind === "unregistered") {
104
+ return {
105
+ networkId,
106
+ nodeId: null,
107
+ logKey: `token:${tokenId}`,
108
+ aliases: resolved.alias ? [resolved.alias] : [],
109
+ ownerUserId: row.user_id,
110
+ mode: "normal",
111
+ };
112
+ }
113
+ const node = db.get<{ owner_user_id: string | null; permission_mode: string | null }>(
114
+ `SELECT owner_user_id, permission_mode FROM nodes WHERE node_id = ?1 AND network_id = ?2`,
115
+ resolved.nodeId, networkId,
116
+ );
84
117
  return {
85
118
  networkId,
86
- nodeId: row.bound_node_id,
87
- logKey: row.bound_node_id ?? `token:${tokenId}`,
88
- aliases: [...aliases],
89
- ownerUserId: row.owner_user_id || row.user_id || null,
90
- mode: normalizeMode(row.permission_mode),
119
+ nodeId: resolved.nodeId,
120
+ logKey: resolved.nodeId,
121
+ aliases: [resolved.alias],
122
+ ownerUserId: node?.owner_user_id || row.user_id || null,
123
+ mode: normalizeMode(node?.permission_mode),
91
124
  };
92
125
  }
93
126
 
@@ -5,6 +5,7 @@ import { createHash } from "node:crypto";
5
5
  import { parsePeopleQuery, peopleDirectory, resolvePersonName } from "./requirements-people.js";
6
6
  import { encodeCursor, matchesTaskId, matchesTerms, parseListQuery, type ListQuery, type NameMaps } from "./requirements-search.js";
7
7
  import { db, logAudit } from "./db.js";
8
+ import { resolveNodeCaller } from "./create-node.js";
8
9
  import { markGzipReusable } from "./http-gzip.js";
9
10
  import { applyTagOp, normalizeTags, parseTagOp, storedTags, type TagOp } from "./requirement-tags.js";
10
11
  import { aggregateStats, parseStatsQuery, type StatsRow } from "./requirements-stats.js";
@@ -795,13 +796,8 @@ function actorOf(ctx: RequirementsRequestContext): Actor | null {
795
796
  if (!ctx.auth) return null;
796
797
  if (!ctx.isNodeToken) return { kind: "user", id: ctx.auth.userId };
797
798
  const tokenId = ctx.auth.tokenId ?? null;
798
- const bound = tokenId ? db.get<{ bound_node_id: string | null }>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", tokenId)?.bound_node_id : null;
799
- if (bound) return { kind: "node", id: bound };
800
- const alias = ctx.auth.tokenName?.startsWith("node:") ? ctx.auth.tokenName.slice(5) : null;
801
- if (alias && ctx.auth.networkId) {
802
- const node = db.get<{ node_id: string }>("SELECT node_id FROM nodes WHERE network_id = ?1 AND alias = ?2", ctx.auth.networkId, alias);
803
- if (node) return { kind: "node", id: node.node_id };
804
- }
799
+ const resolved = tokenId ? resolveNodeCaller(tokenId) : null;
800
+ if (resolved?.ok && resolved.kind === "node") return { kind: "node", id: resolved.nodeId };
805
801
  return { kind: "node", id: `token:${tokenId ?? "unknown"}` };
806
802
  }
807
803
 
@@ -12,15 +12,20 @@
12
12
  // task row and, once sender labels reach the copresence TUIs, rendered to a
13
13
  // human as an attribution.
14
14
  //
15
- // This is the same decision expressed as a pure function so every credential
16
- // kind is a directly constructible test input rather than a hand-assembled
17
- // Request (the #503 convention in this codebase).
15
+ // Production network tokens pass tokenId and resolve through resolveNodeCaller.
16
+ // A token whose name is not `node:` (not_a_node_token, the registration token
17
+ // before report_status) falls back to nodeAliasForToken, same as main.
18
+ // tokenId omitted keeps the name-only shape used by the unit cases below.
19
+
20
+ import { resolveNodeCaller } from "./create-node.js";
18
21
 
19
22
  export interface RestIdentityInput {
20
23
  /** Raw bearer token, only its prefix matters (`ntok_` = network-bound). */
21
24
  token: string;
22
25
  /** `api_tokens.name`; for node tokens it is `node:<alias>`. */
23
26
  tokenName: string | null | undefined;
27
+ /** Set on the production path. Network tokens then ignore tokenName as authority. */
28
+ tokenId?: string | null;
24
29
  /** Username resolved from the authenticated token. */
25
30
  authenticatedUsername?: string | null;
26
31
  /** Caller-supplied `body.from`. */
@@ -46,8 +51,28 @@ export function nodeAliasForToken(token: string, tokenName: string | null | unde
46
51
  }
47
52
 
48
53
  export function resolveRestFromSession(input: RestIdentityInput): RestIdentityResult {
49
- const alias = nodeAliasForToken(input.token, input.tokenName);
50
54
  const requested = typeof input.requestedFrom === "string" ? input.requestedFrom.trim() : "";
55
+ let alias: string | null;
56
+ if (input.token.startsWith("ntok_") && input.tokenId) {
57
+ const resolved = resolveNodeCaller(input.tokenId);
58
+ if (resolved.ok) {
59
+ alias = resolved.alias;
60
+ } else if (resolved.reason === "not_a_node_token") { // board679-plain-ntok-rest
61
+ // Name is not `node:` yet. Main signs that as api, and refuses a claim.
62
+ // not_owner / bound_node_missing / ambiguous stay the hard reject.
63
+ alias = nodeAliasForToken(input.token, input.tokenName);
64
+ } else {
65
+ return {
66
+ ok: false,
67
+ error: "from_session_identity_mismatch",
68
+ message: "network token from_session does not match token-bound node alias",
69
+ tokenAlias: "",
70
+ requestedFromSession: requested,
71
+ };
72
+ }
73
+ } else {
74
+ alias = nodeAliasForToken(input.token, input.tokenName);
75
+ }
51
76
 
52
77
  // A network-bound token whose name we cannot resolve to an alias (`node:`,
53
78
  // `node: `, or a name that was never node-scoped) is the degenerate case.
package/src/server.ts CHANGED
@@ -75,6 +75,7 @@ import {
75
75
  TASK_REST_SELECT,
76
76
  } from "./rest-projections.js";
77
77
  import { resolveRestFromSession } from "./rest-identity.js";
78
+ import { resolveNodeCaller } from "./create-node.js";
78
79
  import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
79
80
  import { diagnoseTask } from "./task-diagnostic.js";
80
81
  import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
@@ -197,12 +198,12 @@ function sweepStaleRateLimits(): void {
197
198
  }
198
199
 
199
200
  // ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
200
- function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null, includeProtocolTools = false): McpServer {
201
+ function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null, includeProtocolTools = false, callerIdentityRejected = false): McpServer {
201
202
  const server = new McpServer({
202
203
  name: "commhub",
203
204
  version: "0.5.0",
204
205
  });
205
- registerTools(server, clientIP, enforceNetworkId, enforceUserId, callerAlias, callerTokenIsNetwork, callerTokenId, { includeProtocol: includeProtocolTools });
206
+ registerTools(server, clientIP, enforceNetworkId, enforceUserId, callerAlias, callerTokenIsNetwork, callerTokenId, { includeProtocol: includeProtocolTools }, callerIdentityRejected);
206
207
  return server;
207
208
  }
208
209
 
@@ -272,6 +273,16 @@ function nodeSubscriberInfo(req: Request, server: any, tokenId: string | null, a
272
273
  return { nodeId, instanceId, remote, userAgent: req.headers.get("user-agent")?.slice(0, 200) ?? null };
273
274
  }
274
275
 
276
+ // The stream belongs to a node id, not a name. A session that never stored
277
+ // node_id still owns its stream when this network has exactly one nodes row
278
+ // for the alias. Null does not match null.
279
+ function streamOwnerNodeId(alias: string, networkId: string): string | null {
280
+ const fromSession = db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, networkId)?.node_id ?? null;
281
+ if (fromSession) return fromSession;
282
+ const rows = db.all<{ node_id: string }>("SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2", alias, networkId);
283
+ return rows.length === 1 ? rows[0].node_id : null;
284
+ }
285
+
275
286
  function isLocalhostIP(ip: string): boolean {
276
287
  return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1" || ip === "localhost";
277
288
  }
@@ -310,17 +321,24 @@ function isHubAdminCredential(resolved: { networkId: string | null; user: { role
310
321
  function nodeTokenAlias(resolved: { tokenName: string | null }): string | null {
311
322
  return resolved.tokenName?.startsWith("node:") ? resolved.tokenName.slice("node:".length) : null;
312
323
  }
313
- function nodeMayRename(resolved: { networkId: string | null; tokenName: string | null }, networkId: unknown, oldAlias: unknown): boolean {
324
+ function resolvedNodeAlias(resolved: { networkId: string | null; tokenName: string | null; tokenId?: string | null }): string | null {
325
+ if (!resolved.tokenId) return nodeTokenAlias(resolved);
326
+ const caller = resolveNodeCaller(resolved.tokenId);
327
+ if (!caller.ok) return null;
328
+ if (resolved.networkId && caller.networkId !== resolved.networkId) return null;
329
+ return caller.alias;
330
+ }
331
+ function nodeMayRename(resolved: { networkId: string | null; tokenName: string | null; tokenId?: string | null }, networkId: unknown, oldAlias: unknown): boolean {
314
332
  if (!resolved.networkId) return true;
315
- return networkId === resolved.networkId && typeof oldAlias === "string" && oldAlias === nodeTokenAlias(resolved);
333
+ return networkId === resolved.networkId && typeof oldAlias === "string" && oldAlias === resolvedNodeAlias(resolved);
316
334
  }
317
- function nodeMayRenameTxn(resolved: { networkId: string | null; tokenName: string | null }, txnId: unknown): boolean {
335
+ function nodeMayRenameTxn(resolved: { networkId: string | null; tokenName: string | null; tokenId?: string | null }, txnId: unknown): boolean {
318
336
  if (!resolved.networkId) return true;
319
337
  if (typeof txnId !== "string") return false;
320
338
  const txn = db.get<{ network_id: string; old_alias: string; new_alias: string }>("SELECT network_id, old_alias, new_alias FROM rename_txn WHERE txn_id = ?1", txnId);
321
339
  if (!txn || txn.network_id !== resolved.networkId) return false;
322
340
  // commit 前后令牌名可能已被改成新 alias(api_tokens.name 跟随改名),两个都算它自己。
323
- const self = nodeTokenAlias(resolved);
341
+ const self = resolvedNodeAlias(resolved);
324
342
  return self === txn.old_alias || self === txn.new_alias;
325
343
  }
326
344
 
@@ -1030,11 +1048,20 @@ return Bun.serve({
1030
1048
  const token = requestToken(req);
1031
1049
  const authCtx = resolveRequestAuth(req);
1032
1050
  const enforceNetId = authCtx?.networkId || null;
1033
- // Derive the calling alias from the token name (e.g., 'node:视频审查')
1034
- // so peer agents see the real sender instead of 'hub' on send_task.
1035
- const callerAlias = authCtx?.tokenName?.startsWith("node:")
1036
- ? authCtx.tokenName.slice("node:".length)
1037
- : (authCtx?.username || null);
1051
+ // A resolved node token speaks as that node. A token whose name is not
1052
+ // `node:` (the registration network token) keeps the username. Only a
1053
+ // `node:` token refused as not_owner / bound_node_missing / ambiguous
1054
+ // is impersonation, and must not fall through to a client-supplied from.
1055
+ let callerAlias: string | null = authCtx?.username || null;
1056
+ let callerIdentityRejected = false;
1057
+ if (token?.startsWith("ntok_")) {
1058
+ const resolvedCaller = authCtx?.tokenId ? resolveNodeCaller(authCtx.tokenId) : null;
1059
+ if (resolvedCaller?.ok) callerAlias = resolvedCaller.alias;
1060
+ else if (resolvedCaller && resolvedCaller.reason !== "not_a_node_token") { // board679-plain-ntok
1061
+ callerAlias = null;
1062
+ callerIdentityRejected = true;
1063
+ }
1064
+ }
1038
1065
  const transport = new WebStandardStreamableHTTPServerTransport({
1039
1066
  sessionIdGenerator: undefined,
1040
1067
  });
@@ -1052,7 +1079,7 @@ return Bun.serve({
1052
1079
  labelRoute(req, mcpParsedBody === undefined ? "?" : mcpRouteLabel(mcpParsedBody));
1053
1080
  }
1054
1081
  // #478:tools/list 按调用者过滤;X-Anet-Tools: all / ?tools=all 时节点令牌也列出协议工具。
1055
- const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias, !!token?.startsWith("ntok_"), authCtx?.tokenId || null, wantsAllTools(req, url));
1082
+ const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias, !!token?.startsWith("ntok_"), authCtx?.tokenId || null, wantsAllTools(req, url), callerIdentityRejected);
1056
1083
  await mcpServer.connect(transport);
1057
1084
  const response = await transport.handleRequest(req, mcpParsedBody === undefined ? undefined : { parsedBody: mcpParsedBody });
1058
1085
  // Disconnect after response to prevent McpServer leak
@@ -1206,11 +1233,22 @@ return Bun.serve({
1206
1233
  nodeId: db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", sessionName, scopedNetId)?.node_id ?? null,
1207
1234
  }));
1208
1235
  if (streamDenied) return streamDenied;
1209
- // #507 — the node's OWN stream (token bound to this alias) is node-scoped:
1210
- // at most one per (network, alias), newest wins. A node token watching some
1211
- // other alias stays a plain monitor and can never supersede that node.
1212
- if (authCtx.tokenName === `node:${sessionName}` && authCtx.networkId === scopedNetId) {
1213
- return createSSEStream(sessionName, scopedNetId, { node: nodeSubscriberInfo(req, server, authCtx.tokenId, sessionName, scopedNetId) });
1236
+ // #507 — the node's OWN stream is node-scoped: at most one per (network,
1237
+ // alias), newest wins. Own means the resolved node id equals this stream's
1238
+ // node id. A matching token name is not enough, and a monitor never supersedes.
1239
+ const resolvedCaller = authCtx.tokenId ? resolveNodeCaller(authCtx.tokenId) : null;
1240
+ const streamNodeId = streamOwnerNodeId(sessionName, scopedNetId);
1241
+ if (
1242
+ resolvedCaller?.ok
1243
+ && resolvedCaller.kind === "node"
1244
+ && resolvedCaller.nodeId
1245
+ && streamNodeId
1246
+ && resolvedCaller.nodeId === streamNodeId
1247
+ && resolvedCaller.networkId === scopedNetId
1248
+ ) {
1249
+ const info = nodeSubscriberInfo(req, server, authCtx.tokenId, sessionName, scopedNetId);
1250
+ if (!info.nodeId) info.nodeId = resolvedCaller.nodeId;
1251
+ return createSSEStream(sessionName, scopedNetId, { node: info });
1214
1252
  }
1215
1253
  return createSSEStream(sessionName, scopedNetId);
1216
1254
  }
@@ -1395,8 +1433,9 @@ return Bun.serve({
1395
1433
  // RFC-040:managed_department_ids = 我负责的部门 + 全部下级(空 = 不是负责人;viewer 恒空;节点令牌不是人,恒空)。
1396
1434
  const networks = (resolved.networkId ? allNetworks.filter((n: any) => n.network_id === resolved.networkId) : allNetworks)
1397
1435
  .map((n: any) => ({ ...n, managed_department_ids: resolved.networkId ? [] : managedDepartmentIds(n.network_id, resolved.user.user_id) }));
1436
+ const nodeCaller = resolved.networkId && resolved.tokenId ? resolveNodeCaller(resolved.tokenId) : null;
1398
1437
  const credential = resolved.networkId
1399
- ? { kind: "node", network_id: resolved.networkId, node_alias: resolved.tokenName?.startsWith("node:") ? resolved.tokenName.slice(5) : null, acts_as_owner: false }
1438
+ ? { kind: "node", network_id: resolved.networkId, node_alias: nodeCaller?.ok ? nodeCaller.alias : null, acts_as_owner: false }
1400
1439
  : { kind: "user" };
1401
1440
  return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId, credential }));
1402
1441
  }
@@ -3546,6 +3585,7 @@ return Bun.serve({
3546
3585
  const identity = resolveRestFromSession({
3547
3586
  token: requestToken(req),
3548
3587
  tokenName: restAuth?.tokenName,
3588
+ tokenId: restAuth?.tokenId,
3549
3589
  authenticatedUsername: restAuth?.username,
3550
3590
  requestedFrom: body.from,
3551
3591
  });
package/src/tools.ts CHANGED
@@ -267,7 +267,7 @@ function guardNodePermissionTools(server: McpServer, tokenId: string, networkId:
267
267
  }
268
268
  }
269
269
 
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 } = {}) {
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) {
271
271
  // 多用户 Agent 权限:用户令牌调用者在哪些网络里是受限成员(只看授权 Agent)。
272
272
  // 网络令牌不会走到这里 —— 受限成员的网络令牌在 resolveToken 就被拒了。
273
273
  const restrictedNets = enforceUserId && !callerTokenIsNetwork && !enforceNetworkId ? restrictedNetworkIds(enforceUserId) : [];
@@ -278,9 +278,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
278
278
  // an agent's send_task call always claimed from='hub' and peer agents
279
279
  // couldn't tell who actually asked them. Network-bound node tokens are an
280
280
  // identity boundary: they must not spoof another node via from_session.
281
- const defaultFrom = (clientFrom?: string) => (callerTokenIsNetwork && callerAlias) ? callerAlias : (clientFrom || callerAlias || "hub");
281
+ const defaultFrom = (clientFrom?: string) => {
282
+ if (callerTokenIsNetwork && callerIdentityRejected) return "";
283
+ return (callerTokenIsNetwork && callerAlias) ? callerAlias : (clientFrom || callerAlias || "hub");
284
+ };
282
285
  const fromIdentityMismatchReply = (clientFrom?: string) => {
283
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
+ }
284
301
  if (!callerTokenIsNetwork || !callerAlias || !requestedFrom || requestedFrom === callerAlias) return null;
285
302
  return {
286
303
  content: [{
@@ -916,6 +933,21 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
916
933
  if (!canWrite(effectiveNetId)) {
917
934
  return writeDeniedReply(effectiveNetId);
918
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
+ }
919
951
  const canonical = resolveCanonicalAlias(sessionNetId, alias);
920
952
  let effectiveAlias = canonical.alias;
921
953
  if (canonical.renamed) {
@@ -969,11 +1001,6 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
969
1001
  };
970
1002
  }
971
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}]` : ""}`);
972
- if (callerTokenIsNetwork && callerTokenId) {
973
- try {
974
- db.run("UPDATE api_tokens SET name = ?1 WHERE token_id = ?2", [`node:${effectiveAlias}`, callerTokenId]);
975
- } catch {}
976
- }
977
1004
  // #507 — a second copy of this node (same token + alias) may still be connected;
978
1005
  // its sibling's shutdown offline must not take the node offline under it.
979
1006
  const offlineDeferred = deferOfflineIfAnotherCopyConnected(effectiveAlias, sessionNetId, status, resume_id);
@@ -1162,6 +1189,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1162
1189
  [uuidv4(), sessionNetId, resume_id, effectiveAlias, hostHostname, hostIp, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb, diskTotalGb, diskUsedGb, diskAvailGb, processRssBytes, processRssMb, processCpuPct, processUptimeSeconds, processInFlightCount]
1163
1190
  );
1164
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
+ }
1165
1197
  });
1166
1198
  // #448 — only the node token bound to this alias may speak for its health
1167
1199
  // (same rule as the *_capable flags above).
@@ -1204,10 +1236,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1204
1236
  } catch {}
1205
1237
  }
1206
1238
 
1207
- // V2: upsert nodes table for persistent node identity. SEC-1
1208
- // gate (PR A #287 follow-up, 通信牛 catch 2026-06-28): delegate
1209
- // to upsertNodeWithSec1Guard so production + test exercise the
1210
- // 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.
1211
1242
  if (node_id) {
1212
1243
  try {
1213
1244
  const nodeRuntime = ag?.includes(":") ? ag.split(":")[1] + "-sdk" : ag ?? null;
@@ -1226,8 +1257,6 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1226
1257
  hostname: hn ?? null,
1227
1258
  config_snapshot: cfgSnap ?? null,
1228
1259
  });
1229
- // The session row above already took node_id; a refused identity claim
1230
- // must not leave this session pointing at another node's row.
1231
1260
  if (upserted.result === "refused" && upserted.reason === "identity_mismatch") {
1232
1261
  db.run("UPDATE sessions SET node_id = NULL WHERE resume_id = ?1 AND node_id = ?2", [resume_id, node_id]);
1233
1262
  }
@@ -5423,7 +5452,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5423
5452
  "Daemon pulls the alias + node_id list of children it spawned (for childrenMap rebuild after restart). RFC-027 PR1.1.",
5424
5453
  {},
5425
5454
  async () => {
5426
- const callerDaemon = resolveCallerDaemonTokenBound();
5455
+ const callerDaemon = resolveCallerDaemonTokenBound(); // board #674: token-bound snapshot, not a lease
5427
5456
  if (!callerDaemon.ok) {
5428
5457
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
5429
5458
  }
@@ -5446,18 +5475,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5446
5475
  AND COALESCE(n.lifecycle_state, 'active') NOT IN ('stopped', 'stop_failed')`,
5447
5476
  callerDaemon.daemonNodeId, callerDaemon.networkId,
5448
5477
  );
5449
- 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 => ({
5450
5479
  child_node_id: r.child_node_id,
5451
5480
  alias: r.child_name,
5452
5481
  lifecycle_state: r.lifecycle_state ?? "active",
5453
5482
  }));
5454
- const adopted = db.all<{ node_id: string; alias: string; lifecycle_state: string | null }>(
5455
- `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
5456
5485
  JOIN nodes n ON n.node_id=b.node_id AND n.network_id=b.network_id
5457
5486
  WHERE b.daemon_node_id=?1 AND b.network_id=?2 AND b.status='active'`,
5458
5487
  callerDaemon.daemonNodeId, callerDaemon.networkId,
5459
5488
  );
5460
- 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 });
5461
5490
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: children.length, children }) }] };
5462
5491
  },
5463
5492
  );