@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.110",
3
+ "version": "0.9.0-preview.112",
4
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).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/auth.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js"; import { deleteDepartmentsForNetwork, groupTx, removeMemberFromChatGroups } from "./departments.js"; // #419: also ensures network_members.department_id exists (same line on purpose: docs pin auth.ts line numbers)
5
5
  import { WEAK_PASSWORDS } from "./password-dict.js";
6
+ import { checkNodeTokenClaim, legacyNodeHolder } from "./node-token-ownership.js";
6
7
  import { NETWORK_REST_COLUMNS, NETWORK_REST_SELECT, sqlColumns } from "./rest-projections.js";
7
8
  import { deleteAgentGrants, isAgentRestricted } from "./agent-access.js"; import { deleteTaskGrantsForMember, NEW_MEMBER_TASK_ACCESS, type TaskAccessMode } from "./task-access.js"; // 一行两个 import:文档钉着 auth.ts 的行号
8
9
 
@@ -136,7 +137,7 @@ export function register(username: string, password: string, email?: string, dis
136
137
  const userToken = generateUserToken();
137
138
  const userTokenId = generateId("tok");
138
139
  db.run(
139
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
140
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)",
140
141
  [userTokenId, hashToken(userToken), userId, null, "user-login", "user", cleanClientField(opts.client?.label, 64), cleanClientField(opts.client?.userAgent, 256)]
141
142
  );
142
143
 
@@ -144,7 +145,7 @@ export function register(username: string, password: string, email?: string, dis
144
145
  const networkToken = generateNetworkToken();
145
146
  const networkTokenId = generateId("tok");
146
147
  db.run(
147
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
148
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)",
148
149
  [networkTokenId, hashToken(networkToken), userId, networkId, "default-network", "network"]
149
150
  );
150
151
 
@@ -216,7 +217,7 @@ export function login(username: string, password: string, client: SessionClientI
216
217
  const userToken = generateUserToken();
217
218
  const tokenId = generateId("tok");
218
219
  db.run(
219
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
220
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)",
220
221
  [tokenId, hashToken(userToken), user.user_id, null, "user-login", "user", cleanClientField(client.label, 64), cleanClientField(client.userAgent, 256)]
221
222
  );
222
223
 
