@sleep2agi/commhub-server 0.9.0-preview.111 → 0.9.0-preview.113

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.111",
3
+ "version": "0.9.0-preview.113",
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",
@@ -0,0 +1,294 @@
1
+ // #711 改密码只让浏览器 / app 的登录会话下线,命令行和脚本令牌默认保留。
2
+ //
3
+ // main 上 POST /api/auth/password 跑的是 `DELETE FROM api_tokens WHERE user_id = ? AND network_id IS NULL
4
+ // AND token_id != ?` —— 每台机器上 `anet login` 拿到的令牌、POST /api/auth/tokens 建的具名脚本令牌,
5
+ // 跟浏览器会话一起被删光。安全复审定的形状:默认不变,保留是显式选项。这里每条都钉住:
6
+ // · 默认(旧 app、dashboard 代理不带字段):撤销全部不绑网络的令牌 —— 客户端自报的 cli 也活不下来;
7
+ // · keep_cli_tokens=true(anet passwd 默认带):只撤其他 kind='login' 会话,保留的 cli 令牌列在响应里、不含令牌值;
8
+ // · 当前会话(换发后的新令牌)永远可用,而且沿用当前令牌的种类;
9
+ // · 节点 / 网络令牌永远不动;
10
+ // · 启动迁移按 client_label / scope 回填存量令牌的 kind(夹具库,跑两遍幂等)。
11
+ // SQLite 由 test798 自动收进;PostgreSQL 由 tests/test2123-hub-postgres-ladder 的 run_pg_tests_rc 跑同一个文件。
12
+
13
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
14
+ import { mkdtempSync, rmSync } from "fs";
15
+ import { join } from "path";
16
+ import { tmpdir } from "os";
17
+ import { spawnSync } from "child_process";
18
+ import { register, createNetworkTokenForNode, createToken, changePassword } from "./auth.js";
19
+ import { db, hashToken } from "./db.js";
20
+
21
+ const DIR = mkdtempSync(join(tmpdir(), "anet-pwchange-kind-"));
22
+ let BASE = "";
23
+ let hub: any = null;
24
+ const PW = "PwChangeKindPassw0rd!";
25
+ const PW2 = "PwChangeKindPassw0rd!2";
26
+ const CLI_LABEL = "anet 2.5.0 · test-box · login";
27
+
28
+ async function call(token: string, method: string, path: string, body?: unknown) {
29
+ const res = await fetch(`${BASE}${path}`, {
30
+ method,
31
+ headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
32
+ body: body === undefined ? undefined : JSON.stringify(body),
33
+ });
34
+ return { status: res.status, body: await res.json().catch(() => null) as any };
35
+ }
36
+ const works = async (token: string) => (await call(token, "GET", "/api/auth/me")).status === 200;
37
+ const kindOf = (token: string) => db.get<any>("SELECT kind FROM api_tokens WHERE token_hash = ?1", hashToken(token))?.kind ?? null;
38
+
39
+ type Fleet = {
40
+ user: string; userId: string; net: string;
41
+ app: string; appB: string; regToken: string;
42
+ cliByLabel: string; cliByKind: string; apiToken: string;
43
+ nodeToken: string; netToken: string;
44
+ };
45
+
46
+ let seq = 0;
47
+ async function fleet(): Promise<Fleet> {
48
+ const user = `pwk_${Date.now()}_${++seq}`;
49
+ const reg = register(user, PW);
50
+ expect(reg.ok).toBe(true);
51
+ const login = async (extra: Record<string, unknown>) => {
52
+ const r = await call("", "POST", "/api/auth/login", { username: user, password: PW, ...extra });
53
+ expect(r.status).toBe(200);
54
+ return r.body.token as string;
55
+ };
56
+ const node = createNetworkTokenForNode(reg.user!.user_id, reg.network_id!, "pwk-node", `node_pwk_${seq}`);
57
+ expect(node.ok).toBe(true);
58
+ const api = createToken(reg.user!.user_id, "ci-script");
59
+ expect(api.ok).toBe(true);
60
+ const netTok = createToken(reg.user!.user_id, "net-script", reg.network_id!);
61
+ expect(netTok.ok).toBe(true);
62
+ return {
63
+ user, userId: reg.user!.user_id, net: reg.network_id!,
64
+ regToken: reg.token!,
65
+ app: await login({ client_label: "Desktop app" }),
66
+ appB: await login({}),
67
+ cliByLabel: await login({ client_label: CLI_LABEL }),
68
+ cliByKind: await login({ client_kind: "cli" }),
69
+ apiToken: api.token!,
70
+ nodeToken: node.token!,
71
+ netToken: netTok.token!,
72
+ };
73
+ }
74
+
75
+ beforeAll(async () => {
76
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
77
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
78
+ process.env.HOST = "127.0.0.1";
79
+ process.env.COMMHUB_LOGIN_IP_MAX = "1000";
80
+ const mod: any = await import("./server.js");
81
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
82
+ BASE = `http://127.0.0.1:${hub.port}`;
83
+ }, 30_000);
84
+
85
+ afterAll(() => {
86
+ try { hub?.stop?.(true); } catch {}
87
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
88
+ });
89
+
90
+ describe("new tokens are classified at issue time", () => {
91
+ test("browser/app logins are 'login'; anet-labelled, client_kind=cli and named API tokens are 'cli'; node tokens unclassified", async () => {
92
+ const f = await fleet();
93
+ expect(kindOf(f.regToken)).toBe("login");
94
+ expect(kindOf(f.app)).toBe("login");
95
+ expect(kindOf(f.appB)).toBe("login");
96
+ expect(kindOf(f.cliByLabel)).toBe("cli");
97
+ expect(kindOf(f.cliByKind)).toBe("cli");
98
+ expect(kindOf(f.apiToken)).toBe("cli");
99
+ expect(kindOf(f.nodeToken)).toBe(null);
100
+ expect(kindOf(f.netToken)).toBe(null);
101
+ });
102
+ });
103
+
104
+ // 响应里出现任何令牌值都算泄漏:换发给调用方的那一条(body.token)除外,它本来就要交给调用方。
105
+ function assertNoTokenValues(body: any, f: Fleet) {
106
+ const { token: _rotated, ...rest } = body;
107
+ const text = JSON.stringify(rest);
108
+ for (const t of [f.regToken, f.app, f.appB, f.cliByLabel, f.cliByKind, f.apiToken, f.nodeToken, f.netToken]) {
109
+ expect(text.includes(t)).toBe(false);
110
+ }
111
+ expect(/\b(utok|atok|ntok)_[A-Za-z0-9]{8,}/.test(text)).toBe(false);
112
+ expect(text.includes("token_hash")).toBe(false);
113
+ }
114
+
115
+ describe("POST /api/auth/password (default: old apps, dashboard proxy — no field)", () => {
116
+ test("every other non-network token is revoked (pre-#711 behaviour); node tokens and the rotated current session survive", async () => {
117
+ const f = await fleet();
118
+ const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2 });
119
+ expect(r.status).toBe(200);
120
+ expect(r.body.ok).toBe(true);
121
+ expect(r.body.revoked_login).toBe(2); // reg + appB
122
+ expect(r.body.revoked_cli).toBe(3); // cliByLabel + cliByKind + apiToken
123
+ expect(r.body.revoked).toBe(5);
124
+ expect(r.body.kept_cli_tokens).toBeUndefined();
125
+ for (const t of [f.regToken, f.appB, f.cliByLabel, f.cliByKind, f.apiToken]) expect(await works(t)).toBe(false);
126
+ expect(await works(f.nodeToken)).toBe(true);
127
+ expect(await works(f.netToken)).toBe(true);
128
+ expect(await works(r.body.token)).toBe(true);
129
+ expect(kindOf(r.body.token)).toBe("login");
130
+ expect(await works(f.app)).toBe(false);
131
+ });
132
+
133
+ test("a client-declared cli token (client_kind / anet label) does NOT survive a default password change", async () => {
134
+ const f = await fleet();
135
+ expect(kindOf(f.cliByKind)).toBe("cli");
136
+ const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2 });
137
+ expect(r.status).toBe(200);
138
+ expect(await works(f.cliByKind)).toBe(false);
139
+ expect(await works(f.cliByLabel)).toBe(false);
140
+ });
141
+
142
+ test("a non-boolean keep_cli_tokens is not an opt-in; the removed revoke_cli_tokens field does nothing", async () => {
143
+ const f = await fleet();
144
+ const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, keep_cli_tokens: "true", revoke_cli_tokens: false });
145
+ expect(r.status).toBe(200);
146
+ expect(r.body.revoked_cli).toBe(3);
147
+ expect(await works(f.cliByLabel)).toBe(false);
148
+ expect(await works(f.apiToken)).toBe(false);
149
+ });
150
+ });
151
+
152
+ describe("POST /api/auth/password with keep_cli_tokens=true (anet passwd)", () => {
153
+ test("only other login sessions are revoked; CLI/script tokens are kept and listed without token values", async () => {
154
+ const f = await fleet();
155
+ const r = await call(f.cliByLabel, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, keep_cli_tokens: true });
156
+ expect(r.status).toBe(200);
157
+ expect(r.body.revoked_login).toBe(3); // reg + app + appB
158
+ expect(r.body.revoked_cli).toBe(0);
159
+ expect(r.body.revoked).toBe(3);
160
+ for (const t of [f.regToken, f.app, f.appB]) expect(await works(t)).toBe(false);
161
+ expect(await works(f.cliByKind)).toBe(true);
162
+ expect(await works(f.apiToken)).toBe(true);
163
+ expect(await works(f.nodeToken)).toBe(true);
164
+ expect(await works(f.netToken)).toBe(true);
165
+ // 当前会话:换发的新令牌可用,沿用 cli
166
+ expect(await works(r.body.token)).toBe(true);
167
+ expect(kindOf(r.body.token)).toBe("cli");
168
+ // 保留清单:不含当前这条(它已被换掉),只含另外两条 cli;字段齐全、无令牌值
169
+ const kept = r.body.kept_cli_tokens as any[];
170
+ const idOf = (t: string) => db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(t)).token_id;
171
+ expect(kept.map((k) => k.token_id).sort()).toEqual([idOf(f.cliByKind), idOf(f.apiToken)].sort());
172
+ for (const k of kept) {
173
+ expect(Object.keys(k).sort()).toEqual(["client_label", "created_at", "last_used_at", "name", "token_id"]);
174
+ expect(k.token_id).toMatch(/^tok_[0-9a-f]{12}$/);
175
+ }
176
+ expect(kept.find((k) => k.token_id === idOf(f.apiToken)).name).toBe("ci-script");
177
+ assertNoTokenValues(r.body, f);
178
+ // 可以逐条撤销
179
+ expect((await call(r.body.token, "DELETE", `/api/auth/tokens/${idOf(f.apiToken)}`)).status).toBe(200);
180
+ expect(await works(f.apiToken)).toBe(false);
181
+ });
182
+
183
+ test("default response carries no token values either", async () => {
184
+ const f = await fleet();
185
+ const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2 });
186
+ expect(r.status).toBe(200);
187
+ assertNoTokenValues(r.body, f);
188
+ });
189
+ });
190
+
191
+ describe("changePassword() keeps the current token row in both modes", () => {
192
+ test("no opts at all = default: CLI tokens revoked, current kept", async () => {
193
+ const f = await fleet();
194
+ const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.app)).token_id;
195
+ const r = changePassword(f.userId, PW, PW2, cur);
196
+ expect(r.ok).toBe(true);
197
+ expect(r.revoked_cli).toBe(3);
198
+ expect(r.kept_cli_tokens).toBeUndefined();
199
+ expect(await works(f.app)).toBe(true);
200
+ expect(await works(f.cliByKind)).toBe(false);
201
+ expect(await works(f.apiToken)).toBe(false);
202
+ });
203
+
204
+ for (const keepCliTokens of [false, true]) {
205
+ test(`keepCliTokens=${keepCliTokens}: current login token kept`, async () => {
206
+ const f = await fleet();
207
+ const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.app)).token_id;
208
+ const r = changePassword(f.userId, PW, PW2, cur, { keepCliTokens });
209
+ expect(r.ok).toBe(true);
210
+ expect(await works(f.app)).toBe(true);
211
+ expect(await works(f.appB)).toBe(false);
212
+ expect(await works(f.cliByLabel)).toBe(keepCliTokens);
213
+ expect(await works(f.nodeToken)).toBe(true);
214
+ });
215
+ test(`keepCliTokens=${keepCliTokens}: current CLI token kept`, async () => {
216
+ const f = await fleet();
217
+ const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.cliByKind)).token_id;
218
+ const r = changePassword(f.userId, PW, PW2, cur, { keepCliTokens });
219
+ expect(r.ok).toBe(true);
220
+ expect(await works(f.cliByKind)).toBe(true);
221
+ expect(await works(f.app)).toBe(false);
222
+ expect(await works(f.apiToken)).toBe(keepCliTokens);
223
+ });
224
+ }
225
+ });
226
+
227
+ describe("startup migration backfills api_tokens.kind on a fixture DB", () => {
228
+ // 把 kind 列拿掉 = 回到 #711 之前的表结构;塞存量行;再起一个进程加载 db.ts(= Hub 启动迁移)。
229
+ function bootMigration() {
230
+ const r = spawnSync(process.execPath, ["-e", `await import(${JSON.stringify(join(import.meta.dir, "db.ts"))}); process.exit(0);`], {
231
+ cwd: join(import.meta.dir, ".."),
232
+ env: { ...process.env, NODE_ENV: "test" },
233
+ encoding: "utf8",
234
+ timeout: 60_000,
235
+ });
236
+ if (r.status !== 0) console.log(String(r.stderr || "").slice(-2000));
237
+ return r.status;
238
+ }
239
+
240
+ test("anet-labelled and named API tokens → cli, everything else → login, node tokens untouched; idempotent", async () => {
241
+ const f = await fleet();
242
+ db.exec("ALTER TABLE api_tokens DROP COLUMN kind");
243
+ const ins = (id: string, net: string | null, scope: string, label: string | null) =>
244
+ db.run(
245
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, 'fixture', ?5, ?6, 1)",
246
+ [id, hashToken(`fixture-${id}`), f.userId, net, scope, label],
247
+ );
248
+ const id = (s: string) => `tok_fx_${seq}_${s}`;
249
+ ins(id("cli_label"), null, "user", "anet 2.4.0 · old-box · login");
250
+ ins(id("cli_demo"), null, "user", "anet dev · unknown-host · demo sci-team");
251
+ ins(id("browser"), null, "user", "Chrome on macOS");
252
+ ins(id("nolabel"), null, "user", null);
253
+ ins(id("lookalike"), null, "user", "planet-anet dashboard");
254
+ ins(id("upper"), null, "user", "ANET 2.4.0 · box · login");
255
+ ins(id("padded"), null, "user", " anet 2.4.0 · box · login");
256
+ ins(id("named"), null, "full", null);
257
+ ins(id("node"), f.net, "network", null);
258
+ ins(id("netfull"), f.net, "full", null);
259
+
260
+ expect(bootMigration()).toBe(0);
261
+ const k = (s: string) => db.get<any>("SELECT kind FROM api_tokens WHERE token_id = ?1", id(s))?.kind ?? null;
262
+ expect(k("cli_label")).toBe("cli");
263
+ expect(k("cli_demo")).toBe("cli");
264
+ expect(k("browser")).toBe("login");
265
+ expect(k("nolabel")).toBe("login");
266
+ expect(k("lookalike")).toBe("login");
267
+ expect(k("upper")).toBe("login"); // 区分大小写:SQLite 的 LIKE 会把它当 cli,PG 不会 —— 两边必须一致
268
+ expect(k("padded")).toBe("cli"); // 与运行时一样先 trim
269
+ expect(k("named")).toBe("cli");
270
+ expect(k("node")).toBe(null);
271
+ expect(k("netfull")).toBe(null);
272
+ // fleet() 的 live 令牌也被回填(列重建后原值丢了,按同一规则算回来)
273
+ expect(kindOf(f.cliByLabel)).toBe("cli");
274
+ expect(kindOf(f.apiToken)).toBe("cli");
275
+ expect(kindOf(f.app)).toBe("login");
276
+
277
+ // 幂等:已有值不被改写(手动改成 login 的 anet 行保持 login),再跑一次结果不变
278
+ db.run("UPDATE api_tokens SET kind = 'login' WHERE token_id = ?1", [id("cli_label")]);
279
+ expect(bootMigration()).toBe(0);
280
+ expect(k("cli_label")).toBe("login");
281
+ expect(k("named")).toBe("cli");
282
+ expect(k("browser")).toBe("login");
283
+
284
+ // 迁移后的库上,keep_cli_tokens 保留回填成 cli 的存量令牌
285
+ const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.app)).token_id;
286
+ const r = changePassword(f.userId, PW, PW2, cur, { keepCliTokens: true });
287
+ expect(r.ok).toBe(true);
288
+ expect(k("named")).toBe("cli");
289
+ expect(k("cli_demo")).toBe("cli");
290
+ expect(k("browser")).toBe(null); // 被撤销(行已删除)
291
+ expect(k("node")).toBe(null);
292
+ expect(Number(db.get<any>("SELECT COUNT(*) AS n FROM api_tokens WHERE token_id = ?1", id("node")).n)).toBe(1);
293
+ }, 120_000);
294
+ });
package/src/auth.ts CHANGED
@@ -137,8 +137,8 @@ export function register(username: string, password: string, email?: string, dis
137
137
  const userToken = generateUserToken();
138
138
  const userTokenId = generateId("tok");
139
139
  db.run(
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)",
141
- [userTokenId, hashToken(userToken), userId, null, "user-login", "user", cleanClientField(opts.client?.label, 64), cleanClientField(opts.client?.userAgent, 256)]
140
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, kind, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1)",
141
+ [userTokenId, hashToken(userToken), userId, null, "user-login", "user", cleanClientField(opts.client?.label, 64), cleanClientField(opts.client?.userAgent, 256), userTokenKindFor(opts.client)]
142
142
  );
