@sleep2agi/commhub-server 0.9.0-preview.135 → 0.9.0-preview.137
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 +2 -2
- package/src/auth-password-change-cli-tokens-http.test.ts +131 -2
- package/src/auth.ts +13 -9
- package/src/create-node.ts +22 -11
- package/src/host-supervisor-identity-http.test.ts +136 -0
- package/src/host-supervisor-identity.ts +92 -0
- package/src/list-host-supervisors.test.ts +5 -4
- package/src/password-network-tokens.ts +25 -0
- package/src/server.ts +5 -25
- package/src/tools.ts +5 -25
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
4
|
-
"description": "CommHub Server
|
|
3
|
+
"version": "0.9.0-preview.137",
|
|
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",
|
|
7
7
|
"bin": {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// · 默认(旧 app、dashboard 代理不带字段):撤销全部不绑网络的令牌 —— 客户端自报的 cli 也活不下来;
|
|
7
7
|
// · keep_cli_tokens=true(anet passwd 默认带):只撤其他 kind='login' 会话,保留的 cli 令牌列在响应里、不含令牌值;
|
|
8
8
|
// · 当前会话(换发后的新令牌)永远可用,而且沿用当前令牌的种类;
|
|
9
|
-
// ·
|
|
9
|
+
// · 节点令牌不动;具名网络令牌默认保留,可显式revoke_network_tokens;
|
|
10
10
|
// · 启动迁移按 client_label / scope 回填存量令牌的 kind(夹具库,跑两遍幂等)。
|
|
11
11
|
// SQLite 由 test798 自动收进;PostgreSQL 由 tests/test2123-hub-postgres-ladder 的 run_pg_tests_rc 跑同一个文件。
|
|
12
12
|
|
|
@@ -15,7 +15,7 @@ import { mkdtempSync, rmSync } from "fs";
|
|
|
15
15
|
import { join } from "path";
|
|
16
16
|
import { tmpdir } from "os";
|
|
17
17
|
import { spawnSync } from "child_process";
|
|
18
|
-
import { register, createNetworkTokenForNode, createToken, changePassword } from "./auth.js";
|
|
18
|
+
import { register, createNetworkTokenForNode, createToken, createNetwork, changePassword } from "./auth.js";
|
|
19
19
|
import { db, hashToken } from "./db.js";
|
|
20
20
|
|
|
21
21
|
const DIR = mkdtempSync(join(tmpdir(), "anet-pwchange-kind-"));
|
|
@@ -292,3 +292,132 @@ describe("startup migration backfills api_tokens.kind on a fixture DB", () => {
|
|
|
292
292
|
expect(Number(db.get<any>("SELECT COUNT(*) AS n FROM api_tokens WHERE token_id = ?1", id("node")).n)).toBe(1);
|
|
293
293
|
}, 120_000);
|
|
294
294
|
});
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
describe("network password opt-in", () => {
|
|
298
|
+
const id = (token: string) => db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash=?1", hashToken(token)).token_id;
|
|
299
|
+
|
|
300
|
+
test("default lists only the caller's active named network tokens across networks, without credentials", async () => {
|
|
301
|
+
const f = await fleet();
|
|
302
|
+
const second = createNetwork(f.userId, `pwk-second-${seq}`);
|
|
303
|
+
expect(second.ok).toBe(true);
|
|
304
|
+
const extra = createToken(f.userId, "second-network-script", second.network_id);
|
|
305
|
+
const other = await fleet();
|
|
306
|
+
const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2 });
|
|
307
|
+
expect(r.status).toBe(200);
|
|
308
|
+
expect(r.body.revoked_network).toBe(0);
|
|
309
|
+
expect(r.body.revoked).toBe(5);
|
|
310
|
+
expect(r.body.kept_network_tokens.map((x: any) => x.token_id).sort()).toEqual([id(f.netToken), id(extra.token!)].sort());
|
|
311
|
+
for (const row of r.body.kept_network_tokens) {
|
|
312
|
+
expect(Object.keys(row).sort()).toEqual(["created_at", "last_used_at", "name", "network_id", "token_id"]);
|
|
313
|
+
}
|
|
314
|
+
const body = JSON.stringify(r.body);
|
|
315
|
+
expect(body.includes("token_hash")).toBe(false);
|
|
316
|
+
for (const t of [f.netToken, extra.token!, f.nodeToken, other.netToken]) expect(body.includes(t)).toBe(false);
|
|
317
|
+
expect(await works(f.netToken)).toBe(true);
|
|
318
|
+
expect(await works(extra.token!)).toBe(true);
|
|
319
|
+
expect(await works(other.netToken)).toBe(true);
|
|
320
|
+
expect(await works(r.body.token)).toBe(true);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("explicit boolean opt-in revokes only own named network tokens, independently of keep_cli_tokens", async () => {
|
|
324
|
+
const f = await fleet();
|
|
325
|
+
const other = await fleet();
|
|
326
|
+
const second = createNetwork(f.userId, `pwk-revoke-second-${seq}`);
|
|
327
|
+
const extra = createToken(f.userId, "second-network-script", second.network_id);
|
|
328
|
+
const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, keep_cli_tokens: true, revoke_network_tokens: true });
|
|
329
|
+
expect(r.status).toBe(200);
|
|
330
|
+
expect(r.body.revoked_network).toBe(2);
|
|
331
|
+
expect(r.body.revoked_login).toBe(2);
|
|
332
|
+
expect(r.body.revoked_cli).toBe(0);
|
|
333
|
+
expect(r.body.revoked).toBe(4);
|
|
334
|
+
expect(r.body.kept_network_tokens).toEqual([]);
|
|
335
|
+
expect(await works(f.netToken)).toBe(false);
|
|
336
|
+
expect(await works(extra.token!)).toBe(false);
|
|
337
|
+
expect(await works(other.netToken)).toBe(true);
|
|
338
|
+
expect(await works(other.app)).toBe(true);
|
|
339
|
+
expect(await works(f.nodeToken)).toBe(true);
|
|
340
|
+
expect(await works(f.apiToken)).toBe(true);
|
|
341
|
+
expect(await works(r.body.token)).toBe(true);
|
|
342
|
+
expect(await works(f.app)).toBe(false);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
for (const value of [false, "true", 1, null, {}]) {
|
|
346
|
+
test(`non-true option ${JSON.stringify(value)} cannot revoke network tokens`, async () => {
|
|
347
|
+
const f = await fleet();
|
|
348
|
+
const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, revoke_network_tokens: value });
|
|
349
|
+
expect(r.status).toBe(200);
|
|
350
|
+
expect(r.body.revoked_network).toBe(0);
|
|
351
|
+
expect(r.body.kept_network_tokens).toHaveLength(1);
|
|
352
|
+
expect(await works(f.netToken)).toBe(true);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
test("scope and binding protect node credentials; inactive rows are neither listed nor counted", async () => {
|
|
357
|
+
const f = await fleet();
|
|
358
|
+
const legacy = createNetworkTokenForNode(f.userId, f.net, `legacy-pwk-${seq}`);
|
|
359
|
+
expect(legacy.ok).toBe(true);
|
|
360
|
+
const bound = createToken(f.userId, "mislabelled-bound-node", f.net);
|
|
361
|
+
db.run("UPDATE api_tokens SET bound_node_id=?1 WHERE token_id=?2", ["n-protected717", bound.token_id]);
|
|
362
|
+
const expired = createToken(f.userId, "expired-script", f.net);
|
|
363
|
+
db.run("UPDATE api_tokens SET expires_at=datetime('now','-1 day') WHERE token_id=?1", [expired.token_id]);
|
|
364
|
+
const revoked = createToken(f.userId, "revoked-script", f.net);
|
|
365
|
+
db.run("UPDATE api_tokens SET revoked_at=datetime('now') WHERE token_id=?1", [revoked.token_id]);
|
|
366
|
+
const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, revoke_network_tokens: true });
|
|
367
|
+
expect(r.status).toBe(200);
|
|
368
|
+
expect(r.body.revoked_network).toBe(1);
|
|
369
|
+
expect(await works(f.nodeToken)).toBe(true);
|
|
370
|
+
expect(await works(bound.token!)).toBe(true);
|
|
371
|
+
for (const t of [bound, expired, revoked, legacy]) expect(db.get("SELECT token_id FROM api_tokens WHERE token_id=?1", t.token_id)).not.toBeNull();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("bad old password or weak new password has no token side effects", async () => {
|
|
375
|
+
for (const body of [{ old_password: "wrong", new_password: PW2 }, { old_password: PW, new_password: "123" }]) {
|
|
376
|
+
const f = await fleet();
|
|
377
|
+
const r = await call(f.app, "POST", "/api/auth/password", { ...body, revoke_network_tokens: true });
|
|
378
|
+
expect(r.status).toBe(400);
|
|
379
|
+
for (const t of [f.app, f.netToken, f.apiToken, f.nodeToken]) expect(await works(t)).toBe(true);
|
|
380
|
+
expect((await call("", "POST", "/api/auth/login", { username: f.user, password: PW })).status).toBe(200);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("network-scoped and unauthenticated credentials cannot invoke password change", async () => {
|
|
385
|
+
const f = await fleet();
|
|
386
|
+
for (const t of [f.netToken, f.nodeToken, ""]) {
|
|
387
|
+
expect((await call(t, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, revoke_network_tokens: true })).status).toBe(t ? 403 : 401);
|
|
388
|
+
}
|
|
389
|
+
expect(await works(f.netToken)).toBe(true);
|
|
390
|
+
expect(await works(f.app)).toBe(true);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("a failed network revoke rolls back the password and earlier session deletions", async () => {
|
|
394
|
+
const f = await fleet();
|
|
395
|
+
const victim = id(f.netToken);
|
|
396
|
+
const fn = `pw717_failure_${seq}`;
|
|
397
|
+
const trigger = `pw717_trigger_${seq}`;
|
|
398
|
+
if (db.dialect === "postgres") db.exec(`CREATE FUNCTION ${fn}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.token_id='${victim}' THEN RAISE EXCEPTION 'fixture717_delete_failure'; END IF; RETURN OLD; END; $$; CREATE TRIGGER ${trigger} BEFORE DELETE ON api_tokens FOR EACH ROW EXECUTE FUNCTION ${fn}()`);
|
|
399
|
+
else db.exec(`CREATE TRIGGER ${trigger} BEFORE DELETE ON api_tokens WHEN OLD.token_id='${victim}' BEGIN SELECT RAISE(ABORT,'fixture717_delete_failure'); END`);
|
|
400
|
+
try {
|
|
401
|
+
expect((await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, revoke_network_tokens: true })).status).toBe(400);
|
|
402
|
+
for (const t of [f.app, f.appB, f.apiToken, f.netToken, f.nodeToken]) expect(await works(t)).toBe(true);
|
|
403
|
+
expect((await call("", "POST", "/api/auth/login", { username: f.user, password: PW })).status).toBe(200);
|
|
404
|
+
expect((await call("", "POST", "/api/auth/login", { username: f.user, password: PW2 })).status).toBe(401);
|
|
405
|
+
} finally {
|
|
406
|
+
db.exec(db.dialect === "postgres" ? `DROP TRIGGER ${trigger} ON api_tokens; DROP FUNCTION ${fn}()` : `DROP TRIGGER ${trigger}`);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("direct changePassword keeps its current row and defaults to listing network tokens", async () => {
|
|
411
|
+
const f = await fleet();
|
|
412
|
+
const cur = id(f.app);
|
|
413
|
+
const r = changePassword(f.userId, PW, PW2, cur);
|
|
414
|
+
expect(r.ok).toBe(true);
|
|
415
|
+
expect(r.kept_network_tokens).toHaveLength(1);
|
|
416
|
+
expect(await works(f.app)).toBe(true);
|
|
417
|
+
const r2 = changePassword(f.userId, PW2, PW, cur, { revokeNetworkTokens: true, keepCliTokens: true });
|
|
418
|
+
expect(r2.ok).toBe(true);
|
|
419
|
+
expect(r2.revoked_network).toBe(1);
|
|
420
|
+
expect(await works(f.app)).toBe(true);
|
|
421
|
+
expect(await works(f.nodeToken)).toBe(true);
|
|
422
|
+
});
|
|
423
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* V3 Auth module — user registration, login, token management
|
|
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
|
-
import { WEAK_PASSWORDS } from "./password-dict.js";
|
|
5
|
+
import { WEAK_PASSWORDS } from "./password-dict.js"; import { passwordNetworkTokens, type KeptNetworkToken } from "./password-network-tokens.js";
|
|
6
6
|
import { checkNodeTokenClaim, legacyNodeHolder } from "./node-token-ownership.js";
|
|
7
7
|
import { NETWORK_REST_COLUMNS, NETWORK_REST_SELECT, sqlColumns } from "./rest-projections.js";
|
|
8
8
|
import { deleteAgentGrants, isAgentRestricted } from "./agent-access.js"; import { deleteTaskGrantsForMember, newMemberTaskAccess, type TaskAccessMode } from "./task-access.js"; // 一行两个 import:文档钉着 auth.ts 的行号
|
|
@@ -330,7 +330,7 @@ export type SessionClientInfo = { label?: unknown; userAgent?: unknown; kind?: u
|
|
|
330
330
|
// 种类由客户端自报(client_kind / client_label),**只用于展示和「显式保留」那条路**:
|
|
331
331
|
// 改密码默认仍撤销全部不绑网络的令牌(当前这条除外),调用方显式 keep_cli_tokens=true 才保留 'cli'。
|
|
332
332
|
// 所以一个自称 cli 的令牌永远不能靠自报在默认改密码里活下来。
|
|
333
|
-
// 节点 / 网络令牌(network_id 非空)
|
|
333
|
+
// 节点 / 网络令牌(network_id 非空)不按cli分类;具名网络令牌另有显式撤销选项。
|
|
334
334
|
export type UserTokenKind = "login" | "cli";
|
|
335
335
|
|
|
336
336
|
/** 登录 / 注册请求签发的令牌算哪一类:客户端显式报 kind='cli',或 client_label 是 anet 自报的「anet <版本> · …」。 */
|
|
@@ -642,14 +642,14 @@ export function revokeOtherSessions(userId: string, currentTokenId: string): num
|
|
|
642
642
|
return db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND ${SESSION_TOKEN_SQL} AND token_id != ?2`, [userId, currentTokenId]).changes;
|
|
643
643
|
}
|
|
644
644
|
|
|
645
|
-
export type ChangePasswordResult = { ok: boolean; error?: string; revoked?: number; revoked_login?: number; revoked_cli?: number; kept_cli_tokens?: KeptCliToken[] };
|
|
645
|
+
export type ChangePasswordResult = { ok: boolean; error?: string; revoked?: number; revoked_login?: number; revoked_cli?: number; kept_cli_tokens?: KeptCliToken[]; revoked_network?: number; kept_network_tokens?: KeptNetworkToken[] };
|
|
646
646
|
|
|
647
647
|
/**
|
|
648
648
|
* 改密码。默认撤销该用户全部不绑网络的令牌(改密码前的行为,旧 app 依赖它)。#711:opts.keepCliTokens=true
|
|
649
649
|
* 时保留 kind='cli' 的(anet 命令行登录、具名脚本令牌),并在 kept_cli_tokens 里列出。当前会话和节点令牌永远保留。
|
|
650
|
-
* revoked
|
|
650
|
+
* revokeNetworkTokens=true显式撤销具名网络令牌;默认列出保留清单。revoked含三类合计。
|
|
651
651
|
*/
|
|
652
|
-
export function changePassword(userId: string, oldPassword: string, newPassword: string, currentTokenId?: string | null, opts: { keepCliTokens?: boolean } = {}): ChangePasswordResult {
|
|
652
|
+
export function changePassword(userId: string, oldPassword: string, newPassword: string, currentTokenId?: string | null, opts: { keepCliTokens?: boolean; revokeNetworkTokens?: boolean } = {}): ChangePasswordResult {
|
|
653
653
|
const passwordError = validatePasswordStrength(newPassword, "new password");
|
|
654
654
|
if (passwordError) return { ok: false, error: passwordError };
|
|
655
655
|
const user = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", userId);
|
|
@@ -663,10 +663,14 @@ export function changePassword(userId: string, oldPassword: string, newPassword:
|
|
|
663
663
|
// password change. If the user changes via `anet passwd`, the bootstrap
|
|
664
664
|
// nudge goes away on next login. SET to 0 explicitly (rather than skip)
|
|
665
665
|
// so a future flag flip can't drift the state.
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
666
|
+
const nextHash = hashPassword(newPassword);
|
|
667
|
+
return db.transaction(() => {
|
|
668
|
+
db.run("UPDATE users SET password_hash = ?1, must_change_password = 0, updated_at = datetime('now') WHERE user_id = ?2", [nextHash, userId]);
|
|
669
|
+
const keep = opts.keepCliTokens === true;
|
|
670
|
+
const r = revokeTokensAfterPasswordChange(userId, currentTokenId, keep);
|
|
671
|
+
const network = passwordNetworkTokens(userId, currentTokenId, opts.revokeNetworkTokens === true);
|
|
672
|
+
return { ok: true, revoked: r.login + r.cli + network.revoked, revoked_login: r.login, revoked_cli: r.cli, ...(keep ? { kept_cli_tokens: r.kept } : {}), revoked_network: network.revoked, kept_network_tokens: network.kept };
|
|
673
|
+
});
|
|
670
674
|
}
|
|
671
675
|
|
|
672
676
|
/**
|
package/src/create-node.ts
CHANGED
|
@@ -237,7 +237,8 @@ export type NodeCallerResolution =
|
|
|
237
237
|
| { ok: true; kind: "unregistered"; alias: string; networkId: string }
|
|
238
238
|
| { ok: false; reason: "not_a_node_token" | "bound_node_missing" | "ambiguous" | "not_owner" };
|
|
239
239
|
|
|
240
|
-
type NodeCallerRow = { node_id: string; alias: string; network_id: string; owner_user_id: string | null };
|
|
240
|
+
export type NodeCallerRow = { node_id: string; alias: string; network_id: string; owner_user_id: string | null };
|
|
241
|
+
export type NodeCallerToken = { name: string; network_id: string | null; user_id: string | null; bound_node_id: string | null; node_identity_epoch: number };
|
|
241
242
|
|
|
242
243
|
// nodes has no unique (network, alias). An unbound token that hits several
|
|
243
244
|
// rows is not rejected outright: the row owned by the token's issuer wins;
|
|
@@ -246,18 +247,12 @@ type NodeCallerRow = { node_id: string; alias: string; network_id: string; owner
|
|
|
246
247
|
function disambiguateUnboundAlias(
|
|
247
248
|
rows: NodeCallerRow[],
|
|
248
249
|
userId: string | null,
|
|
249
|
-
|
|
250
|
-
networkId: string,
|
|
250
|
+
recentNodeId: string | null,
|
|
251
251
|
): NodeCallerRow | undefined {
|
|
252
252
|
const owned = userId ? rows.filter((row) => row.owner_user_id === userId) : [];
|
|
253
253
|
if (owned.length === 1) return owned[0]; // board679-unbound-dup
|
|
254
254
|
const pool = owned.length > 1 ? owned : rows;
|
|
255
|
-
const
|
|
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) : [];
|
|
255
|
+
const hit = recentNodeId ? pool.filter((row) => row.node_id === recentNodeId) : [];
|
|
261
256
|
return hit.length === 1 ? hit[0] : undefined;
|
|
262
257
|
}
|
|
263
258
|
|
|
@@ -268,7 +263,7 @@ function disambiguateUnboundAlias(
|
|
|
268
263
|
// do not get a second exception.
|
|
269
264
|
export function resolveNodeCaller(tokenId: string | null | undefined): NodeCallerResolution {
|
|
270
265
|
if (!tokenId) return { ok: false, reason: "not_a_node_token" };
|
|
271
|
-
const tokRow = db.get<
|
|
266
|
+
const tokRow = db.get<NodeCallerToken>(
|
|
272
267
|
`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
268
|
tokenId,
|
|
274
269
|
);
|
|
@@ -280,10 +275,26 @@ export function resolveNodeCaller(tokenId: string | null | undefined): NodeCalle
|
|
|
280
275
|
const rows = tokRow.bound_node_id
|
|
281
276
|
? 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
277
|
: 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);
|
|
278
|
+
const recent = !tokRow.bound_node_id && rows.length > 1
|
|
279
|
+
? db.get<{ node_id: string | null }>(
|
|
280
|
+
`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`,
|
|
281
|
+
tokenAlias, tokRow.network_id,
|
|
282
|
+
) : null;
|
|
283
|
+
return resolveNodeCallerFromRows(tokRow, rows, recent?.node_id ?? null);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Shared identity contract for individual callers and scoped, batched list reads. */
|
|
287
|
+
export function resolveNodeCallerFromRows(
|
|
288
|
+
tokRow: NodeCallerToken, rows: NodeCallerRow[], recentNodeId: string | null,
|
|
289
|
+
): NodeCallerResolution {
|
|
290
|
+
if (!tokRow.name?.startsWith("node:") || !tokRow.network_id) {
|
|
291
|
+
return { ok: false, reason: "not_a_node_token" };
|
|
292
|
+
}
|
|
293
|
+
const tokenAlias = tokRow.name.slice(5);
|
|
283
294
|
const nodeRow = rows.length === 1
|
|
284
295
|
? rows[0]
|
|
285
296
|
: (!tokRow.bound_node_id && rows.length > 1
|
|
286
|
-
? disambiguateUnboundAlias(rows, tokRow.user_id,
|
|
297
|
+
? disambiguateUnboundAlias(rows, tokRow.user_id, recentNodeId)
|
|
287
298
|
: undefined);
|
|
288
299
|
// board679-bound-row-missing: a bound token with no row is refused. Do not look up by name.
|
|
289
300
|
if (tokRow.bound_node_id && !nodeRow) return { ok: false, reason: "bound_node_missing" };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { db } from "./db.js";
|
|
4
|
+
import { register } from "./auth.js";
|
|
5
|
+
import { registerTools } from "./tools.js";
|
|
6
|
+
import { hostSupervisorIdentityRows } from "./host-supervisor-identity.js";
|
|
7
|
+
|
|
8
|
+
if (!process.env.COMMHUB_DB && !process.env.COMMHUB_TEST_PG_URL) throw Error("explicit fixture DB required");
|
|
9
|
+
let net: string, foreign: string, user: string, token: string, hub: any, base: string;
|
|
10
|
+
let sequence = 0;
|
|
11
|
+
const next = () => `n_lhs1109_${++sequence}`;
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
const auth = register(`lhs1109_${Date.now()}`, "FixturePassword1109!", undefined, "fixture");
|
|
14
|
+
if (!auth.ok || !auth.token || !auth.user || !auth.network_id) throw Error("fixture registration failed");
|
|
15
|
+
user = auth.user.user_id; token = auth.token; net = auth.network_id;
|
|
16
|
+
foreign = `net_foreign_lhs1109_${Date.now()}`;
|
|
17
|
+
db.run("INSERT INTO networks(network_id,network_name,owner_id) VALUES(?1,'foreign',?2)", [foreign, user]);
|
|
18
|
+
process.env.HOST = "127.0.0.1";
|
|
19
|
+
const { bootServer } = await import("./server.js");
|
|
20
|
+
hub = await bootServer({ port: 0 }); base = `http://127.0.0.1:${hub.port}`;
|
|
21
|
+
}, 30_000);
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
db.run("DELETE FROM api_tokens WHERE name LIKE 'node:lhs1109_%'");
|
|
24
|
+
db.run("DELETE FROM sessions WHERE alias LIKE 'lhs1109_%'");
|
|
25
|
+
db.run("DELETE FROM nodes WHERE alias LIKE 'lhs1109_%'");
|
|
26
|
+
});
|
|
27
|
+
afterAll(() => hub?.stop(true));
|
|
28
|
+
|
|
29
|
+
function node(alias: string, network = net, owner: string | null = user) {
|
|
30
|
+
const id = next();
|
|
31
|
+
db.run(`INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,config_snapshot)
|
|
32
|
+
VALUES(?1,?2,?2,?3,?4,'{"role":"host_supervisor"}')`, [id, `lhs1109_${alias}`, network, owner]);
|
|
33
|
+
return id;
|
|
34
|
+
}
|
|
35
|
+
function credential(id: string, opts: { bound?: string | null; name?: string; issuer?: string; epoch?: number; network?: string; scope?: string; expired?: boolean; revoked?: boolean } = {}) {
|
|
36
|
+
const n = db.get<any>("SELECT alias, network_id FROM nodes WHERE node_id=?1", id)!;
|
|
37
|
+
const tid = `tok_lhs1109_${++sequence}`;
|
|
38
|
+
db.run(`INSERT INTO api_tokens(token_id,token_hash,user_id,network_id,scope,name,bound_node_id,node_identity_epoch,expires_at,revoked_at)
|
|
39
|
+
VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)`, [tid, `hash_${tid}`, opts.issuer ?? user,
|
|
40
|
+
opts.network ?? n.network_id, opts.scope ?? "network", `node:${opts.name ?? n.alias}`,
|
|
41
|
+
opts.bound === undefined ? id : opts.bound, opts.epoch ?? 2,
|
|
42
|
+
opts.expired ? "2000-01-01 00:00:00" : null, opts.revoked ? "2000-01-01 00:00:00" : null]);
|
|
43
|
+
return tid;
|
|
44
|
+
}
|
|
45
|
+
function heartbeat(id: string, opts: { bound?: string | null; network?: string | null; alias?: string; age?: number } = {}) {
|
|
46
|
+
const n = db.get<any>("SELECT alias,network_id FROM nodes WHERE node_id=?1", id)!;
|
|
47
|
+
db.run(`INSERT INTO sessions(resume_id,alias,network_id,node_id,last_seen_at,status,cpu_cores,mem_total_gb,ip)
|
|
48
|
+
VALUES(?1,?2,?3,?4,?5,'idle',8,16,'10.1.1.1')`, [`s_lhs1109_${++sequence}`, opts.alias ?? n.alias,
|
|
49
|
+
opts.network === undefined ? n.network_id : opts.network, opts.bound === undefined ? id : opts.bound,
|
|
50
|
+
new Date(Date.now() - (opts.age ?? 0)).toISOString()]);
|
|
51
|
+
}
|
|
52
|
+
async function list(entry: "MCP" | "REST") {
|
|
53
|
+
if (entry === "REST") {
|
|
54
|
+
const r = await fetch(`${base}/api/host-supervisors?network_id=${net}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
55
|
+
expect(r.status).toBe(200); return (await r.json() as any).daemons as any[];
|
|
56
|
+
}
|
|
57
|
+
const server = new McpServer({ name: "fixture", version: "0" }) as any;
|
|
58
|
+
let handler: any;
|
|
59
|
+
const original = server.tool.bind(server);
|
|
60
|
+
server.tool = (name: string, desc: string, schema: any, fn: any) => {
|
|
61
|
+
if (name === "list_host_supervisors") handler = fn;
|
|
62
|
+
return original(name, desc, schema, fn);
|
|
63
|
+
};
|
|
64
|
+
registerTools(server, undefined, null, user, null, false, null);
|
|
65
|
+
const result = await handler({ network_id: net });
|
|
66
|
+
const parsed = JSON.parse(result.content[0].text); expect(parsed.ok).toBe(true); return parsed.daemons as any[];
|
|
67
|
+
}
|
|
68
|
+
for (const entry of ["MCP", "REST"] as const) {
|
|
69
|
+
test(`${entry}: bound credentials survive stale token names and rotations dedupe`, async () => {
|
|
70
|
+
const id = node("renamed"); credential(id, { name: "lhs1109_oldname" }); credential(id); heartbeat(id);
|
|
71
|
+
const rows = await list(entry); expect(rows.length).toBe(1);
|
|
72
|
+
expect(rows[0]).toMatchObject({ daemon_node_id: id, online: true });
|
|
73
|
+
expect(JSON.stringify(rows)).not.toContain("hash_");
|
|
74
|
+
});
|
|
75
|
+
test(`${entry}: same-name foreign binding and missing bound node cannot authenticate daemon`, async () => {
|
|
76
|
+
const id = node("victim"), other = node("victim", foreign);
|
|
77
|
+
credential(id, { bound: other }); credential(id, { bound: "n_lhs1109_missing" }); heartbeat(id);
|
|
78
|
+
expect(await list(entry)).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
test(`${entry}: legacy owner and epoch-zero ownerless keep compatibility; squatter and new ownerless rejected`, async () => {
|
|
81
|
+
const good = node("owned"), old = node("old", net, null), squatter = node("squatter"), recent = node("recent", net, null);
|
|
82
|
+
credential(good, { bound: null }); credential(old, { bound: null, epoch: 0 });
|
|
83
|
+
credential(squatter, { bound: null, issuer: "u_foreign_fixture" }); credential(recent, { bound: null, epoch: 1 });
|
|
84
|
+
const ids = (await list(entry)).map(r => r.daemon_node_id).sort(); expect(ids).toEqual([good, old].sort());
|
|
85
|
+
});
|
|
86
|
+
test(`${entry}: same-alias sibling heartbeat cannot make stopped daemon online`, async () => {
|
|
87
|
+
const stopped = node("duplicate"), live = node("duplicate"); credential(stopped); credential(live); heartbeat(live);
|
|
88
|
+
const rows = await list(entry);
|
|
89
|
+
expect(rows.find(r => r.daemon_node_id === stopped)).toMatchObject({ online: false, last_seen_at: null });
|
|
90
|
+
expect(rows.find(r => r.daemon_node_id === live)).toMatchObject({ online: true });
|
|
91
|
+
});
|
|
92
|
+
test(`${entry}: null-ID heartbeat requires unique alias in its network`, async () => {
|
|
93
|
+
const unique = node("unique"), a = node("ambiguous"), b = node("ambiguous");
|
|
94
|
+
for (const id of [unique,a,b]) credential(id);
|
|
95
|
+
heartbeat(unique, { bound: null }); heartbeat(a, { bound: null });
|
|
96
|
+
const rows = await list(entry);
|
|
97
|
+
expect(rows.find(r => r.daemon_node_id === unique).online).toBe(true);
|
|
98
|
+
expect(rows.filter(r => r.daemon_node_id !== unique).map(r => r.online)).toEqual([false,false]);
|
|
99
|
+
});
|
|
100
|
+
test(`${entry}: null-network legacy heartbeat is accepted only for globally unique aliases`, async () => {
|
|
101
|
+
const unique = node("globalunique"), shared = node("globalshared"); node("globalshared", foreign);
|
|
102
|
+
credential(unique); credential(shared);
|
|
103
|
+
heartbeat(unique, { bound: null, network: null }); heartbeat(shared, { bound: null, network: null });
|
|
104
|
+
const rows = await list(entry);
|
|
105
|
+
expect(rows.find(r => r.daemon_node_id === unique).online).toBe(true);
|
|
106
|
+
expect(rows.find(r => r.daemon_node_id === shared).online).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
test(`${entry}: bound heartbeat ignores stale alias but refuses wrong network and stale timestamps`, async () => {
|
|
109
|
+
const renamed = node("newalias"), wrong = node("wrongnet"), stale = node("stale");
|
|
110
|
+
for (const id of [renamed,wrong,stale]) credential(id);
|
|
111
|
+
heartbeat(renamed, { alias: "lhs1109_stalealias" }); heartbeat(wrong, { network: foreign }); heartbeat(stale, { age: 360_000 });
|
|
112
|
+
const rows = await list(entry);
|
|
113
|
+
expect(rows.find(r => r.daemon_node_id === renamed).online).toBe(true);
|
|
114
|
+
expect(rows.filter(r => r.daemon_node_id !== renamed).map(r => r.online)).toEqual([false,false]);
|
|
115
|
+
});
|
|
116
|
+
test(`${entry}: unbound duplicate-owner disambiguation uses authoritative recent session`, async () => {
|
|
117
|
+
const a = node("unboundduplicate"), b = node("unboundduplicate"); credential(a, { bound: null }); heartbeat(b);
|
|
118
|
+
const rows = await list(entry); expect(rows.length).toBe(1); expect(rows[0]).toMatchObject({ daemon_node_id: b, online: true });
|
|
119
|
+
});
|
|
120
|
+
test(`${entry}: deleted, revoked, expired and wrong-scope credentials excluded`, async () => {
|
|
121
|
+
for (const [label,opts] of [["expired",{ expired:true }],["revoked",{ revoked:true }],["scope",{ scope:"full" }],["deleted",{}]] as const) {
|
|
122
|
+
const id = node(label), tid = credential(id, opts); heartbeat(id);
|
|
123
|
+
if (label === "deleted") db.run("DELETE FROM api_tokens WHERE token_id=?1", [tid]);
|
|
124
|
+
}
|
|
125
|
+
expect(await list(entry)).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
test("batched list performs three reads for many daemons and multiple scoped networks", () => {
|
|
129
|
+
for (let i=0;i<30;i++) { const id=node(`fleet${i}`, i%2 ? net : foreign); credential(id); heartbeat(id); }
|
|
130
|
+
const original = db.all.bind(db); let reads=0;
|
|
131
|
+
db.all = ((sql: string, ...params: any[]) => { reads++; return original(sql, ...params); }) as any;
|
|
132
|
+
try {
|
|
133
|
+
const rows = hostSupervisorIdentityRows("SELECT * FROM nodes WHERE network_id=?1 OR network_id=?2", [net,foreign]);
|
|
134
|
+
expect(rows.length).toBe(30); expect(reads).toBe(3);
|
|
135
|
+
} finally { db.all = original; }
|
|
136
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
import { resolveNodeCallerFromRows, type NodeCallerRow, type NodeCallerToken } from "./create-node.js";
|
|
3
|
+
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
4
|
+
|
|
5
|
+
type NodeRow = NodeCallerRow & Record<string, any>;
|
|
6
|
+
type SessionRow = {
|
|
7
|
+
node_id: string | null; alias: string; network_id: string | null;
|
|
8
|
+
last_seen_at: string | null; status: string | null;
|
|
9
|
+
cpu_cores: number | null; mem_total_gb: number | null; ip: string | null;
|
|
10
|
+
legacy_alias_count: number;
|
|
11
|
+
};
|
|
12
|
+
const key = (network: string, alias: string) => JSON.stringify([network, alias]);
|
|
13
|
+
const push = <T>(map: Map<string, T[]>, id: string, value: T) => {
|
|
14
|
+
const list = map.get(id) ?? []; list.push(value); map.set(id, list);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** The caller supplies a scoped nodes query, never an unchecked network id.
|
|
18
|
+
* Three SQL reads regardless of fleet size; no token secrets are selected.
|
|
19
|
+
* All node roles participate in legacy alias disambiguation.
|
|
20
|
+
*/
|
|
21
|
+
export function hostSupervisorIdentityRows(scopedNodesSql: string, params: any[]): NodeRow[] {
|
|
22
|
+
const nodes = db.all<NodeRow>(scopedNodesSql, ...params);
|
|
23
|
+
if (!nodes.length) return [];
|
|
24
|
+
const tokens = db.all<NodeCallerToken>(`
|
|
25
|
+
SELECT t.name, t.network_id, t.user_id, t.bound_node_id, t.node_identity_epoch
|
|
26
|
+
FROM api_tokens t
|
|
27
|
+
WHERE t.network_id IN (SELECT scoped.network_id FROM (${scopedNodesSql}) scoped)
|
|
28
|
+
AND t.scope='network' AND t.revoked_at IS NULL
|
|
29
|
+
AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))
|
|
30
|
+
AND t.name LIKE 'node:%'
|
|
31
|
+
`, ...params);
|
|
32
|
+
const sessions = db.all<SessionRow>(`
|
|
33
|
+
SELECT s.node_id, s.alias, s.network_id, s.last_seen_at, s.status,
|
|
34
|
+
s.cpu_cores, s.mem_total_gb, s.ip,
|
|
35
|
+
CASE WHEN s.network_id IS NULL AND s.node_id IS NULL THEN
|
|
36
|
+
(SELECT COUNT(*) FROM nodes n WHERE n.alias=s.alias)
|
|
37
|
+
ELSE 0 END AS legacy_alias_count
|
|
38
|
+
FROM sessions s
|
|
39
|
+
WHERE s.network_id IN (SELECT scoped.network_id FROM (${scopedNodesSql}) scoped)
|
|
40
|
+
OR (s.network_id IS NULL AND (
|
|
41
|
+
s.alias IN (SELECT scoped.alias FROM (${scopedNodesSql}) scoped)
|
|
42
|
+
OR s.node_id IN (SELECT scoped.node_id FROM (${scopedNodesSql}) scoped)))
|
|
43
|
+
ORDER BY s.updated_at DESC
|
|
44
|
+
`, ...params);
|
|
45
|
+
const byId = new Map(nodes.map(n => [n.node_id, n]));
|
|
46
|
+
const byAlias = new Map<string, NodeRow[]>();
|
|
47
|
+
const byGlobalAlias = new Map<string, NodeRow[]>();
|
|
48
|
+
for (const n of nodes) {
|
|
49
|
+
push(byAlias, key(n.network_id, n.alias), n);
|
|
50
|
+
push(byGlobalAlias, n.alias, n);
|
|
51
|
+
}
|
|
52
|
+
const recent = new Map<string, string>();
|
|
53
|
+
for (const s of sessions) {
|
|
54
|
+
if (!s.network_id || !s.node_id) continue;
|
|
55
|
+
const id = key(s.network_id, s.alias);
|
|
56
|
+
if (!recent.has(id)) recent.set(id, s.node_id);
|
|
57
|
+
}
|
|
58
|
+
const credentialed = new Set<string>();
|
|
59
|
+
for (const t of tokens) {
|
|
60
|
+
if (!t.network_id) continue;
|
|
61
|
+
const bound = t.bound_node_id ? byId.get(t.bound_node_id) : null;
|
|
62
|
+
const rows = t.bound_node_id
|
|
63
|
+
? (bound?.network_id === t.network_id ? [bound] : [])
|
|
64
|
+
: byAlias.get(key(t.network_id, t.name.slice(5))) ?? [];
|
|
65
|
+
const caller = resolveNodeCallerFromRows(t, rows, recent.get(key(t.network_id, t.name.slice(5))) ?? null);
|
|
66
|
+
if (caller.ok && caller.kind === "node") credentialed.add(caller.nodeId);
|
|
67
|
+
}
|
|
68
|
+
const heartbeat = new Map<string, SessionRow>();
|
|
69
|
+
for (const s of sessions) {
|
|
70
|
+
let node: NodeRow | undefined;
|
|
71
|
+
if (s.node_id) {
|
|
72
|
+
node = byId.get(s.node_id);
|
|
73
|
+
if (s.network_id !== null && node?.network_id !== s.network_id) continue;
|
|
74
|
+
} else if (s.network_id) {
|
|
75
|
+
const matches = byAlias.get(key(s.network_id, s.alias)) ?? [];
|
|
76
|
+
if (matches.length === 1) node = matches[0];
|
|
77
|
+
} else if (Number(s.legacy_alias_count) === 1) {
|
|
78
|
+
node = byGlobalAlias.get(s.alias)?.[0];
|
|
79
|
+
}
|
|
80
|
+
if (!node || !s.last_seen_at || !Number.isFinite(parseDbTimestampMs(s.last_seen_at))) continue;
|
|
81
|
+
const previous = heartbeat.get(node.node_id);
|
|
82
|
+
if (!previous || parseDbTimestampMs(s.last_seen_at) > parseDbTimestampMs(previous.last_seen_at!)) {
|
|
83
|
+
heartbeat.set(node.node_id, s);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return nodes.filter(n => credentialed.has(n.node_id)).map(n => {
|
|
87
|
+
const s = heartbeat.get(n.node_id);
|
|
88
|
+
return { ...n, session_last_seen: s?.last_seen_at ?? null, session_status: s?.status ?? null,
|
|
89
|
+
session_cpu_cores: s?.cpu_cores ?? null, session_mem_total_gb: s?.mem_total_gb ?? null,
|
|
90
|
+
session_ip: s?.ip ?? null };
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -40,6 +40,7 @@ function cleanup() {
|
|
|
40
40
|
try { db.run("DELETE FROM users WHERE user_id LIKE 'u_lhs_%'"); } catch {}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Each case starts empty; plain INSERT keeps these fixtures portable to PostgreSQL.
|
|
43
44
|
beforeEach(cleanup);
|
|
44
45
|
afterAll(cleanup);
|
|
45
46
|
|
|
@@ -52,7 +53,7 @@ function seedUser(user_id: string) {
|
|
|
52
53
|
}
|
|
53
54
|
function seedNetwork(net_id: string, owner_id = "u_lhs_owner") {
|
|
54
55
|
db.run(
|
|
55
|
-
`INSERT
|
|
56
|
+
`INSERT INTO networks (network_id, network_name, owner_id, created_at)
|
|
56
57
|
VALUES (?1, ?2, ?3, datetime('now'))`,
|
|
57
58
|
[net_id, net_id, owner_id],
|
|
58
59
|
);
|
|
@@ -81,7 +82,7 @@ function seedDaemon(opts: {
|
|
|
81
82
|
allowed_secret_keys: opts.secrets ?? [],
|
|
82
83
|
});
|
|
83
84
|
db.run(
|
|
84
|
-
`INSERT
|
|
85
|
+
`INSERT INTO nodes (
|
|
85
86
|
node_id, node_name, alias, network_id,
|
|
86
87
|
runtimes_supported, allowed_secret_keys,
|
|
87
88
|
config_snapshot, hostname,
|
|
@@ -95,14 +96,14 @@ function seedDaemon(opts: {
|
|
|
95
96
|
if (opts.sessionAgeMs !== undefined) {
|
|
96
97
|
const ts = new Date(Date.now() - opts.sessionAgeMs).toISOString().replace("T", " ").replace(/\..+/, "");
|
|
97
98
|
db.run(
|
|
98
|
-
`INSERT
|
|
99
|
+
`INSERT INTO sessions (resume_id, alias, network_id, last_seen_at, status, cpu_cores, mem_total_gb, ip)
|
|
99
100
|
VALUES (?1, ?2, ?3, ?4, 'idle', 8, 16, '10.0.0.5')`,
|
|
100
101
|
[`s_${node_id}`, opts.alias, opts.network_id, ts],
|
|
101
102
|
);
|
|
102
103
|
}
|
|
103
104
|
if (opts.mintToken !== false && !opts.tokenDeleted) {
|
|
104
105
|
db.run(
|
|
105
|
-
`INSERT
|
|
106
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
106
107
|
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, ?6)`,
|
|
107
108
|
[`tok_${node_id}`, `u_seed_${node_id}`, opts.network_id, `node:${opts.alias}`,
|
|
108
109
|
`hash_${node_id}`,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { db } from "./db.js";
|
|
2
|
+
|
|
3
|
+
export type KeptNetworkToken = {
|
|
4
|
+
token_id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
network_id: string;
|
|
7
|
+
created_at: string;
|
|
8
|
+
last_used_at: string | null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Only user-created named network tokens. Node credentials have a different scope or binding. */
|
|
12
|
+
export function passwordNetworkTokens(userId: string, currentTokenId: string | null | undefined, revoke: boolean) {
|
|
13
|
+
const where = `user_id = ?1 AND network_id IS NOT NULL AND scope = 'full'
|
|
14
|
+
AND bound_node_id IS NULL AND revoked_at IS NULL
|
|
15
|
+
AND (expires_at IS NULL OR expires_at > datetime('now'))${currentTokenId ? " AND token_id != ?2" : ""}`;
|
|
16
|
+
const params = currentTokenId ? [userId, currentTokenId] : [userId];
|
|
17
|
+
if (revoke) {
|
|
18
|
+
return { revoked: db.run(`DELETE FROM api_tokens WHERE ${where}`, params).changes, kept: [] as KeptNetworkToken[] };
|
|
19
|
+
}
|
|
20
|
+
const kept = db.all<KeptNetworkToken>(
|
|
21
|
+
`SELECT token_id, name, network_id, created_at, last_used_at FROM api_tokens WHERE ${where} ORDER BY created_at DESC, token_id`,
|
|
22
|
+
...params,
|
|
23
|
+
);
|
|
24
|
+
return { revoked: 0, kept };
|
|
25
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hostSupervisorIdentityRows } from "./host-supervisor-identity.js";
|
|
1
2
|
import { buildServeErrorResponse } from "./serve-error.js";
|
|
2
3
|
import { sanitizeRuntimeReadiness } from "./runtime-readiness.js";
|
|
3
4
|
import { markGzipReusable, maybeGzipResponse, trimLightTask } from "./http-gzip";
|
|
@@ -1520,7 +1521,7 @@ return Bun.serve({
|
|
|
1520
1521
|
// #711 不带 keep_cli_tokens 的请求(旧 app、dashboard 代理)= 改密码前的行为:撤销全部不绑网络的令牌。
|
|
1521
1522
|
// keep_cli_tokens 必须是布尔 true 才保留命令行 / 脚本令牌。
|
|
1522
1523
|
const meta = resolved.tokenId ? getUserTokenMeta(resolved.user.user_id, resolved.tokenId) : null;
|
|
1523
|
-
const result = changePassword(resolved.user.user_id, body.old_password, body.new_password, resolved.tokenId, { keepCliTokens: body.keep_cli_tokens === true });
|
|
1524
|
+
const result = changePassword(resolved.user.user_id, body.old_password, body.new_password, resolved.tokenId, { keepCliTokens: body.keep_cli_tokens === true, revokeNetworkTokens: body.revoke_network_tokens === true });
|
|
1524
1525
|
if (result.ok) {
|
|
1525
1526
|
// 换发的新令牌沿用当前这条的种类和设备标识:anet passwd 换完仍是命令行令牌。
|
|
1526
1527
|
const issued = issueUserToken(resolved.user.user_id, "password-change", { kind: meta?.kind, clientLabel: meta?.client_label, userAgent: meta?.user_agent });
|
|
@@ -4574,31 +4575,10 @@ return Bun.serve({
|
|
|
4574
4575
|
isPrivileged = true;
|
|
4575
4576
|
}
|
|
4576
4577
|
|
|
4577
|
-
|
|
4578
|
-
// rows (revokeToken DELETEs the row in production — PR2 v1
|
|
4579
|
-
// SHOULD-FIX per 通信龙 audit). EXISTS naturally dedupes daemons
|
|
4580
|
-
// with rotated tokens.
|
|
4581
|
-
const sqlRows = db.all<Record<string, any>>(`
|
|
4582
|
-
SELECT
|
|
4583
|
-
n.node_id, n.alias, n.hostname, n.network_id,
|
|
4578
|
+
const sqlRows = hostSupervisorIdentityRows(`SELECT n.node_id, n.alias, n.hostname, n.network_id, n.owner_user_id,
|
|
4584
4579
|
n.runtimes_supported, n.allowed_secret_keys,
|
|
4585
|
-
n.created_at, n.updated_at,
|
|
4586
|
-
|
|
4587
|
-
s.cpu_cores AS session_cpu_cores,
|
|
4588
|
-
s.mem_total_gb AS session_mem_total_gb,
|
|
4589
|
-
s.ip AS session_ip,
|
|
4590
|
-
n.config_snapshot
|
|
4591
|
-
FROM nodes n
|
|
4592
|
-
LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
|
|
4593
|
-
WHERE n.network_id = ?1
|
|
4594
|
-
AND EXISTS (
|
|
4595
|
-
SELECT 1 FROM api_tokens t
|
|
4596
|
-
WHERE t.network_id = n.network_id
|
|
4597
|
-
AND t.name = 'node:' || n.alias
|
|
4598
|
-
AND t.revoked_at IS NULL
|
|
4599
|
-
)
|
|
4600
|
-
ORDER BY n.updated_at DESC
|
|
4601
|
-
`, effectiveNetId);
|
|
4580
|
+
n.created_at, n.updated_at, n.config_snapshot
|
|
4581
|
+
FROM nodes n WHERE n.network_id = ?1 ORDER BY n.updated_at DESC`, [effectiveNetId]);
|
|
4602
4582
|
|
|
4603
4583
|
const nowMs = Date.now();
|
|
4604
4584
|
// 心跳周期是 3 分钟(agent-node/src/cli.ts 的 report_status 定时器),
|
package/src/tools.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hostSupervisorIdentityRows } from "./host-supervisor-identity.js";
|
|
1
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
3
|
import { markTaskActivity } from "./task-activity.js";
|
|
3
4
|
import { requestProviderSnapshot, requestProviderWrite, requestProviderCollection, requestProviderApplication, requestProviderLogin, getProviderSnapshot, pullProviderSnapshots, ackProviderSnapshot } from "./daemon-provider-snapshots.js";
|
|
@@ -4277,35 +4278,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
4277
4278
|
// (already gate-locked by enforceNetworkId path of resolveReadScope).
|
|
4278
4279
|
const isPrivileged = callerRole === "admin" || callerRole === "owner" || (!enforceUserId);
|
|
4279
4280
|
|
|
4280
|
-
//
|
|
4281
|
-
|
|
4282
|
-
// (the standard revokeToken path deletes the row, not flagging
|
|
4283
|
-
// revoked_at — PR2 v1 SHOULD-FIX per 通信龙 audit). EXISTS naturally
|
|
4284
|
-
// dedupes if a daemon ever had multiple tokens (e.g. rotation; nit ⚪).
|
|
4285
|
-
let sql = `
|
|
4286
|
-
SELECT
|
|
4287
|
-
n.node_id, n.alias, n.hostname, n.network_id,
|
|
4281
|
+
// Scope first; batch immutable credential + heartbeat identity for both list entry points.
|
|
4282
|
+
let sql = `SELECT n.node_id, n.alias, n.hostname, n.network_id, n.owner_user_id,
|
|
4288
4283
|
n.runtimes_supported, n.allowed_secret_keys,
|
|
4289
|
-
n.created_at, n.updated_at,
|
|
4290
|
-
s.last_seen_at AS session_last_seen,
|
|
4291
|
-
s.status AS session_status,
|
|
4292
|
-
s.cpu_cores AS session_cpu_cores,
|
|
4293
|
-
s.mem_total_gb AS session_mem_total_gb,
|
|
4294
|
-
s.ip AS session_ip,
|
|
4295
|
-
n.config_snapshot
|
|
4296
|
-
FROM nodes n
|
|
4297
|
-
LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
|
|
4298
|
-
WHERE EXISTS (
|
|
4299
|
-
SELECT 1 FROM api_tokens t
|
|
4300
|
-
WHERE t.network_id = n.network_id
|
|
4301
|
-
AND t.name = 'node:' || n.alias
|
|
4302
|
-
AND t.revoked_at IS NULL
|
|
4303
|
-
)
|
|
4304
|
-
`;
|
|
4284
|
+
n.created_at, n.updated_at, n.config_snapshot FROM nodes n WHERE 1=1`;
|
|
4305
4285
|
const sqlParams: any[] = [];
|
|
4306
4286
|
sql = addReadScope(sql, sqlParams, readScope, "n.network_id");
|
|
4307
4287
|
sql += ` ORDER BY n.updated_at DESC`;
|
|
4308
|
-
const rows =
|
|
4288
|
+
const rows = hostSupervisorIdentityRows(sql, sqlParams);
|
|
4309
4289
|
|
|
4310
4290
|
// Filter to role=host_supervisor (read from config_snapshot;
|
|
4311
4291
|
// schema-promoted columns runtimes_supported/allowed_secret_keys
|