@@ -246,8 +247,9 @@ export function login(username: string, password: string, client: SessionClientI
246
247
  *
247
248
  * RFC-036: callers that provide nodeId atomically establish the immutable
248
249
  * node-owner binding before the plaintext token is returned. The 3-argument
249
- * legacy form remains available for old clients, but its token has no bound
250
- * node id and therefore cannot consume external-schedule edit intents.
250
+ * name-only form remains available: new identities bind on first registration,
251
+ * and a verified same-holder refresh binds to the existing unique node. This
252
+ * does not fill NULL row owners or grant external-schedule ownership.
251
253
  */
252
254
  export function createNetworkTokenForNode(userId: string, networkId: string, nodeName: string, nodeId?: string): { ok: boolean; token?: string; token_id?: string; node_id?: string; error?: string } {
253
255
  // Verify user is a member of this network with write access
@@ -262,16 +264,18 @@ export function createNetworkTokenForNode(userId: string, networkId: string, nod
262
264
  const tokenId = generateId("tok");
263
265
  try {
264
266
  db.transaction(() => {
267
+ nodeId = checkNodeTokenClaim(userId, networkId, nodeName, nodeId);
265
268
  if (nodeId) {
266
- const existing = db.get<{ network_id: string | null; owner_user_id: string | null }>(
267
- "SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1",
269
+ const existing = db.get<{ network_id: string | null; owner_user_id: string | null; alias: string | null }>(
270
+ "SELECT network_id, owner_user_id, alias FROM nodes WHERE node_id = ?1",
268
271
  nodeId,
269
272
  );
270
273
  if (existing?.network_id && existing.network_id !== networkId) throw new Error("cross_network_node");
274
+ if (existing?.alias && existing.alias !== nodeName) throw new Error("node_owner_mismatch");
271
275
  // Existing pre-RFC-036 rows have no trustworthy owner anchor. A token
272
276
  // refresh must not turn knowledge of a legacy node_id into ownership.
273
- // They remain read-only until a separately authorized migration.
274
- if (existing && !existing.owner_user_id) throw new Error("node_owner_unclaimed");
277
+ // A same-holder refresh binds the token, without filling the row owner.
278
+ if (existing && !existing.owner_user_id && !legacyNodeHolder(userId, networkId, nodeName, nodeId)) throw new Error("node_owner_unclaimed");
275
279
  if (existing?.owner_user_id && existing.owner_user_id !== userId) throw new Error("node_owner_mismatch");
276
280
  db.run(
277
281
  `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
@@ -280,7 +284,7 @@ export function createNetworkTokenForNode(userId: string, networkId: string, nod
280
284
  node_name = COALESCE(nodes.node_name, ?2),
281
285
  alias = COALESCE(nodes.alias, ?2),
282
286
  network_id = COALESCE(nodes.network_id, ?3),
283
- owner_user_id = COALESCE(nodes.owner_user_id, ?4),
287
+ owner_user_id = nodes.owner_user_id,
284
288
  updated_at = datetime('now')`,
285
289
  [nodeId, nodeName, networkId, userId],
286
290
  );
@@ -293,7 +297,7 @@ export function createNetworkTokenForNode(userId: string, networkId: string, nod
293
297
  }
294
298
  }
295
299
  db.run(
296
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id) VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6)",
300
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6, 2)",
297
301
  [tokenId, hashToken(token), userId, networkId, `node:${nodeName}`, nodeId ?? null],
298
302
  );
299
303
  });
@@ -505,7 +509,7 @@ export function createToken(userId: string, name: string, networkId?: string): {
505
509
  const token = generateToken();
506
510
  const tokenId = generateId("tok");
507
511
  db.run(
508
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
512
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)",
509
513
  [tokenId, hashToken(token), userId, networkId || null, name, "full"]
510
514
  );
511
515
  return { ok: true, token, token_id: tokenId };
@@ -520,7 +524,7 @@ export function issueUserToken(userId: string, name = "user-login"): { token: st
520
524
  const token = generateUserToken();
521
525
  const tokenId = generateId("tok");
522
526
  db.run(
523
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, NULL, ?4, 'user')",
527
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, node_identity_epoch) VALUES (?1, ?2, ?3, NULL, ?4, 'user', 1)",
524
528
  [tokenId, hashToken(token), userId, name]
525
529
  );
526
530
  return { token, token_id: tokenId };
@@ -748,7 +752,7 @@ export function joinByInvite(inviteCode: string, userId: string): { ok: boolean;
748
752
  // Auto-create a token for this network
749
753
  const token = generateToken();
750
754
  const tokenId = generateId("tok");
751
- db.run("INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
755
+ db.run("INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)",
752
756
  [tokenId, hashToken(token), userId, invite.network_id, "auto-join", "full"]);
753
757
  return { ok: true, network_id: invite.network_id, role: invite.role };
754
758
  }
@@ -0,0 +1,121 @@
1
+ import { beforeAll, afterAll, expect, test } from "bun:test";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { preflightCodexStart } from "../../agent-node/src/runtime/adopt-codex-start-preflight.js";
4
+
5
+ process.env.COMMHUB_DB ||= `${mkdtempSync("/tmp/binding-http-")}/hub.db`;
6
+ process.env.COMMHUB_AUTH_TOKEN ||= "binding-master-fixture";
7
+ let db:any, hub:ReturnType<typeof Bun.serve>, base:string, owner:any, other:any;
8
+ const daemon="n_binding_daemon", child="n_binding_child";
9
+ let token:string, wrong:string, cross:string, ordinary:string, revoked:string;
10
+ async function mcp(credential:string,name:string,args:Record<string,unknown>={}) {
11
+ const res=await fetch(`${base}/mcp`, {method:"POST",headers:{Authorization:`Bearer ${credential}`,
12
+ "Content-Type":"application/json",Accept:"application/json, text/event-stream","MCP-Protocol-Version":"2025-03-26"},
13
+ body:JSON.stringify({jsonrpc:"2.0",id:1,method:"tools/call",params:{name,arguments:args}})});
14
+ const raw=await res.text();
15
+ if(res.status!==200)return {ok:false,http_status:res.status};
16
+ const lines=raw.split("\n").filter(s=>s.startsWith("data:"));
17
+ const payload=JSON.parse(lines.length?lines.at(-1)!.slice(5):raw);
18
+ return payload.result?.content?.[0]?.text ? JSON.parse(payload.result.content[0].text) : {ok:false,error:payload.error};
19
+ }
20
+ const list=(t=token)=>mcp(t,"list_my_children");
21
+ const request=()=>mcp(owner.token,"request_adopt_node",{node_id:child,daemon_node_id:daemon,workdir:"/fixture/project"});
22
+ const ack=(id:string,status="adopted")=>mcp(token,"ack_adopt_request",{request_id:id,status});
23
+ const unadopt=()=>mcp(owner.token,"unadopt_node",{node_id:child});
24
+ async function activate(){const r=await request();expect(r.ok).toBe(true);expect((await ack(r.request_id)).status).toBe("active");return r.request_id;}
25
+ beforeAll(async()=>{
26
+ ({db}=await import("./db.js"));
27
+ const {register}=await import("./auth.js");
28
+ owner=register("binding_owner","Fixture-Strong1!");other=register("binding_other","Fixture-Strong1!");
29
+ expect(owner.ok&&other.ok).toBe(true);
30
+ db.run("UPDATE users SET role='user' WHERE user_id=?1",[other.user.user_id]);
31
+ db.run("INSERT INTO network_members(network_id,user_id,role) VALUES(?1,?2,'member')",[owner.network_id,other.user.user_id]);
32
+ const {generateNetworkToken,hashToken}=await import("./db.js");
33
+ function node(id:string,network:string,user:string,supervisor=true){
34
+ db.run("INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,hostname,config_snapshot,lifecycle_state) VALUES(?1,?1,?1,?2,?3,'fixture-host',?4,'active')",
35
+ [id,network,user,JSON.stringify(supervisor?{role:"host_supervisor",daemon_capabilities:{adopt_capable:true}}:{})]);
36
+ db.run("INSERT INTO sessions(resume_id,alias,node_id,network_id,status,last_seen_at) VALUES(?1,?1,?1,?2,'idle',datetime('now'))",[id,network]);
37
+ const raw=generateNetworkToken();
38
+ db.run("INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash) VALUES(?1,?2,?3,'network',?4,?5)",[id,user,network,`node:${id}`,hashToken(raw)]);
39
+ return raw;
40
+ }
41
+ token=node(daemon,owner.network_id,owner.user.user_id);
42
+ wrong=node("n_binding_other",owner.network_id,other.user.user_id);
43
+ cross=node("n_binding_cross",other.network_id,other.user.user_id);
44
+ node(child,owner.network_id,owner.user.user_id,false);
45
+ ordinary=generateNetworkToken();revoked=generateNetworkToken();
46
+ for(const [id,raw,name,dead] of [["ordinary",ordinary,"general",null],["revoked",revoked,`node:${daemon}`,"2020-01-01"]])
47
+ db.run("INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash,revoked_at) VALUES(?1,?2,?3,'network',?4,?5,?6)",[id,owner.user.user_id,owner.network_id,name,hashToken(raw as string),dead]);
48
+ db.run("INSERT INTO nodes(node_id,node_name,alias,network_id,lifecycle_state) VALUES('node_binding_created','created','created',?1,'active')",[owner.network_id]);
49
+ db.run("INSERT INTO node_create_requests(request_id,daemon_node_id,child_name,network_id,runtime,flags_json,env_keys,status,created_at,created_by_token) VALUES('cr_binding_created',?1,'created',?2,'codex-sdk','{}','[]','succeeded',1,'fixture')",[daemon,owner.network_id]);
50
+ const {bootServer}=await import("./server.js");hub=bootServer({port:0,hostname:"127.0.0.1"});base=`http://127.0.0.1:${hub.port}`;
51
+ },60000);
52
+ afterAll(()=>hub?.stop(true));
53
+
54
+ test("generation projection and unchanged created row",async()=>{
55
+ const id=await activate();
56
+ const r=await list();expect(r.ok).toBe(true);
57
+ expect((await mcp(token,"get_adopt_request",{request_id:id})).error).toBe("request_not_pending");
58
+ expect(r.children.find((n:any)=>n.child_node_id===child)).toEqual({child_node_id:child,alias:child,lifecycle_state:"active",managed:"adopted",binding_request_id:id});
59
+ expect(r.children.find((n:any)=>n.child_node_id==="node_binding_created")).toEqual({child_node_id:"node_binding_created",alias:"created",lifecycle_state:"active"});
60
+ // Old consumers continue reading exactly the same fields; additive only.
61
+ expect(r.children.map(({child_node_id,alias,lifecycle_state,managed}:any)=>({child_node_id,alias,lifecycle_state,managed}))).toContainEqual({child_node_id:child,alias:child,lifecycle_state:"active",managed:"adopted"});
62
+ expect((await unadopt()).ok).toBe(true);
63
+ });
64
+ test("token boundary denies humans general revoked and other owners",async()=>{
65
+ await activate();
66
+ for(const t of [owner.token,other.token,ordinary,revoked])expect((await list(t)).ok).toBe(false);
67
+ for(const t of [wrong,cross]){const r=await list(t);expect(r.ok).toBe(true);expect(r.children).toEqual([]);}
68
+ expect((await unadopt()).ok).toBe(true);
69
+ });
70
+ test("child node token sees an empty list, not its parent binding",async()=>{
71
+ const id=await activate();
72
+ try {
73
+ const issued=await fetch(`${base}/api/auth/node-token`,{method:"POST",
74
+ headers:{Authorization:`Bearer ${owner.token}`,"Content-Type":"application/json"},
75
+ body:JSON.stringify({network_id:owner.network_id,node_name:child,node_id:child})});
76
+ expect(issued.status).toBe(200);
77
+ const credential=await issued.json() as any;
78
+ expect(credential.ok).toBe(true);
79
+ expect(typeof credential.token).toBe("string");
80
+ // Same owner and network do not confer the parent's daemon identity.
81
+ expect((await list()).children.find((n:any)=>n.child_node_id===child).binding_request_id).toBe(id);
82
+ expect(await list(credential.token)).toEqual({ok:true,count:0,children:[]});
83
+ } finally {expect((await unadopt()).ok).toBe(true);}
84
+ });
85
+ test("network predicate rejects corrupted cross-network binding sentinel",async()=>{
86
+ const id=await activate();
87
+ // Deliberately inconsistent test DB: daemon stays in A, binding and child in B.
88
+ // This makes the network predicate independently load-bearing, HTTP still real.
89
+ try {
90
+ db.run("UPDATE node_daemon_bindings SET network_id=?1 WHERE request_id=?2",[other.network_id,id]);
91
+ db.run("UPDATE nodes SET network_id=?1 WHERE node_id=?2",[other.network_id,child]);
92
+ expect((await list()).children.some((n:any)=>n.child_node_id===child)).toBe(false);
93
+ } finally {
94
+ db.run("UPDATE node_daemon_bindings SET network_id=?1 WHERE request_id=?2",[owner.network_id,id]);
95
+ db.run("UPDATE nodes SET network_id=?1 WHERE node_id=?2",[owner.network_id,child]);await unadopt();
96
+ }
97
+ });
98
+ test("node join rejects mismatched node network sentinel",async()=>{
99
+ await activate();
100
+ try {db.run("UPDATE nodes SET network_id=?1 WHERE node_id=?2",[other.network_id,child]);expect((await list()).children.some((n:any)=>n.child_node_id===child)).toBe(false);}
101
+ finally {db.run("UPDATE nodes SET network_id=?1 WHERE node_id=?2",[owner.network_id,child]);await unadopt();}
102
+ });
103
+ test("non-active bindings never project generation",async()=>{
104
+ const r=await request();expect(r.ok).toBe(true);
105
+ expect((await list()).children.some((n:any)=>n.child_node_id===child)).toBe(false);
106
+ expect((await ack(r.request_id,"refused")).status).toBe("refused");
107
+ expect((await list()).children.some((n:any)=>n.child_node_id===child)).toBe(false);
108
+ await activate();await unadopt();expect((await list()).children.some((n:any)=>n.child_node_id===child)).toBe(false);
109
+ });
110
+ test("revocation rotates generation and old acknowledgements cannot revive",async()=>{
111
+ const r1=await activate();await unadopt();
112
+ expect((await ack(r1)).error).toBe("request_not_pending");
113
+ const r2=await request();expect(r2.ok).toBe(true);expect(r2.request_id).not.toBe(r1);
114
+ expect((await list()).children.some((n:any)=>n.child_node_id===child)).toBe(false);
115
+ expect((await ack(r2.request_id)).status).toBe("active");
116
+ const current=(await list()).children.find((n:any)=>n.child_node_id===child).binding_request_id;
117
+ expect(current).toBe(r2.request_id);expect(current).not.toBe(r1);
118
+ // Real HTTP authority feeds the existing daemon gate; later probes must not run.
119
+ for(const authority of [current,undefined])await expect(preflightCodexStart({request_id:r1} as any,{} as any,{} as any,authority,()=>true)).rejects.toThrow("adopt_codex_binding_generation_unproven");
120
+ await unadopt();
121
+ });
@@ -232,40 +232,91 @@ export type DaemonResolveResult =
232
232
  | { ok: true; daemonNodeId: string; daemonAlias: string; networkId: string }
233
233
  | { ok: false; error: string };
234
234
 
235
+ export type NodeCallerResolution =
236
+ | { ok: true; kind: "node"; nodeId: string; alias: string; networkId: string }
237
+ | { ok: true; kind: "unregistered"; alias: string; networkId: string }
238
+ | { ok: false; reason: "not_a_node_token" | "bound_node_missing" | "ambiguous" | "not_owner" };
239
+
240
+ type NodeCallerRow = { node_id: string; alias: string; network_id: string; owner_user_id: string | null };
241
+
242
+ // nodes has no unique (network, alias). An unbound token that hits several
243
+ // rows is not rejected outright: the row owned by the token's issuer wins;
244
+ // if that is still not unique, the node_id on the latest session for the
245
+ // alias wins. Still not unique, then refuse.
246
+ function disambiguateUnboundAlias(
247
+ rows: NodeCallerRow[],
248
+ userId: string | null,
249
+ alias: string,
250
+ networkId: string,
251
+ ): NodeCallerRow | undefined {
252
+ const owned = userId ? rows.filter((row) => row.owner_user_id === userId) : [];
253
+ if (owned.length === 1) return owned[0]; // board679-unbound-dup
254
+ const pool = owned.length > 1 ? owned : rows;
255
+ const recent = db.get<{ node_id: string | null }>(
256
+ `SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND node_id IS NOT NULL ORDER BY updated_at DESC LIMIT 1`,
257
+ alias,
258
+ networkId,
259
+ );
260
+ const hit = recent?.node_id ? pool.filter((row) => row.node_id === recent.node_id) : [];
261
+ return hit.length === 1 ? hit[0] : undefined;
262
+ }
263
+
264
+ // One lookup for every caller-identity entry. The token name is a hint.
265
+ // bound_node_id is the row: if that row is gone, refuse — do not look up by name.
266
+ // An unbound token with no row in its network stays the token name. First
267
+ // registration (report_status) still depends on that; sending and subscribing
268
+ // do not get a second exception.
269
+ export function resolveNodeCaller(tokenId: string | null | undefined): NodeCallerResolution {
270
+ if (!tokenId) return { ok: false, reason: "not_a_node_token" };
271
+ const tokRow = db.get<{ name: string; network_id: string | null; user_id: string | null; bound_node_id: string | null; node_identity_epoch: number }>(
272
+ `SELECT name, network_id, user_id, bound_node_id, node_identity_epoch FROM api_tokens WHERE token_id = ?1 AND revoked_at IS NULL`,
273
+ tokenId,
274
+ );
275
+ if (!tokRow || !tokRow.name || !tokRow.name.startsWith("node:") || !tokRow.network_id) {
276
+ return { ok: false, reason: "not_a_node_token" };
277
+ }
278
+ const tokenAlias = tokRow.name.slice(5);
279
+ type DaemonRow = { node_id: string; alias: string; network_id: string; owner_user_id: string | null };
280
+ const rows = tokRow.bound_node_id
281
+ ? db.all<DaemonRow>(`SELECT node_id, alias, network_id, owner_user_id FROM nodes WHERE node_id = ?1 AND network_id = ?2`, tokRow.bound_node_id, tokRow.network_id)
282
+ : db.all<DaemonRow>(`SELECT node_id, alias, network_id, owner_user_id FROM nodes WHERE alias = ?1 AND network_id = ?2`, tokenAlias, tokRow.network_id);
283
+ const nodeRow = rows.length === 1
284
+ ? rows[0]
285
+ : (!tokRow.bound_node_id && rows.length > 1
286
+ ? disambiguateUnboundAlias(rows, tokRow.user_id, tokenAlias, tokRow.network_id)
287
+ : undefined);
288
+ // board679-bound-row-missing: a bound token with no row is refused. Do not look up by name.
289
+ if (tokRow.bound_node_id && !nodeRow) return { ok: false, reason: "bound_node_missing" };
290
+ if (!nodeRow && rows.length === 0) {
291
+ return { ok: true, kind: "unregistered", alias: tokenAlias, networkId: tokRow.network_id };
292
+ }
293
+ if (!nodeRow) return { ok: false, reason: "ambiguous" };
294
+ // Legacy unbound tokens keep their owner's authority. A token name is only a hint.
295
+ const ownsNode = !!tokRow.user_id && tokRow.user_id === nodeRow.owner_user_id;
296
+ const boundToNode = tokRow.bound_node_id === nodeRow.node_id;
297
+ const legacyOwnerless = nodeRow.owner_user_id === null && tokRow.node_identity_epoch === 0;
298
+ if (!ownsNode && !boundToNode && !legacyOwnerless) return { ok: false, reason: "not_owner" };
299
+ return { ok: true, kind: "node", nodeId: nodeRow.node_id, alias: nodeRow.alias, networkId: nodeRow.network_id };
300
+ }
301
+
235
302
  export function resolveCallerDaemonTokenBound(opts: {
236
303
  callerTokenIsNetwork: boolean;
237
304
  callerTokenId: string | null | undefined;
238
305
  enforceNetworkId: string | null | undefined;
239
306
  }): DaemonResolveResult {
240
- if (!opts.callerTokenIsNetwork || !opts.callerTokenId) {
241
- return { ok: false, error: "caller_not_a_daemon" };
242
- }
243
- if (!opts.enforceNetworkId) {
244
- return { ok: false, error: "caller_not_a_daemon" };
245
- }
246
- const tokRow = db.get<{ name: string; network_id: string | null }>(
247
- `SELECT name, network_id FROM api_tokens WHERE token_id = ?1 AND revoked_at IS NULL`,
248
- opts.callerTokenId,
249
- );
250
- if (!tokRow || !tokRow.name || !tokRow.name.startsWith("node:")) {
251
- return { ok: false, error: "caller_not_a_daemon" };
252
- }
253
- if (tokRow.network_id !== opts.enforceNetworkId) {
307
+ if (!opts.callerTokenIsNetwork || !opts.callerTokenId || !opts.enforceNetworkId) {
254
308
  return { ok: false, error: "caller_not_a_daemon" };
255
309
  }
256
- const tokenAlias = tokRow.name.slice(5);
257
- const nodeRow = db.get<{ node_id: string; alias: string; network_id: string }>(
258
- `SELECT node_id, alias, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2 LIMIT 1`,
259
- tokenAlias, tokRow.network_id,
260
- );
261
- if (!nodeRow) {
310
+ const resolved = resolveNodeCaller(opts.callerTokenId);
311
+ // No row yet is not a daemon. First registration stays on report_status.
312
+ if (!resolved.ok || resolved.kind !== "node" || resolved.networkId !== opts.enforceNetworkId) {
262
313
  return { ok: false, error: "caller_not_a_daemon" };
263
314
  }
264
315
  return {
265
316
  ok: true,
266
- daemonNodeId: nodeRow.node_id,
267
- daemonAlias: nodeRow.alias,
268
- networkId: nodeRow.network_id,
317
+ daemonNodeId: resolved.nodeId,
318
+ daemonAlias: resolved.alias,
319
+ networkId: resolved.networkId,
269
320
  };
270
321
  }
271
322
 
@@ -0,0 +1,231 @@
1
+ import { beforeAll, afterAll, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, readdirSync } from "node:fs";
3
+ import { nodeTokenReissueError } from "../../agent-network/src/cli-errors";
4
+
5
+ process.env.COMMHUB_DB ||= `${mkdtempSync("/tmp/owner678-")}/hub.db`;
6
+ let db: any, hub: ReturnType<typeof Bun.serve>, base: string;
7
+ let owner: any, member: any, outsider: any, legacy: string, mismatched: string, bound: string;
8
+ let wrongBound: string, ownerless: string, revoked: string, crossNetwork: string, requestId: string;
9
+ const daemon = "n_owner_fixture_daemon", child = "n_owner_fixture_child";
10
+
11
+ async function call(token: string, name: string, args: Record<string, unknown> = {}) {
12
+ const response = await fetch(`${base}/mcp`, {
13
+ method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json",
14
+ Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
15
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
16
+ });
17
+ expect(response.status).toBe(200);
18
+ const raw = await response.text(), lines = raw.split("\n").filter(line => line.startsWith("data:"));
19
+ const message = JSON.parse(lines.length ? lines.at(-1)!.slice(5) : raw);
20
+ expect(message.error).toBeUndefined();
21
+ return JSON.parse(message.result.content[0].text);
22
+ }
23
+ async function mint(token: string, network: string, name: string, nodeId?: string) {
24
+ const response = await fetch(`${base}/api/auth/node-token`, { method: "POST",
25
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
26
+ body: JSON.stringify({ network_id: network, node_name: name, ...(nodeId ? { node_id: nodeId } : {}) }),
27
+ });
28
+ return { status: response.status, body: await response.json() as any };
29
+ }
30
+ beforeAll(async () => {
31
+ ({ db } = await import("./db.js"));
32
+ const { register } = await import("./auth.js");
33
+ const { generateNetworkToken, hashToken } = await import("./db.js");
34
+ owner = register("owner678", "Fixture-Strong1!");
35
+ member = register("member678", "Fixture-Strong1!");
36
+ outsider = register("outside678", "Fixture-Strong1!");
37
+ db.run("UPDATE users SET role='user' WHERE user_id IN (?1,?2)", [member.user.user_id, outsider.user.user_id]);
38
+ db.run("INSERT INTO network_members(network_id,user_id,role) VALUES(?1,?2,'member')", [owner.network_id, member.user.user_id]);
39
+ for (const id of [daemon, child, "n_ownerless_fixture"]) {
40
+ db.run(`INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,hostname,config_snapshot,lifecycle_state)
41
+ VALUES(?1,?1,?1,?2,?3,'fixture-host',?4,'active')`, [id, owner.network_id,
42
+ id === "n_ownerless_fixture" ? null : owner.user.user_id,
43
+ JSON.stringify(id === daemon ? { role: "host_supervisor", daemon_capabilities: { adopt_capable: true } } : {})]);
44
+ }
45
+ db.run("INSERT INTO sessions(resume_id,alias,node_id,network_id,status,last_seen_at) VALUES(?1,?1,?1,?2,'idle',datetime('now'))", [daemon, owner.network_id]);
46
+ function credential(label: string, user: string, node: string | null = null, alias = daemon, network = owner.network_id) {
47
+ const token = generateNetworkToken();
48
+ db.run(`INSERT INTO api_tokens(token_id,user_id,network_id,scope,name,token_hash,bound_node_id)
49
+ VALUES(?1,?2,?3,'network',?4,?5,?6)`, [label, user, network, `node:${alias}`, hashToken(token), node]);
50
+ return token;
51
+ }
52
+ legacy = credential("owner678_legacy", owner.user.user_id);
53
+ mismatched = credential("owner678_mismatch", member.user.user_id);
54
+ bound = credential("owner678_bound", member.user.user_id, daemon);
55
+ wrongBound = credential("owner678_wrong_bound", member.user.user_id, "n_absent_bound");
56
+ ownerless = credential("owner678_ownerless", member.user.user_id, null, "n_ownerless_fixture");
57
+ db.run("UPDATE api_tokens SET node_identity_epoch=1 WHERE token_id='owner678_ownerless'");
58
+ revoked = credential("owner678_revoked", owner.user.user_id);
59
+ db.run("UPDATE api_tokens SET revoked_at=datetime('now') WHERE token_id='owner678_revoked'");
60
+ crossNetwork = credential("owner678_cross", outsider.user.user_id, daemon, daemon, outsider.network_id);
61
+ const { bootServer } = await import("./server.js");
62
+ hub = bootServer({ port: 0, hostname: "127.0.0.1" }); base = `http://127.0.0.1:${hub.port}`;
63
+ const request = await call(owner.token, "request_adopt_node", { node_id: child, daemon_node_id: daemon, workdir: "/fixture/project" });
64
+ expect(request.ok).toBe(true); requestId = request.request_id;
65
+ }, 60000);
66
+ afterAll(() => hub?.stop(true));
67
+
68
+ test("name-only issuance checks existing ownership atomically", async () => {
69
+ const count = () => db.get("SELECT COUNT(*) AS n FROM api_tokens").n;
70
+ const before = count();
71
+ expect(await mint(member.token, owner.network_id, daemon)).toEqual({ status: 400, body: { ok: false, error: "node_owner_mismatch" } });
72
+ expect(await mint(owner.token, owner.network_id, "n_ownerless_fixture")).toEqual({ status: 400, body: { ok: false, error: "node_owner_mismatch" } });
73
+ expect(count()).toBe(before);
74
+ // Legacy owner refresh and a new node name remain supported.
75
+ const refresh = await mint(owner.token, owner.network_id, daemon);
76
+ expect(refresh.status).toBe(200);
77
+ expect((await call(refresh.body.token, "list_my_children")).ok).toBe(true);
78
+ expect((await mint(member.token, owner.network_id, "fresh678")).status).toBe(200);
79
+ expect((await mint(member.token, owner.network_id, daemon, daemon)).body.error).toBe("node_owner_mismatch");
80
+ });
81
+
82
+ // Every daemon-facing handler must fail at the shared identity boundary,
83
+ // not incidentally at request lookup or argument validation.
84
+ const endpoints: [string, Record<string, unknown>][] = [
85
+ ["list_my_pending_create_requests", {}], ["list_my_pending_lifecycle_requests", {}],
86
+ ["get_create_request", { request_id: "cr_fixture_absent" }],
87
+ ["ack_create_request", { request_id: "cr_fixture_absent", status: "started" }],
88
+ ["get_stop_request", { request_id: "sr_fixture_absent" }],
89
+ ["ack_stop_request", { request_id: "sr_fixture_absent", status: "stopped" }],
90
+ ["get_start_request", { request_id: "start_fixture_absent" }],
91
+ ["ack_start_request", { request_id: "start_fixture_absent", status: "started" }],
92
+ ["list_my_children", {}], ["get_adopt_request", {}], ["ack_adopt_request", { status: "adopted" }],
93
+ ["get_probe_request", { probe_id: "probe_fixture_absent" }],
94
+ ["ack_probe_request", { probe_id: "probe_fixture_absent", status: "ok", latency_ms: 1 }],
95
+ ];
96
+ for (const [name, arguments_] of endpoints) {
97
+ test(`daemon identity boundary: ${name}`, async () => {
98
+ const args = name.includes("adopt_request") ? { ...arguments_, request_id: requestId } : arguments_;
99
+ for (const token of [mismatched, wrongBound, ownerless, owner.token, member.token]) {
100
+ expect(await call(token, name, args)).toMatchObject({ ok: false, error: "caller_not_a_daemon" });
101
+ }
102
+ expect(db.get("SELECT status FROM node_daemon_bindings WHERE request_id=?1", requestId).status).toBe("pending");
103
+ });
104
+ }
105
+
106
+ test("legacy owner and exact bound identity remain supported", async () => {
107
+ expect(db.get("SELECT bound_node_id FROM api_tokens WHERE token_id='owner678_legacy'").bound_node_id).toBeNull();
108
+ for (const token of [legacy, bound]) {
109
+ expect((await call(token, "list_my_children")).ok).toBe(true);
110
+ expect((await call(token, "list_my_pending_create_requests")).ok).toBe(true);
111
+ expect((await call(token, "list_my_pending_lifecycle_requests")).ok).toBe(true);
112
+ expect((await call(token, "get_adopt_request", { request_id: requestId })).ok).toBe(true);
113
+ }
114
+ // An explicit binding does not bypass the network or revocation boundaries.
115
+ expect(await call(crossNetwork, "list_my_children")).toMatchObject({ ok: false, error: "caller_not_a_daemon" });
116
+ const denied = await fetch(`${base}/mcp`, { method: "POST", headers: { Authorization: `Bearer ${revoked}` } });
117
+ expect(denied.status).toBe(401);
118
+ expect(await call(legacy, "ack_adopt_request", { request_id: requestId, status: "adopted" })).toMatchObject({ ok: true, status: "active" });
119
+ expect((await call(legacy, "list_my_children")).children).toContainEqual({ child_node_id: child, alias: child, lifecycle_state: "active", managed: "adopted", binding_request_id: requestId });
120
+ const stop = await call(owner.token, "stop_node", { node_id: child, network_id: owner.network_id });
121
+ expect(stop.ok).toBe(true);
122
+ expect((await call(legacy, "get_stop_request", { request_id: stop.request_id })).ok).toBe(true);
123
+ expect((await call(legacy, "ack_stop_request", { request_id: stop.request_id, status: "stopped" })).ok).toBe(true);
124
+ const start = await call(owner.token, "start_node", { node_id: child, network_id: owner.network_id });
125
+ expect(start.ok).toBe(true);
126
+ expect((await call(legacy, "get_start_request", { request_id: start.request_id })).ok).toBe(true);
127
+ expect(await call(legacy, "ack_start_request", { request_id: start.request_id, status: "started", child_pid: 4321 })).toMatchObject({ ok: true, status: "started" });
128
+ });
129
+
130
+ test("ownerless legacy daemon remains supported without rewriting its owner", async () => {
131
+ db.run("UPDATE api_tokens SET node_identity_epoch=0 WHERE token_id='owner678_ownerless'");
132
+ try {
133
+ expect((await call(ownerless, "list_my_children")).ok).toBe(true);
134
+ const refresh = await mint(member.token, owner.network_id, "n_ownerless_fixture");
135
+ expect(refresh.status).toBe(200);
136
+ expect(refresh.body.node_id).toBe("n_ownerless_fixture");
137
+ expect((await call(refresh.body.token, "list_my_children")).ok).toBe(true);
138
+ const withId = await mint(member.token, owner.network_id, "n_ownerless_fixture", "n_ownerless_fixture");
139
+ expect(withId.status).toBe(200);
140
+ expect(db.get("SELECT owner_user_id FROM nodes WHERE node_id='n_ownerless_fixture'").owner_user_id).toBeNull();
141
+ expect((await mint(owner.token, owner.network_id, "n_ownerless_fixture")).status).toBe(400);
142
+ } finally { db.run("UPDATE api_tokens SET node_identity_epoch=1 WHERE token_id='owner678_ownerless'"); }
143
+ });
144
+
145
+ test("new name-only registration binds once and its holder can refresh with either shape", async () => {
146
+ const first = await mint(member.token, owner.network_id, "refresh_fixture");
147
+ expect(first.status).toBe(200);
148
+ expect(db.get("SELECT node_identity_epoch FROM api_tokens WHERE token_id=?1", first.body.token_id).node_identity_epoch).toBe(2);
149
+ const result = await call(first.body.token, "report_status", {
150
+ resume_id: "refresh-fixture-session", alias: "refresh_fixture", status: "idle", node_id: "n_refresh_fixture",
151
+ });
152
+ expect(result.ok).toBe(true);
153
+ expect(db.get("SELECT bound_node_id FROM api_tokens WHERE token_id=?1", first.body.token_id).bound_node_id).toBe("n_refresh_fixture");
154
+ expect(db.get("SELECT owner_user_id FROM nodes WHERE node_id='n_refresh_fixture'").owner_user_id).toBeNull();
155
+ for (const id of [undefined, "n_refresh_fixture"]) {
156
+ const refresh = await mint(member.token, owner.network_id, "refresh_fixture", id);
157
+ expect(refresh.status).toBe(200);
158
+ expect(refresh.body.node_id).toBe("n_refresh_fixture");
159
+ expect((await call(refresh.body.token, "list_my_children")).ok).toBe(true);
160
+ }
161
+ expect((await mint(owner.token, owner.network_id, "refresh_fixture")).status).toBe(400);
162
+ });
163
+
164
+ test("fresh ID cannot mint another node alias", async () => {
165
+ const before = db.get("SELECT COUNT(*) AS n FROM api_tokens").n;
166
+ expect((await mint(member.token, owner.network_id, daemon, "n_fresh_impostor")).status).toBe(400);
167
+ expect(db.get("SELECT node_id FROM nodes WHERE node_id='n_fresh_impostor'")).toBeNull();
168
+ expect(db.get("SELECT COUNT(*) AS n FROM api_tokens").n).toBe(before);
169
+ expect((await call(legacy, "list_my_children")).ok).toBe(true);
170
+ });
171
+
172
+ test("duplicate legacy aliases use bound IDs and an unbound token follows its owner", async () => {
173
+ // Historical duplicate rows: the member's row was inserted first. Neither
174
+ // lookup order nor alias can redirect an exact-bound token. An unbound
175
+ // token resolves to the row its issuer owns.
176
+ const { hashToken, generateNetworkToken } = await import("./db.js");
177
+ const tokens: string[] = [];
178
+ for (const [id, user] of [["n_duplicate_first", member.user.user_id], ["n_duplicate_owner", owner.user.user_id]]) {
179
+ db.run("INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id) VALUES(?1,'duplicate_fixture','duplicate_fixture',?2,?3)", [id, owner.network_id, user]);
180
+ const token = generateNetworkToken(); tokens.push(token);
181
+ db.run("INSERT INTO api_tokens(token_id,token_hash,user_id,network_id,name,scope,bound_node_id) VALUES(?1,?2,?3,?4,'node:duplicate_fixture','network',?1)", [id, hashToken(token), user, owner.network_id]);
182
+ db.run("INSERT INTO node_daemon_bindings(request_id,network_id,node_id,daemon_node_id,workdir,requested_by,status,created_at,updated_at) VALUES(?1,?2,?1,?1,'/fixture',?3,'pending',1,1)", [id, owner.network_id, user]);
183
+ }
184
+ for (let i = 0; i < tokens.length; i++) {
185
+ const result = await call(tokens[i], "list_my_children");
186
+ expect(result.ok).toBe(true);
187
+ const ownId = i === 0 ? "n_duplicate_first" : "n_duplicate_owner";
188
+ expect((await call(tokens[i], "get_adopt_request", { request_id: ownId })).ok).toBe(true);
189
+ expect((await call(tokens[i], "get_adopt_request", { request_id: i === 0 ? "n_duplicate_owner" : "n_duplicate_first" })).ok).toBe(false);
190
+ }
191
+ db.run("UPDATE api_tokens SET bound_node_id=NULL WHERE token_id='n_duplicate_owner'");
192
+ expect((await call(tokens[1], "list_my_children")).ok).toBe(true);
193
+ expect((await call(tokens[1], "get_adopt_request", { request_id: "n_duplicate_owner" })).ok).toBe(true);
194
+ expect((await call(tokens[1], "get_adopt_request", { request_id: "n_duplicate_first" })).ok).toBe(false);
195
+ db.run("UPDATE api_tokens SET bound_node_id=NULL WHERE token_id='n_duplicate_first'");
196
+ expect((await call(tokens[0], "list_my_children")).ok).toBe(true);
197
+ expect((await call(tokens[0], "get_adopt_request", { request_id: "n_duplicate_first" })).ok).toBe(true);
198
+ expect((await call(tokens[0], "get_adopt_request", { request_id: "n_duplicate_owner" })).ok).toBe(false);
199
+ expect((await mint(owner.token, owner.network_id, "duplicate_fixture", "n_third_duplicate")).status).toBe(400);
200
+ });
201
+
202
+ test("all production token issuers explicitly mark the rollout epoch", () => {
203
+ const root = `${import.meta.dir}/../..`;
204
+ function sources(dir: string): string[] {
205
+ return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
206
+ const path = `${dir}/${entry.name}`;
207
+ return entry.isDirectory() ? sources(path) : entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts") ? [path] : [];
208
+ });
209
+ }
210
+ const sourceFiles = ["server/src", "agent-network/src", "agent-network/bin", "agent-node/src"].flatMap(dir => sources(`${root}/${dir}`));
211
+ let count = 0;
212
+ for (const file of sourceFiles) {
213
+ const source = readFileSync(file, "utf8");
214
+ for (const match of source.matchAll(/INSERT\s+INTO\s+api_tokens\s*\(([^)]+)\)/gi)) {
215
+ expect(match[1]).toContain("node_identity_epoch"); count++;
216
+ }
217
+ }
218
+ expect(count).toBe(9);
219
+ expect(readFileSync(`${root}/agent-network/bin/cli.ts`, "utf8")).toContain("'admin-reset', 'user', 1)");
220
+ });
221
+
222
+ test("revoked ownerless credential cannot be reclaimed by doctor and gives actionable guidance", async () => {
223
+ db.run("UPDATE api_tokens SET revoked_at=datetime('now') WHERE name='node:n_ownerless_fixture'");
224
+ const result = await mint(member.token, owner.network_id, "n_ownerless_fixture");
225
+ expect(result.status).toBe(400);
226
+ expect(result.body.error).toBe("node_owner_mismatch");
227
+ expect(nodeTokenReissueError(result.body)).toContain("这个节点没有归属,请联系管理员认领(#682)");
228
+ expect(nodeTokenReissueError({ error: "node_owner_unclaimed" })).toContain("请联系管理员认领(#682)");
229
+ expect(nodeTokenReissueError({ error: "network_not_found" })).toBe("network_not_found");
230
+ expect(readFileSync(`${import.meta.dir}/../../agent-network/bin/cli.ts`, "utf8")).toContain("body ? nodeTokenReissueError(body) : r.status");
231
+ });
package/src/db.ts CHANGED
@@ -640,6 +640,9 @@ for (const ddl of [
640
640
  "ALTER TABLE api_tokens ADD COLUMN revoked_at TEXT",
641
641
  "ALTER TABLE api_tokens ADD COLUMN role TEXT",
642
642
  "ALTER TABLE api_tokens ADD COLUMN bound_node_id TEXT",
643
+ // Existing tokens retain epoch 0. Every production issuer writes 1 (general)
644
+ // or 2 (ownership-checked node token); 0 is a rollout boundary, not a clock.
645
+ "ALTER TABLE api_tokens ADD COLUMN node_identity_epoch INTEGER NOT NULL DEFAULT 0",
643
646
  // 登录会话的设备标识(GET /api/auth/sessions 显示用):客户端自报的 client_label + 登录请求的 User-Agent。
644
647
  "ALTER TABLE api_tokens ADD COLUMN client_label TEXT",
645
648
  "ALTER TABLE api_tokens ADD COLUMN user_agent TEXT",