143
143
 
144
144
  // Network token (ntok_) — bound to default network, for agent-node
@@ -217,8 +217,8 @@ export function login(username: string, password: string, client: SessionClientI
217
217
  const userToken = generateUserToken();
218
218
  const tokenId = generateId("tok");
219
219
  db.run(
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)",
221
- [tokenId, hashToken(userToken), user.user_id, null, "user-login", "user", cleanClientField(client.label, 64), cleanClientField(client.userAgent, 256)]
220
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, kind, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1)",
221
+ [tokenId, hashToken(userToken), user.user_id, null, "user-login", "user", cleanClientField(client.label, 64), cleanClientField(client.userAgent, 256), userTokenKindFor(client)]
222
222
  );
223
223
 
224
224
  // Find default network
@@ -321,7 +321,28 @@ const SESSION_TOKEN_SQL = "scope = 'user' AND network_id IS NULL";
321
321
  // 以小时为粒度对一个以天计的闲置窗口没有影响。
322
322
  const LAST_USED_WRITE_INTERVAL_SECONDS = 3600;
323
323
 
324
- export type SessionClientInfo = { label?: unknown; userAgent?: unknown };
324
+ export type SessionClientInfo = { label?: unknown; userAgent?: unknown; kind?: unknown };
325
+
326
+ // #711 不绑网络的用户令牌的种类(api_tokens.kind):
327
+ // 'login' — 浏览器 / app 的登录会话;
328
+ // 'cli' — anet 命令行登录、POST /api/auth/tokens 建的具名脚本令牌。
329
+ // 种类由客户端自报(client_kind / client_label),**只用于展示和「显式保留」那条路**:
330
+ // 改密码默认仍撤销全部不绑网络的令牌(当前这条除外),调用方显式 keep_cli_tokens=true 才保留 'cli'。
331
+ // 所以一个自称 cli 的令牌永远不能靠自报在默认改密码里活下来。
332
+ // 节点 / 网络令牌(network_id 非空)不分类,改密码永远不碰。
333
+ export type UserTokenKind = "login" | "cli";
334
+
335
+ /** 登录 / 注册请求签发的令牌算哪一类:客户端显式报 kind='cli',或 client_label 是 anet 自报的「anet <版本> · …」。 */
336
+ /** anet 自报的 client_label:去掉首尾空白后,区分大小写地以「anet 」开头(启动回填用同一条规则,见 db.ts)。 */
337
+ export function isAnetClientLabel(label: string): boolean {
338
+ return label.trim().startsWith("anet ");
339
+ }
340
+
341
+ export function userTokenKindFor(client: SessionClientInfo = {}): UserTokenKind {
342
+ if (client.kind === "cli") return "cli";
343
+ if (typeof client.label === "string" && isAnetClientLabel(client.label)) return "cli";
344
+ return "login";
345
+ }
325
346
 
