@sleep2agi/commhub-server 0.9.0-preview.109 → 0.9.0-preview.111
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/create-node-unicode-name.test.ts +118 -0
- package/src/create-node-validate.test.ts +19 -5
- package/src/create-node-validate.ts +14 -4
- package/src/create-node.ts +13 -6
- package/src/daemon-token-owner-http.test.ts +226 -0
- package/src/db.ts +3 -0
- package/src/node-lifecycle-read-http.test.ts +203 -0
- package/src/node-lifecycle-read.ts +99 -0
- 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/scheduled-tasks.ts +5 -3
- package/src/server.ts +28 -5
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -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 +49 -13
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.111",
|
|
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,118 @@
|
|
|
1
|
+
// Board #652 — create_node takes Chinese / Unicode node names (the alias); illegal
|
|
2
|
+
// characters are refused with a reason before any row is written; the in-flight
|
|
3
|
+
// uniqueness check still holds. Driven through the REAL /mcp entry (zod schema +
|
|
4
|
+
// handler) on a private port with a temp DB, then get_create_request as the daemon.
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
|
|
11
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-652-"));
|
|
12
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
13
|
+
process.env.HOST = "127.0.0.1";
|
|
14
|
+
|
|
15
|
+
const { db } = await import("./db.js");
|
|
16
|
+
const { register, createNetworkTokenForNode } = await import("./auth.js");
|
|
17
|
+
|
|
18
|
+
const PW = "Unicode652Passw0rd!x";
|
|
19
|
+
let BASE = "";
|
|
20
|
+
let hub: any = null;
|
|
21
|
+
let OWNER = "";
|
|
22
|
+
let OWNER_ID = "";
|
|
23
|
+
let NET = "";
|
|
24
|
+
let DAEMON_TOK = "";
|
|
25
|
+
const DAEMON_ID = "node_t652_daemon";
|
|
26
|
+
|
|
27
|
+
async function rpc(token: string, method: string, params?: unknown): Promise<any> {
|
|
28
|
+
const res = await fetch(`${BASE}/mcp`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
|
|
31
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, ...(params === undefined ? {} : { params }) }),
|
|
32
|
+
});
|
|
33
|
+
const raw = await res.text();
|
|
34
|
+
const lines = raw.split("\n").filter(x => x.startsWith("data:"));
|
|
35
|
+
return lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
|
|
36
|
+
}
|
|
37
|
+
async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
|
|
38
|
+
const out = await rpc(token, "tools/call", { name, arguments: args });
|
|
39
|
+
const text = out?.result?.content?.[0]?.text;
|
|
40
|
+
if (typeof text !== "string") return { _raw: out };
|
|
41
|
+
try { return JSON.parse(text); } catch { return { _text: text, isError: out.result.isError }; }
|
|
42
|
+
}
|
|
43
|
+
const create = (name: string) => mcp(OWNER, "create_node", {
|
|
44
|
+
daemon_node_id: DAEMON_ID, network_id: NET,
|
|
45
|
+
node_spec: { name, runtime: "claude-agent-sdk", model: "claude-sonnet-4-5", flags: {} },
|
|
46
|
+
});
|
|
47
|
+
const rows = (name: string) =>
|
|
48
|
+
db.get<{ n: number }>("SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1 AND child_name = ?2", DAEMON_ID, name)?.n ?? -1;
|
|
49
|
+
|
|
50
|
+
beforeAll(async () => {
|
|
51
|
+
const a = register(`t652_owner_${Date.now()}`, PW, undefined, "Owner");
|
|
52
|
+
if (!a.ok) throw new Error("register failed");
|
|
53
|
+
OWNER = a.token!; OWNER_ID = a.user!.user_id; NET = a.network_id!;
|
|
54
|
+
const mod: any = await import("./server.js");
|
|
55
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
56
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
57
|
+
const t = createNetworkTokenForNode(OWNER_ID, NET, "t652-daemon", DAEMON_ID);
|
|
58
|
+
if (!t.ok || !t.token) throw new Error("daemon token failed: " + t.error);
|
|
59
|
+
DAEMON_TOK = t.token;
|
|
60
|
+
db.run(
|
|
61
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, network_id, config_revision, config_snapshot)
|
|
62
|
+
VALUES (?1, 't652-daemon', 't652-daemon', 'claude-agent-sdk', 'm', '/tmp/cfg.json', '[]', 'h', 'h', ?2, 0, ?3)`,
|
|
63
|
+
[DAEMON_ID, NET, JSON.stringify({ role: "host_supervisor", daemon_capabilities: { runtimes_supported: ["claude-agent-sdk"] } })],
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
afterAll(() => {
|
|
67
|
+
try { hub?.stop?.(true); } catch {}
|
|
68
|
+
rmSync(DIR, { recursive: true, force: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("#652 create_node via /mcp — Unicode names", () => {
|
|
72
|
+
test("「测试」 is accepted; the daemon gets the same alias", async () => {
|
|
73
|
+
const r = await create("测试");
|
|
74
|
+
expect(r.ok).toBe(true);
|
|
75
|
+
expect(String(r.request_id)).toMatch(/^cr_/);
|
|
76
|
+
expect(rows("测试")).toBe(1);
|
|
77
|
+
const g = await mcp(DAEMON_TOK, "get_create_request", { request_id: r.request_id });
|
|
78
|
+
expect(g.ok).toBe(true);
|
|
79
|
+
expect(g.node_spec.name).toBe("测试");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("upper case / digit-first ASCII and 64 CJK characters pass the zod bound and the rule", async () => {
|
|
83
|
+
for (const name of ["研发助手A", "Demo2", "9lives", "测".repeat(64)]) {
|
|
84
|
+
const r = await create(name);
|
|
85
|
+
expect({ name, ok: r.ok, error: r.error }).toEqual({ name, ok: true, error: undefined });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("surrounding whitespace is trimmed before it is stored", async () => {
|
|
90
|
+
const r = await create(" 前后空格 ");
|
|
91
|
+
expect(r.ok).toBe(true);
|
|
92
|
+
expect(rows("前后空格")).toBe(1);
|
|
93
|
+
expect(rows(" 前后空格 ")).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("illegal characters are refused with reason + char + message, and no row is written", async () => {
|
|
97
|
+
for (const [name, reason] of [["a/b", "forbidden_char"], ["a\\b", "forbidden_char"], ["a:b", "forbidden_char"],
|
|
98
|
+
[".hidden", "forbidden_char"], ["..", "forbidden_char"], ["a b", "forbidden_char"], ["a\u0007b", "forbidden_char"],
|
|
99
|
+
["-x", "leading_dash"], ["测".repeat(65), "too_long"]] as const) {
|
|
100
|
+
const r = await create(name);
|
|
101
|
+
expect({ name, error: r.error, reason: r.reason }).toEqual({ name, error: "node_name_invalid", reason });
|
|
102
|
+
expect(String(r.message ?? "").length).toBeGreaterThan(0);
|
|
103
|
+
expect(rows(name)).toBe(0);
|
|
104
|
+
}
|
|
105
|
+
const slash = await create("a/b");
|
|
106
|
+
expect(slash.char).toBe("/");
|
|
107
|
+
expect(slash.message).toContain("/");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("uniqueness: a second in-flight request for the same Chinese name is a conflict", async () => {
|
|
111
|
+
const first = await create("重复名字");
|
|
112
|
+
expect(first.ok).toBe(true);
|
|
113
|
+
const second = await create("重复名字");
|
|
114
|
+
expect(second.ok).toBe(false);
|
|
115
|
+
expect(second.error).toBe("node_name_conflict");
|
|
116
|
+
expect(rows("重复名字")).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -9,17 +9,31 @@ import {
|
|
|
9
9
|
const okSecret = (k: string, _net: string, key: string) => k === key ? `value-of-${k}` : undefined;
|
|
10
10
|
const allow = (k: string) => new Set<string>([k, "ANTHROPIC_API_KEY", "OPENAI_API_KEY"]);
|
|
11
11
|
|
|
12
|
-
describe("validateName (§4.2.2)", () => {
|
|
13
|
-
test("good names", () => {
|
|
14
|
-
for (const n of ["a", "demo-bot", "node_1", "z2", "abc-def-ghi"]) {
|
|
12
|
+
describe("validateName (§4.2.2, #652 Unicode names)", () => {
|
|
13
|
+
test("good names — incl. Chinese, upper case, digits first", () => {
|
|
14
|
+
for (const n of ["a", "demo-bot", "node_1", "z2", "abc-def-ghi", "测试", "研发助手A", "Demo", "1demo", "测".repeat(64)]) {
|
|
15
15
|
expect(() => validateName(n)).not.toThrow();
|
|
16
16
|
}
|
|
17
17
|
});
|
|
18
|
-
test("
|
|
19
|
-
|
|
18
|
+
test("returns the trimmed name", () => {
|
|
19
|
+
expect(validateName(" 测试 ")).toBe("测试");
|
|
20
|
+
});
|
|
21
|
+
test("rejects shell-injection-like and path-like names with a reason", () => {
|
|
22
|
+
for (const n of [";rm -rf /", "demo bot", "-demo", "demo;rm", "demo$INJECT", "x".repeat(65), "测".repeat(65),
|
|
23
|
+
"a/b", "a\\b", "a:b", ".hidden", "..", "a\u0000b", "a`id`", "", " "]) {
|
|
20
24
|
expect(() => validateName(n)).toThrow(ValidationError);
|
|
21
25
|
}
|
|
22
26
|
});
|
|
27
|
+
test("error carries reason + offending char + a readable message", () => {
|
|
28
|
+
try { validateName("a/b"); throw new Error("no throw"); }
|
|
29
|
+
catch (e: any) {
|
|
30
|
+
expect(e).toBeInstanceOf(ValidationError);
|
|
31
|
+
expect(e.code).toBe("node_name_invalid");
|
|
32
|
+
expect(e.detail.reason).toBe("forbidden_char");
|
|
33
|
+
expect(e.detail.char).toBe("/");
|
|
34
|
+
expect(String(e.detail.message)).toContain("/");
|
|
35
|
+
}
|
|
36
|
+
});
|
|
23
37
|
});
|
|
24
38
|
|
|
25
39
|
describe("validateRuntime / validateModel (§4.2.2)", () => {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// payload (RFC §4.6).
|
|
9
9
|
|
|
10
10
|
import { isReservedEnvKey } from "./shared/reserved-env.js";
|
|
11
|
+
import { checkNodeName } from "./shared/node-name.js";
|
|
11
12
|
|
|
12
13
|
export class ValidationError extends Error {
|
|
13
14
|
constructor(public code: string, public detail?: Record<string, unknown>) {
|
|
@@ -66,17 +67,26 @@ export const MAX_ENV_VALUE_BYTES = 16 * 1024;
|
|
|
66
67
|
const ENV_KEY_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
67
68
|
|
|
68
69
|
// §4.2.2 name + model.
|
|
69
|
-
|
|
70
|
+
// Board #652 — the name rule lives in ./shared/node-name.ts (byte-identical copy in
|
|
71
|
+
// agent-node/src/shared/, drift-tested). It used to be /^[a-z][a-z0-9_-]{0,63}$/ here.
|
|
70
72
|
// OpenCode 共存的模型带 provider 前缀(`opencode/mimo-v2.5-free`,agent-node parseModelRef 按第一个 `/` 切),
|
|
71
73
|
// 桌面向导 0.2.61 起就发这种形状 —— 允许**恰好一个**斜杠分隔的两段;每段仍是原字符集,且不能是纯点(`..`/`.`)。
|
|
72
74
|
const MODEL_SEGMENT = "[a-zA-Z0-9._:\\-]+";
|
|
73
75
|
const MODEL_RE = new RegExp(`^${MODEL_SEGMENT}(?:/${MODEL_SEGMENT})?$`);
|
|
74
76
|
const MODEL_DOT_ONLY_SEGMENT = /(^|\/)\.+(\/|$)/;
|
|
75
77
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
/** Board #652 — returns the normalized (trimmed) name the caller must store and forward. */
|
|
79
|
+
export function validateName(s: unknown): string {
|
|
80
|
+
const r = checkNodeName(s);
|
|
81
|
+
if (!r.ok) {
|
|
82
|
+
throw new ValidationError("node_name_invalid", {
|
|
83
|
+
value: typeof s === "string" ? s.slice(0, 80) : typeof s,
|
|
84
|
+
reason: r.error,
|
|
85
|
+
...(r.char !== undefined ? { char: r.char } : {}),
|
|
86
|
+
message: r.message,
|
|
87
|
+
});
|
|
79
88
|
}
|
|
89
|
+
return r.name;
|
|
80
90
|
}
|
|
81
91
|
|
|
82
92
|
export function validateRuntime(s: unknown): asserts s is Runtime {
|
package/src/create-node.ts
CHANGED
|
@@ -243,8 +243,8 @@ export function resolveCallerDaemonTokenBound(opts: {
|
|
|
243
243
|
if (!opts.enforceNetworkId) {
|
|
244
244
|
return { ok: false, error: "caller_not_a_daemon" };
|
|
245
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`,
|
|
246
|
+
const tokRow = db.get<{ name: string; network_id: string | null; user_id: string | null; bound_node_id: string | null; node_identity_epoch: number }>(
|
|
247
|
+
`SELECT name, network_id, user_id, bound_node_id, node_identity_epoch FROM api_tokens WHERE token_id = ?1 AND revoked_at IS NULL`,
|
|
248
248
|
opts.callerTokenId,
|
|
249
249
|
);
|
|
250
250
|
if (!tokRow || !tokRow.name || !tokRow.name.startsWith("node:")) {
|
|
@@ -254,13 +254,20 @@ export function resolveCallerDaemonTokenBound(opts: {
|
|
|
254
254
|
return { ok: false, error: "caller_not_a_daemon" };
|
|
255
255
|
}
|
|
256
256
|
const tokenAlias = tokRow.name.slice(5);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
257
|
+
type DaemonRow = { node_id: string; alias: string; network_id: string; owner_user_id: string | null };
|
|
258
|
+
const rows = tokRow.bound_node_id
|
|
259
|
+
? 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)
|
|
260
|
+
: 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);
|
|
261
|
+
const nodeRow = rows.length === 1 ? rows[0] : undefined;
|
|
261
262
|
if (!nodeRow) {
|
|
262
263
|
return { ok: false, error: "caller_not_a_daemon" };
|
|
263
264
|
}
|
|
265
|
+
// Legacy daemon tokens have no bound_node_id: retain their owner's authority.
|
|
266
|
+
// A token name is a lookup hint, never proof of authority over that node.
|
|
267
|
+
const ownsNode = !!tokRow.user_id && tokRow.user_id === nodeRow.owner_user_id;
|
|
268
|
+
const boundToNode = tokRow.bound_node_id === nodeRow.node_id;
|
|
269
|
+
const legacyOwnerless = nodeRow.owner_user_id === null && tokRow.node_identity_epoch === 0;
|
|
270
|
+
if (!ownsNode && !boundToNode && !legacyOwnerless) return { ok: false, error: "caller_not_a_daemon" };
|
|
264
271
|
return {
|
|
265
272
|
ok: true,
|
|
266
273
|
daemonNodeId: nodeRow.node_id,
|
|
@@ -0,0 +1,226 @@
|
|
|
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" });
|
|
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 unbound ambiguity fails closed", async () => {
|
|
173
|
+
// Historical duplicate rows: the member's row was inserted first. Neither
|
|
174
|
+
// lookup order nor alias can redirect the later owner's exact-bound token.
|
|
175
|
+
const { hashToken, generateNetworkToken } = await import("./db.js");
|
|
176
|
+
const tokens: string[] = [];
|
|
177
|
+
for (const [id, user] of [["n_duplicate_first", member.user.user_id], ["n_duplicate_owner", owner.user.user_id]]) {
|
|
178
|
+
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]);
|
|
179
|
+
const token = generateNetworkToken(); tokens.push(token);
|
|
180
|
+
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]);
|
|
181
|
+
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]);
|
|
182
|
+
}
|
|
183
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
184
|
+
const result = await call(tokens[i], "list_my_children");
|
|
185
|
+
expect(result.ok).toBe(true);
|
|
186
|
+
const ownId = i === 0 ? "n_duplicate_first" : "n_duplicate_owner";
|
|
187
|
+
expect((await call(tokens[i], "get_adopt_request", { request_id: ownId })).ok).toBe(true);
|
|
188
|
+
expect((await call(tokens[i], "get_adopt_request", { request_id: i === 0 ? "n_duplicate_owner" : "n_duplicate_first" })).ok).toBe(false);
|
|
189
|
+
}
|
|
190
|
+
db.run("UPDATE api_tokens SET bound_node_id=NULL WHERE token_id='n_duplicate_owner'");
|
|
191
|
+
expect(await call(tokens[1], "list_my_children")).toMatchObject({ ok: false, error: "caller_not_a_daemon" });
|
|
192
|
+
db.run("UPDATE api_tokens SET bound_node_id=NULL WHERE token_id='n_duplicate_first'");
|
|
193
|
+
expect(await call(tokens[0], "list_my_children")).toMatchObject({ ok: false, error: "caller_not_a_daemon" });
|
|
194
|
+
expect((await mint(owner.token, owner.network_id, "duplicate_fixture", "n_third_duplicate")).status).toBe(400);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("all production token issuers explicitly mark the rollout epoch", () => {
|
|
198
|
+
const root = `${import.meta.dir}/../..`;
|
|
199
|
+
function sources(dir: string): string[] {
|
|
200
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
|
201
|
+
const path = `${dir}/${entry.name}`;
|
|
202
|
+
return entry.isDirectory() ? sources(path) : entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts") ? [path] : [];
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const sourceFiles = ["server/src", "agent-network/src", "agent-network/bin", "agent-node/src"].flatMap(dir => sources(`${root}/${dir}`));
|
|
206
|
+
let count = 0;
|
|
207
|
+
for (const file of sourceFiles) {
|
|
208
|
+
const source = readFileSync(file, "utf8");
|
|
209
|
+
for (const match of source.matchAll(/INSERT\s+INTO\s+api_tokens\s*\(([^)]+)\)/gi)) {
|
|
210
|
+
expect(match[1]).toContain("node_identity_epoch"); count++;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
expect(count).toBe(9);
|
|
214
|
+
expect(readFileSync(`${root}/agent-network/bin/cli.ts`, "utf8")).toContain("'admin-reset', 'user', 1)");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("revoked ownerless credential cannot be reclaimed by doctor and gives actionable guidance", async () => {
|
|
218
|
+
db.run("UPDATE api_tokens SET revoked_at=datetime('now') WHERE name='node:n_ownerless_fixture'");
|
|
219
|
+
const result = await mint(member.token, owner.network_id, "n_ownerless_fixture");
|
|
220
|
+
expect(result.status).toBe(400);
|
|
221
|
+
expect(result.body.error).toBe("node_owner_mismatch");
|
|
222
|
+
expect(nodeTokenReissueError(result.body)).toContain("这个节点没有归属,请联系管理员认领(#682)");
|
|
223
|
+
expect(nodeTokenReissueError({ error: "node_owner_unclaimed" })).toContain("请联系管理员认领(#682)");
|
|
224
|
+
expect(nodeTokenReissueError({ error: "network_not_found" })).toBe("network_not_found");
|
|
225
|
+
expect(readFileSync(`${import.meta.dir}/../../agent-network/bin/cli.ts`, "utf8")).toContain("body ? nodeTokenReissueError(body) : r.status");
|
|
226
|
+
});
|
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",
|