@sleep2agi/commhub-server 0.9.0-preview.131 → 0.9.0-preview.133

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.131",
4
- "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
3
+ "version": "0.9.0-preview.133",
4
+ "description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "bin": {
@@ -14,6 +14,7 @@ const TARGET_RESUME_ID = `resume_dashboard_slash_target_${process.pid}`;
14
14
  let server: any;
15
15
  let base = "";
16
16
  let userToken = "";
17
+ let userName = "";
17
18
  let nodeToken = "";
18
19
  let networkId = "";
19
20
  let requestSequence = 0;
@@ -25,6 +26,7 @@ let requestSequence = 0;
25
26
  beforeAll(async () => {
26
27
  process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
27
28
  const username = `dashboard_slash_${Date.now()}`;
29
+ userName = username;
28
30
  const registered = register(username, "DashboardSlash123!", undefined, "seed");
29
31
  expect(registered.ok).toBe(true);
30
32
  userToken = registered.token!;
@@ -81,7 +83,7 @@ async function postAndRead(token: string, from: string, task: string) {
81
83
  describe("real Hub → authenticated Dashboard slash routing", () => {
82
84
  test("Dashboard /goal and /loop remain exact native payloads for every runtime", async () => {
83
85
  for (const command of ["/goal 更新一下文档", "/loop 5m native loop"] as const) {
84
- const row = await postAndRead(userToken, "dashboard-user", command);
86
+ const row = await postAndRead(userToken, userName, command);
85
87
  expect(row.content).toBe(command);
86
88
  expect(isInteractiveDashboardTask(row)).toBe(true);
87
89
  for (const runtime of ["codex-app-server", "codex", "claude", "grok", "opencode"] as const) {
@@ -92,7 +94,7 @@ describe("real Hub → authenticated Dashboard slash routing", () => {
92
94
 
93
95
  test("Dashboard /agoal and /aloop use the Agent Network scheduler", async () => {
94
96
  for (const command of ["/agoal 5m update docs", "/aloop 5m update docs"] as const) {
95
- const row = await postAndRead(userToken, "dashboard-user", command);
97
+ const row = await postAndRead(userToken, userName, command);
96
98
  expect(isInteractiveDashboardTask(row)).toBe(true);
97
99
  expect(shouldCreateScheduledGoal(row.content, "codex-app-server", true)).toBe(true);
98
100
  }
@@ -75,6 +75,92 @@ let p10Owner: Tok;
75
75
  type Sub = { events: any[]; done: boolean; abort: () => void; ready: Promise<void> };
76
76
  const openSubs: Sub[] = [];
77
77
 
78
+ for (const transport of ["mcp", "rest"] as const) {
79
+ for (const shape of ["bound duplicate", "unbound duplicate", "no session", "other network"] as const) {
80
+ test(`node attribution: ${transport} ${shape} retains the credential's node ID`, async () => {
81
+ const key = `${transport}-${shape.replaceAll(" ", "-")}-${stamp}`;
82
+ const alias = `idvsender-${key}`;
83
+ const mine = `n_idv_sender_${key}`;
84
+ const member = register(`idvmem-${key}`, "CallerMember123!", undefined, "seed");
85
+ if (!member.ok || !member.user) throw new Error("member fixture failed");
86
+ const memberId = member.user.user_id;
87
+ const joined = addNetworkMember(NET, memberId, "member", ownerId, { agentAccess: "all" });
88
+ if (!joined.ok) throw new Error(joined.error);
89
+ seedNode(mine, alias, NET, memberId);
90
+ if (shape.includes("duplicate")) {
91
+ const ownerNode = `n_idv_owner_${key}`;
92
+ seedNode(ownerNode, alias, NET, ownerId);
93
+ seedSession(ownerNode, alias, NET); // the active session belongs to a different row
94
+ }
95
+ if (shape === "other network") {
96
+ const foreignNode = `n_idv_foreign_${key}`;
97
+ seedNode(foreignNode, alias, NET2, outsiderId);
98
+ seedSession(foreignNode, alias, NET2);
99
+ }
100
+ const tok = mint(memberId, NET, shape === "bound duplicate" ? victimAlias : alias,
101
+ shape === "unbound duplicate" ? 0 : 2, shape === "unbound duplicate" ? null : mine);
102
+ const content = `sender identity ${key}`;
103
+ const sent = transport === "mcp"
104
+ ? await mcpSend(tok.token, content, undefined)
105
+ : (await api(tok.token, "POST", "/api/task", { alias: sinkAlias, task: content, network_id: NET })).body;
106
+ expect(sent.ok).toBe(true);
107
+ const row = db.get("SELECT from_name, from_node_id, network_id FROM tasks WHERE content = ?1", content);
108
+ expect(row).toEqual({ from_name: alias, from_node_id: mine, network_id: NET });
109
+ });
110
+ }
111
+ }
112
+
113
+ for (const owner of ["own", "another"] as const) {
114
+ test(`rename registry: prepare rejects ${owner} node without a session`, async () => {
115
+ const target = `idvreserved-${owner}-${stamp}`;
116
+ seedNode(`n_idv_reserved_${owner}_${stamp}`, target, NET, owner === "own" ? ownerId : outsiderId);
117
+ const before = db.get("SELECT COUNT(*) AS n FROM rename_txn");
118
+ const result = await api(ownerToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: victimAlias, new_alias: target });
119
+ expect(result.status).toBe(400);
120
+ expect(result.body.ok).toBe(false);
121
+ expect(db.get("SELECT COUNT(*) AS n FROM rename_txn")).toEqual(before);
122
+ expect(sessionSnap(victimAlias, NET)?.node_id).toBe(victimId);
123
+ });
124
+ }
125
+
126
+ test("rename registry: commit rejects a node created after prepare", async () => {
127
+ const target = `idvrace-${stamp}`;
128
+ const prepared = await api(ownerToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: victimAlias, new_alias: target });
129
+ expect(prepared.body.ok).toBe(true);
130
+ const nodeId = `n_idv_race_${stamp}`;
131
+ seedNode(nodeId, target, NET, outsiderId);
132
+ const result = await api(ownerToken, "POST", "/api/node-rename/commit", { txn_id: prepared.body.txn_id });
133
+ expect(result.status).toBe(400);
134
+ expect(result.body.ok).toBe(false);
135
+ expect(sessionSnap(victimAlias, NET)?.node_id).toBe(victimId);
136
+ expect(db.get("SELECT alias FROM nodes WHERE node_id = ?1", nodeId)).toEqual({ alias: target });
137
+ expect(db.get("SELECT status FROM rename_txn WHERE txn_id = ?1", prepared.body.txn_id)).toEqual({ status: "prepared" });
138
+ await api(ownerToken, "POST", "/api/node-rename/abort", { txn_id: prepared.body.txn_id });
139
+ });
140
+
141
+ test("rename registry: another network's node does not reserve the local alias", async () => {
142
+ const target = `idvforeign-target-${stamp}`;
143
+ seedNode(`n_idv_foreign_target_${stamp}`, target, NET2, outsiderId);
144
+ const prepared = await api(ownerToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: victimAlias, new_alias: target });
145
+ expect(prepared.body.ok).toBe(true);
146
+ expect((await api(ownerToken, "POST", "/api/node-rename/abort", { txn_id: prepared.body.txn_id })).body.ok).toBe(true);
147
+ });
148
+
149
+ test("rename registry: a free alias commits and preserves node identity", async () => {
150
+ const alias = `idvrename-old-${stamp}`;
151
+ const target = `idvrename-new-${stamp}`;
152
+ const nodeId = `n_idv_rename_${stamp}`;
153
+ seedNode(nodeId, alias, NET, ownerId);
154
+ seedSession(nodeId, alias, NET);
155
+ const prepared = await api(ownerToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: alias, new_alias: target });
156
+ expect(prepared.body.ok).toBe(true);
157
+ const committed = await api(ownerToken, "POST", "/api/node-rename/commit", { txn_id: prepared.body.txn_id });
158
+ expect(committed.body.ok).toBe(true);
159
+ expect(sessionSnap(target, NET)?.node_id).toBe(nodeId);
160
+ expect(sessionSnap(alias, NET)).toBeNull();
161
+ expect(db.get("SELECT alias FROM nodes WHERE node_id = ?1", nodeId)).toEqual({ alias: target });
162
+ });
163
+
78
164
  function subscribe(path: string, token: string, headers: Record<string, string> = {}): Sub {
79
165
  const ctrl = new AbortController();
80
166
  const sub: Sub = { events: [], done: false, abort: () => ctrl.abort(), ready: Promise.resolve() };
@@ -165,6 +251,16 @@ function minted(userId: string, networkId: string, alias: string, nodeId: string
165
251
  return { token: row.token, tokenId: row.token_id };
166
252
  }
167
253
 
254
+ function seedNode(nodeId: string, alias: string, networkId: string, userId: string) {
255
+ db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id)
256
+ VALUES (?1, ?2, ?2, ?3, ?4)`, [nodeId, alias, networkId, userId]);
257
+ }
258
+
259
+ function seedSession(nodeId: string, alias: string, networkId: string) {
260
+ db.run(`INSERT INTO sessions (resume_id, alias, node_id, network_id, status)
261
+ VALUES (?1, ?2, ?3, ?4, 'idle')`, [`sdk-${nodeId}`, alias, nodeId, networkId]);
262
+ }
263
+
168
264
  function taskCount(content: string): number {
169
265
  return Number(db.get<{ n: number | string }>("SELECT COUNT(*) AS n FROM tasks WHERE content = ?1", content)?.n ?? 0);
170
266
  }
@@ -192,6 +288,115 @@ const report = (tok: Tok, alias: string, nodeId: string, networkId: string, extr
192
288
  ...extra,
193
289
  });
194
290
 
291
+ function lifecycleFixture(key: string) {
292
+ const daemonId = `node_life_daemon_${key}_${stamp}`, daemonAlias = `life-daemon-${key}-${stamp}`;
293
+ const childId = `node_life_${key}_${stamp}`, childAlias = `life-child-${key}-${stamp}`;
294
+ seedNode(daemonId, daemonAlias, NET, ownerId); seedNode(childId, childAlias, NET, ownerId);
295
+ seedSession(daemonId, daemonAlias, NET);
296
+ db.run("UPDATE nodes SET hostname='lifecycle-host',config_snapshot=?1 WHERE node_id=?2",
297
+ [JSON.stringify({ role: "host_supervisor", daemon_capabilities: { adopt_capable: true } }), daemonId]);
298
+ db.run("UPDATE nodes SET hostname='lifecycle-host',config_snapshot='{}' WHERE node_id=?1", [childId]);
299
+ db.run("UPDATE sessions SET last_seen_at=datetime('now') WHERE node_id=?1", [daemonId]);
300
+ return { daemonId, daemonAlias, childId, childAlias };
301
+ }
302
+
303
+ test("token lifecycle: adoption rejects a different node's same-name credential", async () => {
304
+ const f = lifecycleFixture("adopt-denied");
305
+ const otherNode = `n_life_other_${stamp}`;
306
+ seedNode(otherNode, `life-other-${stamp}`, NET, outsiderId);
307
+ mint(outsiderId, NET, f.daemonAlias, 2, otherNode);
308
+ mint(outsiderId, NET, f.daemonAlias, 0, null);
309
+ const result = await tool(ownerToken, "request_adopt_node", { node_id: f.childId, daemon_node_id: f.daemonId, workdir: "/workspace/fixture" });
310
+ expect(result.error).toBe("daemon_offline");
311
+ expect(db.get("SELECT request_id FROM node_daemon_bindings WHERE node_id=?1", f.childId)).toBeNull();
312
+ });
313
+
314
+ for (const shape of ["bound", "unbound"] as const) {
315
+ test(`token lifecycle: adoption accepts verified ${shape} credentials`, async () => {
316
+ const f = lifecycleFixture(`adopt-${shape}`);
317
+ mint(ownerId, NET, shape === "bound" ? victimAlias : f.daemonAlias, shape === "bound" ? 2 : 0, shape === "bound" ? f.daemonId : null);
318
+ const result = await tool(ownerToken, "request_adopt_node", { node_id: f.childId, daemon_node_id: f.daemonId, workdir: "/workspace/fixture" });
319
+ expect(result.ok).toBe(true);
320
+ });
321
+ }
322
+
323
+ test("token lifecycle: another node's heartbeat cannot make the daemon ready", async () => {
324
+ const f=lifecycleFixture("heartbeat-denied");
325
+ mint(ownerId,NET,f.daemonAlias,2,f.daemonId);
326
+ const other=`node_life_heartbeat_other_${stamp}`;
327
+ seedNode(other,f.daemonAlias,NET,outsiderId);
328
+ db.run("DELETE FROM sessions WHERE node_id=?1",[f.daemonId]);
329
+ seedSession(other,f.daemonAlias,NET);
330
+ db.run("UPDATE sessions SET last_seen_at=datetime('now') WHERE node_id=?1",[other]);
331
+ const result=await tool(ownerToken,"request_adopt_node",{node_id:f.childId,daemon_node_id:f.daemonId,workdir:"/workspace/fixture"});
332
+ expect(result.error).toBe("daemon_offline");
333
+ });
334
+
335
+ test("token lifecycle: a unique legacy null-ID heartbeat remains supported", async () => {
336
+ const f=lifecycleFixture("heartbeat-legacy");
337
+ mint(ownerId,NET,f.daemonAlias,0,null);
338
+ db.run("UPDATE sessions SET node_id=NULL WHERE node_id=?1",[f.daemonId]);
339
+ expect((await tool(ownerToken,"request_adopt_node",{node_id:f.childId,daemon_node_id:f.daemonId,workdir:"/workspace/fixture"})).ok).toBe(true);
340
+ });
341
+
342
+ for (const shape of ["frozen", "legacy null", "wrong frozen", "same alias rebuild"] as const) {
343
+ test(`token lifecycle: delete ${shape} revokes only the target credential`, async () => {
344
+ const key = shape.replaceAll(" ", "-"); const f = lifecycleFixture(key);
345
+ const daemonTok = mint(ownerId, NET, f.daemonAlias, 2, f.daemonId);
346
+ const wrong = mint(outsiderId, NET, f.childAlias, 0, null);
347
+ const childTok = mint(ownerId, NET, shape === "frozen" ? victimAlias : f.childAlias, 2, f.childId);
348
+ db.run(`INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,child_node_id,network_id,runtime,flags_json,env_keys,status,child_token_id,created_at,created_by_token)
349
+ VALUES(?1,?2,?3,?4,?5,'fixture','{}','[]','started',?6,?7,'fixture')`,
350
+ [`cr_${f.childId.slice(5)}`, f.daemonId, f.childAlias, f.childId, NET, childTok.tokenId, Date.now()]);
351
+ const dispatched = await tool(ownerToken, "delete_node", { child_node_id: f.childId, daemon_node_id: f.daemonId, network_id: NET, confirm_alias: f.childAlias });
352
+ expect(dispatched.ok).toBe(true);
353
+ expect(db.get<{ child_token_id: string }>("SELECT child_token_id FROM node_stop_requests WHERE request_id=?1", dispatched.request_id)?.child_token_id).toBe(childTok.tokenId);
354
+ if (shape === "legacy null" || shape === "wrong frozen") db.run("UPDATE node_stop_requests SET child_token_id=?1 WHERE request_id=?2", [shape === "wrong frozen" ? wrong.tokenId : null, dispatched.request_id]);
355
+ let rebuilt: Tok | null = null;
356
+ if (shape === "same alias rebuild") {
357
+ db.run("DELETE FROM nodes WHERE node_id=?1", [f.childId]);
358
+ const newId = `n_life_rebuilt_${stamp}`;seedNode(newId, f.childAlias, NET, ownerId);
359
+ rebuilt = mint(ownerId, NET, f.childAlias, 2, newId);
360
+ }
361
+ const ack = await tool(daemonTok.token, "ack_stop_request", { request_id: dispatched.request_id, status: "stopped" });
362
+ expect(ack.ok).toBe(true);
363
+ const revoked = (id: string) => db.get<{ revoked_at: string | null }>("SELECT revoked_at FROM api_tokens WHERE token_id=?1", id)?.revoked_at;
364
+ expect(revoked(childTok.tokenId)).not.toBeNull();
365
+ expect(revoked(wrong.tokenId)).toBeNull();
366
+ if (rebuilt) expect(revoked(rebuilt.tokenId)).toBeNull();
367
+ expect(db.get("SELECT node_id FROM nodes WHERE node_id=?1", f.childId)).toBeNull();
368
+ });
369
+ }
370
+
371
+ test("token lifecycle: rename updates verified labels and preserves unrelated credentials", async () => {
372
+ const alias = `life-rename-${stamp}`, nodeId = `n_life_rename_${stamp}`, target = `life-renamed-${stamp}`;
373
+ seedNode(nodeId, alias, NET, ownerId);seedSession(nodeId, alias, NET);
374
+ const unrelated = mint(outsiderId, NET, alias, 0, null);
375
+ const bound = mint(ownerId, NET, victimAlias, 2, nodeId);
376
+ const legacy = mint(ownerId, NET, alias, 0, null);
377
+ const foreign = mint(outsiderId, NET2, alias, 0, null);
378
+ const before = [unrelated,foreign].map(t => tokenName(t.tokenId));
379
+ const prepare = await api(ownerToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: alias, new_alias: target });
380
+ expect(prepare.body.ok).toBe(true);
381
+ expect((await api(ownerToken, "POST", "/api/node-rename/commit", { txn_id: prepare.body.txn_id })).body.ok).toBe(true);
382
+ expect(tokenName(bound.tokenId)).toBe(`node:${target}`);
383
+ expect(tokenName(legacy.tokenId)).toBe(`node:${target}`);
384
+ expect([unrelated,foreign].map(t => tokenName(t.tokenId))).toEqual(before);
385
+ });
386
+
387
+ test("token lifecycle: rename refuses ambiguous node rows before and after prepare", async () => {
388
+ const alias=`life-dup-rename-${stamp}`, nodeId=`n_life_dupr_${stamp}`, target=`life-dup-target-${stamp}`;
389
+ seedNode(nodeId,alias,NET,ownerId);seedSession(nodeId,alias,NET);
390
+ const mine=mint(ownerId,NET,alias,2,nodeId);
391
+ const prepared=await api(ownerToken,"POST","/api/node-rename/prepare",{network_id:NET,old_alias:alias,new_alias:target});expect(prepared.body.ok).toBe(true);
392
+ seedNode(`n_life_dupo_${stamp}`,alias,NET,outsiderId);
393
+ expect((await api(ownerToken,"POST","/api/node-rename/commit",{txn_id:prepared.body.txn_id})).body.error).toBe("node_alias_ambiguous");
394
+ expect((await api(ownerToken,"POST","/api/node-rename/prepare",{network_id:NET,old_alias:alias,new_alias:target})).body.error).toBe("node_alias_ambiguous");
395
+ expect(tokenName(mine.tokenId)).toBe(`node:${alias}`);
396
+ expect(db.get("SELECT status FROM rename_txn WHERE txn_id=?1",prepared.body.txn_id)).toEqual({status:"prepared"});
397
+ await api(ownerToken,"POST","/api/node-rename/abort",{txn_id:prepared.body.txn_id});
398
+ });
399
+
195
400
  beforeAll(async () => {
196
401
  process.env.COMMHUB_UPLOADS_DIR = join(dir, "uploads");
197
402
  process.env.HOST = "127.0.0.1";
@@ -3,6 +3,7 @@ import { getUserNetworkRole } from "./auth.js";
3
3
  import { pushEvent } from "./push.js";
4
4
  import { canRestWriteNetwork } from "./network-scope.js";
5
5
  import { parseDbTimestampMs } from "./db-timestamp.js";
6
+ import { activeNodeTokenIds } from "./node-token-identity.js";
6
7
 
7
8
  type Node = { node_id: string; alias: string; network_id: string; owner_user_id: string | null; hostname: string | null; config_snapshot: string | null };
8
9
  type Binding = { request_id: string; network_id: string; node_id: string; daemon_node_id: string; workdir: string; requested_by: string; status: string; error: string | null };
@@ -41,11 +42,15 @@ function daemonReady(daemon: Node | null, node: Node): string | null {
41
42
  const snap = snapshot(daemon);
42
43
  if (snap.role !== "host_supervisor" || snap.daemon_capabilities?.adopt_capable !== true) return "daemon_not_adopt_capable";
43
44
  if (!node.hostname || daemon.hostname !== node.hostname) return "hostname_mismatch";
44
- const session = db.get<{ last_seen_at: string }>(`SELECT last_seen_at FROM sessions WHERE alias=?1 AND network_id=?2`, daemon.alias, daemon.network_id);
45
+ const session = db.get<{ last_seen_at: string }>(
46
+ `SELECT last_seen_at FROM sessions WHERE alias=?1 AND network_id=?2
47
+ AND (node_id=?3 OR (node_id IS NULL AND NOT EXISTS (
48
+ SELECT 1 FROM nodes n WHERE n.network_id=?2 AND n.alias=?1 AND n.node_id!=?3)))`,
49
+ daemon.alias, daemon.network_id, daemon.node_id,
50
+ );
45
51
  const seen = parseDbTimestampMs(session?.last_seen_at ?? "");
46
52
  if (!Number.isFinite(seen) || Date.now() - seen > 300_000) return "daemon_offline";
47
- const token = db.get(`SELECT token_id FROM api_tokens WHERE name=?1 AND network_id=?2 AND revoked_at IS NULL`, `node:${daemon.alias}`, daemon.network_id);
48
- return token ? null : "daemon_offline";
53
+ return activeNodeTokenIds(daemon.node_id, daemon.network_id).length ? null : "daemon_offline";
49
54
  }
50
55
 
51
56
  export function requestAdopt(ctx: Human, args: { node_id: string; daemon_node_id: string; workdir: string }) {
@@ -0,0 +1,35 @@
1
+ import { db } from "./db.js";
2
+ import { resolveNodeCaller } from "./create-node.js";
3
+
4
+ /** Active credentials belonging to an immutable node, including verified legacy unbound tokens. */
5
+ export function activeNodeTokenIds(nodeId: string, networkId: string): string[] {
6
+ const node = db.get<{ alias: string }>("SELECT alias FROM nodes WHERE node_id=?1 AND network_id=?2", nodeId, networkId);
7
+ if (!node) return [];
8
+ const tokens = db.all<{ token_id: string }>(
9
+ `SELECT token_id FROM api_tokens WHERE network_id=?1 AND scope='network' AND revoked_at IS NULL
10
+ AND (bound_node_id=?2 OR (bound_node_id IS NULL AND name=?3)) ORDER BY token_id`,
11
+ networkId, nodeId, `node:${node.alias}`,
12
+ );
13
+ return tokens.filter(token => {
14
+ const caller = resolveNodeCaller(token.token_id);
15
+ return caller.ok && caller.kind === "node" && caller.nodeId === nodeId && caller.networkId === networkId;
16
+ }).map(token => token.token_id);
17
+ }
18
+
19
+ /** Preserve the dispatch-time freeze; old requests without it use verified node identity. */
20
+ export function revokeNodeTokens(nodeId: string, networkId: string, frozenTokenId: string | null): void {
21
+ let ids: string[];
22
+ if (frozenTokenId) {
23
+ const token = db.get<{ bound_node_id: string | null }>(
24
+ "SELECT bound_node_id FROM api_tokens WHERE token_id=?1 AND network_id=?2 AND scope='network' AND revoked_at IS NULL",
25
+ frozenTokenId, networkId,
26
+ );
27
+ // An immutable binding remains sufficient for revocation even after its node row was removed.
28
+ const caller = token && !token.bound_node_id ? resolveNodeCaller(frozenTokenId) : null;
29
+ ids = token?.bound_node_id === nodeId || (caller?.ok && caller.kind === "node" && caller.nodeId === nodeId && caller.networkId === networkId)
30
+ ? [frozenTokenId] : activeNodeTokenIds(nodeId, networkId);
31
+ } else {
32
+ ids = activeNodeTokenIds(nodeId, networkId);
33
+ }
34
+ for (const id of ids) db.run("UPDATE api_tokens SET revoked_at=datetime('now') WHERE token_id=?1 AND network_id=?2 AND revoked_at IS NULL", [id, networkId]);
35
+ }
package/src/rename.ts CHANGED
@@ -19,6 +19,7 @@ import { canSeeAgent, isAgentRestricted } from "./agent-access";
19
19
  import { pushEvent } from "./push";
20
20
  import { eventBus } from "./event_bus";
21
21
  import { aliasControlCharacter, describeAliasControlCharacter } from "./node-alias-control";
22
+ import { activeNodeTokenIds } from "./node-token-identity.js";
22
23
 
23
24
  export interface RenameResult {
24
25
  ok: boolean;
@@ -109,7 +110,7 @@ export function cleanupCommittedRenameSessions(networkIds: string[] | null = nul
109
110
 
110
111
  // PHASE 1 P3 — create a prepared rename_txn row reserving new_alias.
111
112
  // CAS guard (RFC §4 risk #3 TOCTOU): new_alias must be free both in the
112
- // sessions registry AND among in-flight prepared rename_txn rows.
113
+ // sessions/nodes registries AND among in-flight prepared rename_txn rows.
113
114
  export function prepareRename(
114
115
  userId: string, networkId: string, oldAlias: string, newAlias: string,
115
116
  ): RenameResult {
@@ -127,6 +128,9 @@ export function prepareRename(
127
128
  };
128
129
  }
129
130
  if (oldAlias === newAlias) return { ok: false, error: "old and new alias are identical" };
131
+ if (db.all("SELECT node_id FROM nodes WHERE network_id=?1 AND alias=?2", networkId, oldAlias).length > 1) {
132
+ return { ok: false, error: "node_alias_ambiguous" };
133
+ }
130
134
 
131
135
  // old-alias must exist as a session in this network
132
136
  const oldSession = db.get<any>(
@@ -140,9 +144,9 @@ export function prepareRename(
140
144
  };
141
145
  }
142
146
 
143
- // new-alias must not be taken — in sessions OR reserved by another prepared txn
147
+ // A node can reserve its alias before it has ever reported a session.
144
148
  const newInSessions = db.get<any>(
145
- "SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2", networkId, newAlias);
149
+ "SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2 UNION ALL SELECT 1 FROM nodes WHERE network_id = ?1 AND alias = ?2", networkId, newAlias);
146
150
  if (newInSessions) return { ok: false, error: `alias "${newAlias}" already in use` };
147
151
  const newPrepared = db.get<any>(
148
152
  "SELECT txn_id FROM rename_txn WHERE network_id = ?1 AND new_alias = ?2 AND status = 'prepared'",
@@ -167,11 +171,15 @@ export function commitRename(userId: string, txnId: string): RenameResult {
167
171
  if (txn.status === "committed") return { ok: true, txn_id: txnId }; // idempotent
168
172
  if (txn.status === "aborted") return { ok: false, error: "rename transaction was aborted" };
169
173
  if (!hasWriteAccess(userId, txn.network_id)) return { ok: false, error: "no write access to this network" };
174
+ const nodes = db.all<{ node_id: string }>("SELECT node_id FROM nodes WHERE network_id=?1 AND alias=?2", txn.network_id, txn.old_alias);
175
+ if (nodes.length > 1) return { ok: false, error: "node_alias_ambiguous" };
176
+ // Resolve labels before changing the node alias, so legacy unbound ownership remains verifiable.
177
+ const tokenIds = nodes[0] ? activeNodeTokenIds(nodes[0].node_id, txn.network_id) : [];
170
178
 
171
- // Defense-in-depth: new-alias still free in sessions (prepare already CAS'd,
179
+ // Defense-in-depth: new-alias still free in both registries (prepare already CAS'd,
172
180
  // but a parallel direct registration could have raced in).
173
181
  const conflict = db.get<any>(
174
- "SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2", txn.network_id, txn.new_alias);
182
+ "SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2 UNION ALL SELECT 1 FROM nodes WHERE network_id = ?1 AND alias = ?2", txn.network_id, txn.new_alias);
175
183
  if (conflict) return { ok: false, error: `alias "${txn.new_alias}" was taken since prepare` };
176
184
 
177
185
  const renamedNode = db.transaction(() => {
@@ -182,9 +190,9 @@ export function commitRename(userId: string, txnId: string): RenameResult {
182
190
  db.run(
183
191
  "UPDATE nodes SET alias = ?1, updated_at = datetime('now') WHERE network_id = ?2 AND alias = ?3",
184
192
  [txn.new_alias, txn.network_id, txn.old_alias]);
185
- db.run(
186
- "UPDATE api_tokens SET name = ?1 WHERE network_id = ?2 AND name = ?3",
187
- [`node:${txn.new_alias}`, txn.network_id, `node:${txn.old_alias}`]);
193
+ for (const tokenId of tokenIds) db.run(
194
+ "UPDATE api_tokens SET name = ?1 WHERE token_id = ?2 AND network_id = ?3",
195
+ [`node:${txn.new_alias}`, tokenId, txn.network_id]);
188
196
  db.run(
189
197
  "UPDATE rename_txn SET status = 'committed', committed_at = datetime('now') WHERE txn_id = ?1",
190
198
  [txnId]);
@@ -33,7 +33,7 @@ export interface RestIdentityInput {
33
33
  }
34
34
 
35
35
  export type RestIdentityResult =
36
- | { ok: true; fromSession: string }
36
+ | { ok: true; fromSession: string; nodeId?: string | null }
37
37
  | {
38
38
  ok: false;
39
39
  error: "from_session_identity_mismatch";
@@ -69,10 +69,12 @@ export function resolveUserTaskFromSession(username: string | null | undefined,
69
69
  export function resolveRestFromSession(input: RestIdentityInput): RestIdentityResult {
70
70
  const requested = typeof input.requestedFrom === "string" ? input.requestedFrom.trim() : "";
71
71
  let alias: string | null;
72
+ let nodeId: string | null = null;
72
73
  if (input.token.startsWith("ntok_") && input.tokenId) {
73
74
  const resolved = resolveNodeCaller(input.tokenId);
74
75
  if (resolved.ok) {
75
76
  alias = resolved.alias;
77
+ nodeId = resolved.kind === "node" ? resolved.nodeId : null;
76
78
  } else if (resolved.reason === "not_a_node_token") { // board679-plain-ntok-rest
77
79
  // Name is not `node:` yet. Main signs that as api, and refuses a claim.
78
80
  // not_owner / bound_node_missing / ambiguous stay the hard reject.
@@ -135,5 +137,5 @@ export function resolveRestFromSession(input: RestIdentityInput): RestIdentityRe
135
137
  }
136
138
 
137
139
  // Node token, no claim or a matching one: bind to the token's own alias.
138
- return { ok: true, fromSession: alias };
140
+ return { ok: true, fromSession: alias, ...(input.tokenId ? { nodeId } : {}) };
139
141
  }
package/src/server.ts CHANGED
@@ -3695,7 +3695,9 @@ return Bun.serve({
3695
3695
  const ttlSeconds = (body as any).ttl_seconds || 3600;
3696
3696
  const fromNodeId = restAuth && !requestToken(req).startsWith("ntok_")
3697
3697
  ? null
3698
- : resolveRestNodeIdForAlias(fromSession, taskNetId);
3698
+ : requestToken(req).startsWith("ntok_")
3699
+ ? (identity.nodeId ?? null)
3700
+ : resolveRestNodeIdForAlias(fromSession, taskNetId);
3699
3701
  const targetNodeId = target.session?.node_id ?? null;
3700
3702
  const actualTo = restActualTo(target, taskNetId);
3701
3703
  // #221 — fold top-level `attachments` into `meta.attachments` so
@@ -742,7 +742,6 @@ describe("inbox-enqueue lifecycle_state guard (RFC-027 §2.3 race-free)", () =>
742
742
  const r = await call(tools.send_task, {
743
743
  alias: CHILD_A_ALIAS,
744
744
  task: "racey post-SIGTERM task",
745
- from_session: "sender-x",
746
745
  network_id: NET_A,
747
746
  });
748
747
  expect(r.ok).toBe(false);
package/src/tools.ts CHANGED
@@ -15,6 +15,7 @@ import { assertNodeHealthy } from "./node-health-guard.js";
15
15
  import { noteModelAuthHealth } from "./model-auth-notify.js";
16
16
  import { numberedAliasInClause, parseAliasFilter } from "./alias-filter.js";
17
17
  import { createHash } from "node:crypto";
18
+ import { activeNodeTokenIds, revokeNodeTokens } from "./node-token-identity.js";
18
19
  import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
19
20
  import { sessionTaskOnDispatch } from "./session-task-on-dispatch.js";
20
21
  import { TASK_CONTENT_MAX, sessionTaskPreview } from "./shared/task-content-limit.js";
@@ -58,6 +59,7 @@ import {
58
59
  auditCreateNode,
59
60
  auditCreateNodeStrict,
60
61
  resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
62
+ resolveNodeCaller,
61
63
  } from "./create-node.js";
62
64
  import { decideClone, factsFromRows, parseWorkdirPolicy } from "./clone-node.js";
63
65
  import {
@@ -2007,7 +2009,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2007
2009
  }
2008
2010
 
2009
2011
  console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
2010
- const fromNodeId = humanSender ? null : resolveNodeIdForAlias(from_session, effectiveNetId);
2012
+ const senderIdentity = callerTokenIsNetwork ? resolveNodeCaller(callerTokenId) : null;
2013
+ const fromNodeId = humanSender ? null : callerTokenIsNetwork
2014
+ ? (senderIdentity?.ok && senderIdentity.kind === "node" && senderIdentity.networkId === effectiveNetId ? senderIdentity.nodeId : null)
2015
+ : resolveNodeIdForAlias(from_session, effectiveNetId);
2011
2016
  const targetNodeId = target.session?.node_id ?? null;
2012
2017
  const resolvedActualTo = actualTo(target, effectiveNetId);
2013
2018
  // #500 step 2 — what is already queued on the target, read BEFORE this task is written (advice only, never blocks).
@@ -5396,12 +5401,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5396
5401
  // #1448 finding-5 — 记下该 child **当前活着的** ntok 的 token_id,让 ack 精确按
5397
5402
  // token_id 撤销,而非按 name='node:<alias>' 广撤。后者会在「同 alias 节点在这次
5398
5403
  // delete 未收敛的窗口内被重建(拿到同名新 token)」时,把新 token 一并误撤。这里
5399
- // 在派发时刻定住身份;若此刻没有活 token(异常),存 null,ack 退回按 name 撤(旧语义)。
5400
- const childTokenRow = db.get<{ token_id: string }>(
5401
- `SELECT token_id FROM api_tokens WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
5402
- node.network_id, `node:${node.alias}`,
5403
- );
5404
- const childTokenId = childTokenRow?.token_id ?? null;
5404
+ // 在派发时刻定住经解析器验证的节点身份;旧请求也不按名字广撤。
5405
+ const childTokenId = activeNodeTokenIds(node.node_id, node.network_id)[0] ?? null;
5405
5406
 
5406
5407
  // §4.5 D8 — lifecycle UPDATE + audit INSERTs + request INSERT atomic.
5407
5408
  // PR1.1: use db.transaction() (SQLiteAdapter wraps better-sqlite3's
@@ -5639,24 +5640,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5639
5640
  });
5640
5641
  } else if (status === "stopped" && row.action === "delete") {
5641
5642
  // Revoke the child's ntok, then DELETE the nodes row.
5642
- // #1448 finding-5 — 精确按派发时定住的 child_token_id 撤(镜像
5643
- // ack_create_request 的 token_id 精撤)。旧行(升级前派发,child_token_id
5644
- // 为 NULL)fallback 回按 name='node:<alias>' 撤——旧语义,不引入回归。
5645
- // 按 token_id 撤避免了「同 alias 在本次 delete 窗口内被重建、拿到同名新
5646
- // token」时把新 token 一并误撤。
5647
- if (row.child_token_id) {
5648
- db.run(
5649
- `UPDATE api_tokens SET revoked_at = datetime('now')
5650
- WHERE token_id = ?1 AND revoked_at IS NULL`,
5651
- [row.child_token_id],
5652
- );
5653
- } else {
5654
- db.run(
5655
- `UPDATE api_tokens SET revoked_at = datetime('now')
5656
- WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
5657
- [row.network_id, `node:${row.child_alias}`],
5658
- );
5659
- }
5643
+ // Validate the frozen token's immutable identity; legacy requests
5644
+ // resolve only credentials belonging to this node before deleting it.
5645
+ revokeNodeTokens(row.child_node_id, row.network_id, row.child_token_id);
5660
5646
  db.run(`DELETE FROM nodes WHERE node_id = ?1`, [row.child_node_id]);
5661
5647
  // Same as REST DELETE /api/nodes/:id: without this the sessions row
5662
5648
  // stays "offline" and /api/status lists the deleted node forever.