326
347
  function cleanClientField(value: unknown, max: number): string | null {
327
348
  if (typeof value !== "string") return null;
@@ -508,9 +529,10 @@ export function createToken(userId: string, name: string, networkId?: string): {
508
529
  }
509
530
  const token = generateToken();
510
531
  const tokenId = generateId("tok");
532
+ // #711 不绑网络的具名令牌是给脚本 / 命令行长期用的:kind='cli'(改密码时可显式 keep_cli_tokens 保留)。
511
533
  db.run(
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)",
513
- [tokenId, hashToken(token), userId, networkId || null, name, "full"]
534
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, kind, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1)",
535
+ [tokenId, hashToken(token), userId, networkId || null, name, "full", networkId ? null : "cli"]
514
536
  );
515
537
  return { ok: true, token, token_id: tokenId };
516
538
  }
@@ -520,16 +542,55 @@ export function revokeToken(userId: string, tokenId: string): { ok: boolean; err
520
542
  return result.changes > 0 ? { ok: true } : { ok: false, error: "token not found" };
521
543
  }
522
544
 
523
- export function issueUserToken(userId: string, name = "user-login"): { token: string; token_id: string } {
545
+ export function issueUserToken(
546
+ userId: string,
547
+ name = "user-login",
548
+ opts: { kind?: UserTokenKind; clientLabel?: string | null; userAgent?: string | null } = {},
549
+ ): { token: string; token_id: string } {
524
550
  const token = generateUserToken();
525
551
  const tokenId = generateId("tok");
526
552
  db.run(
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)",
528
- [tokenId, hashToken(token), userId, name]
553
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, kind, node_identity_epoch) VALUES (?1, ?2, ?3, NULL, ?4, 'user', ?5, ?6, ?7, 1)",
554
+ [tokenId, hashToken(token), userId, name, opts.clientLabel ?? null, opts.userAgent ?? null, opts.kind === "cli" ? "cli" : "login"]
529
555
  );
