@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.
@@ -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) => (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
+ };
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(10000).optional().describe("Current task description"),
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, 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]
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
- // V2: upsert nodes table for persistent node identity. SEC-1
1200
- // gate (PR A #287 follow-up, 通信牛 catch 2026-06-28): delegate
1201
- // to upsertNodeWithSec1Guard so production + test exercise the
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(10000).describe("Task content"),
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/started inbox task that's still open). Inference is the
1775
- // safety net for when the LLM forgets to pass parent_task_id.
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','started')";
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.slice(0, 200), targetAlias];
1934
- let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
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,