@sleep2agi/commhub-server 0.9.0-preview.110 → 0.9.0-preview.112
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/auth.ts +18 -14
- package/src/binding-generation-http.test.ts +121 -0
- package/src/create-node.ts +74 -23
- package/src/daemon-token-owner-http.test.ts +231 -0
- package/src/db.ts +3 -0
- package/src/node-caller-identity-http.test.ts +714 -0
- package/src/node-daemon-bindings.test.ts +1 -1
- package/src/node-lifecycle-read-http.test.ts +2 -1
- package/src/node-lifecycle-read.ts +1 -0
- package/src/node-permissions.ts +45 -12
- package/src/node-token-ownership.ts +36 -0
- package/src/project-dir-warn-clear-http.test.ts +132 -0
- package/src/requirement-events-http.test.ts +12 -1
- package/src/requirements-last-event-http.test.ts +6 -1
- package/src/requirements.ts +3 -7
- package/src/rest-identity.ts +29 -4
- package/src/scheduled-tasks.ts +5 -3
- package/src/server.ts +63 -21
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -0
- package/src/tools.ts +91 -29
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
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
|
-
*
|
|
250
|
-
*
|
|
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
|
-
//
|
|
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 =
|
|
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
|
+
});
|
package/src/create-node.ts
CHANGED
|
@@ -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
|
|
257
|
-
|
|
258
|
-
|
|
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:
|
|
267
|
-
daemonAlias:
|
|
268
|
-
networkId:
|
|
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",
|