530
556
  return { token, token_id: tokenId };
531
557
  }
532
558
 
559
+ /** 一条令牌的种类和设备标识(改密码换发新令牌时沿用,命令行的令牌换完还是命令行的)。 */
560
+ export function getUserTokenMeta(userId: string, tokenId: string): { kind: UserTokenKind; client_label: string | null; user_agent: string | null } | null {
561
+ const row = db.get<any>("SELECT kind, client_label, user_agent FROM api_tokens WHERE token_id = ?1 AND user_id = ?2 AND network_id IS NULL", tokenId, userId);
562
+ if (!row) return null;
563
+ return { kind: row.kind === "cli" ? "cli" : "login", client_label: row.client_label ?? null, user_agent: row.user_agent ?? null };
564
+ }
565
+
566
+ // #711 改密码撤销哪些令牌。kind 为 NULL 的(启动回填之后由旧版 Hub 签出的)按 'login' 算。
567
+ const CLI_KIND_SQL = "kind = 'cli'";
568
+ const NOT_CLI_KIND_SQL = "COALESCE(kind, 'login') <> 'cli'";
569
+
570
+ /** 改密码后保留下来的命令行 / 脚本令牌(给 UI 列出来、让用户逐条撤销)。**永远不含令牌值。** */
571
+ export type KeptCliToken = { token_id: string; name: string; client_label: string | null; created_at: string; last_used_at: string | null };
572
+
573
+ /**
574
+ * 改密码后的令牌撤销。默认(keepCli=false)= 改密码前的行为:撤销全部不绑网络的令牌;
575
+ * keepCli=true 时保留 kind='cli' 的,并把它们列出来。当前这条(exceptTokenId)和节点 / 网络令牌永远不动。
576
+ */
577
+ export function revokeTokensAfterPasswordChange(userId: string, exceptTokenId: string | null | undefined, keepCli: boolean): { login: number; cli: number; kept: KeptCliToken[] } {
578
+ const except = exceptTokenId ? " AND token_id != ?2" : "";
579
+ const params = exceptTokenId ? [userId, exceptTokenId] : [userId];
580
+ const login = db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND network_id IS NULL AND ${NOT_CLI_KIND_SQL}${except}`, params).changes;
581
+ if (!keepCli) {
582
+ const cli = db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND network_id IS NULL${except}`, params).changes;
583
+ return { login, cli, kept: [] };
584
+ }
585
+ const kept = db.all<any>(
586
+ `SELECT token_id, name, client_label, created_at, last_used_at FROM api_tokens
587
+ WHERE user_id = ?1 AND network_id IS NULL AND ${CLI_KIND_SQL}${except}
588
+ ORDER BY created_at DESC`,
589
+ ...params,
590
+ ).map((r) => ({ token_id: r.token_id, name: r.name, client_label: r.client_label ?? null, created_at: r.created_at, last_used_at: r.last_used_at ?? null }));
591
+ return { login, cli: 0, kept };
592
+ }
593
+
533
594
  export function revokeOtherUserTokens(userId: string, exceptTokenId?: string | null): number {
534
595
  const result = exceptTokenId
535
596
  ? db.run("DELETE FROM api_tokens WHERE user_id = ?1 AND network_id IS NULL AND token_id != ?2", [userId, exceptTokenId])
@@ -544,6 +605,7 @@ export type LoginSession = {
544
605
  last_used_at: string | null;
545
606
  client_label: string | null;
546
607
  user_agent: string | null;
608
+ kind: UserTokenKind;
547
609
  is_current: boolean;
548
610
  };
549
611
 
@@ -551,7 +613,7 @@ export type LoginSession = {
551
613
  export function listSessions(userId: string, currentTokenId: string | null): LoginSession[] {
552
614
  const cutoff = idleCutoffOffset();
553
615
  const rows = db.all<any>(
554
- `SELECT token_id, name, created_at, last_used_at, client_label, user_agent FROM api_tokens
616
+ `SELECT token_id, name, created_at, last_used_at, client_label, user_agent, kind FROM api_tokens
555
617
  WHERE user_id = ?1 AND ${SESSION_TOKEN_SQL} AND revoked_at IS NULL${cutoff ? `
556
618
  AND COALESCE(last_used_at, created_at) >= datetime('now', ?2)` : ""}
557
619
  ORDER BY COALESCE(last_used_at, created_at) DESC, created_at DESC`,
@@ -563,6 +625,7 @@ export function listSessions(userId: string, currentTokenId: string | null): Log
563
625
  last_used_at: r.last_used_at ?? null,
564
626
  client_label: r.client_label ?? null,
565
627
  user_agent: r.user_agent ?? null,
628
+ kind: r.kind === "cli" ? "cli" : "login",
566
629
  is_current: !!currentTokenId && r.token_id === currentTokenId,
567
630
  }));
568
631
  }
@@ -578,7 +641,14 @@ export function revokeOtherSessions(userId: string, currentTokenId: string): num
578
641
  return db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND ${SESSION_TOKEN_SQL} AND token_id != ?2`, [userId, currentTokenId]).changes;
579
642
  }
580
643
 
581
- export function changePassword(userId: string, oldPassword: string, newPassword: string, currentTokenId?: string | null): { ok: boolean; error?: string; revoked?: number } {
644
+ export type ChangePasswordResult = { ok: boolean; error?: string; revoked?: number; revoked_login?: number; revoked_cli?: number; kept_cli_tokens?: KeptCliToken[] };
645
+
646
+ /**
647
+ * 改密码。默认撤销该用户全部不绑网络的令牌(改密码前的行为,旧 app 依赖它)。#711:opts.keepCliTokens=true
648
+ * 时保留 kind='cli' 的(anet 命令行登录、具名脚本令牌),并在 kept_cli_tokens 里列出。当前会话和节点令牌永远保留。
649
+ * revoked = 两类合计(旧字段,兼容旧客户端)。
650
+ */
651
+ export function changePassword(userId: string, oldPassword: string, newPassword: string, currentTokenId?: string | null, opts: { keepCliTokens?: boolean } = {}): ChangePasswordResult {
582
652
  const passwordError = validatePasswordStrength(newPassword, "new password");
583
653
  if (passwordError) return { ok: false, error: passwordError };
584
654
  const user = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", userId);
@@ -593,8 +663,9 @@ export function changePassword(userId: string, oldPassword: string, newPassword:
593
663
  // nudge goes away on next login. SET to 0 explicitly (rather than skip)
594
664
  // so a future flag flip can't drift the state.
595
665
  db.run("UPDATE users SET password_hash = ?1, must_change_password = 0, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
596
- const revoked = revokeOtherUserTokens(userId, currentTokenId);
597
- return { ok: true, revoked };
666
+ const keep = opts.keepCliTokens === true;
667
+ const r = revokeTokensAfterPasswordChange(userId, currentTokenId, keep);
668
+ return { ok: true, revoked: r.login + r.cli, revoked_login: r.login, revoked_cli: r.cli, ...(keep ? { kept_cli_tokens: r.kept } : {}) };
598
669
  }
599
670
 
600
671
  /**
@@ -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
+ });