@sleep2agi/commhub-server 0.9.0-preview.86 → 0.9.0-preview.87

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.86",
3
+ "version": "0.9.0-preview.87",
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,181 @@
1
+ // #462 —— 节点模型登录失效 → 通知节点主人一次。
2
+ //
3
+ // 钉住:
4
+ // 1. 翻转判定(纯函数):进入 revoked/expired 发一次;坏→坏不再发(含 revoked⇄expired);回到 ok 才重新上膛;
5
+ // unknown / 没报既不发也不上膛。
6
+ // 2. 真 report_status(节点令牌):ok→expired→expired→ok→expired = 恰好 2 条,只给节点主人;
7
+ // 网络 owner 和别的成员 0 条。正文带别名、原因、`codex login` 修法,不教人拷别人的 auth.json。
8
+ // 3. 别的节点 / 用户令牌替它报坏状态 → 不发(与 #448 同一条「只有本节点令牌能替自己说健康」)。
9
+ // 4. Hub 重启(内存标记清空)后第一眼看到坏状态:主人还有没读的同类消息 → 不再发;读过了 → 发。
10
+ //
11
+ // 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/model-auth-notify-http.test.ts
12
+ // PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
13
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
14
+ import { mkdtempSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { tmpdir } from "node:os";
17
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
18
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
19
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
20
+ import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
21
+ import { db } from "./db.js";
22
+ import { registerTools } from "./tools.js";
23
+ import { clearNodeHealthStore } from "./node-health-store.js";
24
+ import { __resetModelAuthNoticesForTest, decideModelAuthNotice, MODEL_AUTH_NOTICE_KIND, modelAuthNoticeText } from "./model-auth-notify.js";
25
+
26
+ const dir = mkdtempSync(join(tmpdir(), "anet-model-auth-notify-"));
27
+ const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
28
+ if (!activeDbPath) throw new Error("model-auth-notify requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
29
+
30
+ const PW = "ModelAuthNotify123!";
31
+ const stamp = Date.now();
32
+ const NODE = `ma-node-${stamp}`;
33
+ const NODE_ID = `n_ma_node_${stamp}`;
34
+ const PEER = `ma-peer-${stamp}`;
35
+ const PEER_ID = `n_ma_peer_${stamp}`;
36
+ let netId = "";
37
+ let bossId = "", noraId = "", ottoId = "";
38
+ let nodeTokenId = "", peerTokenId = "";
39
+
40
+ async function connectAs(alias: string, userId: string, tokenId: string, isNetworkToken = true) {
41
+ const s = new McpServer({ name: "model-auth-notify", version: "1" });
42
+ registerTools(s, undefined, netId, userId, alias, isNetworkToken, tokenId);
43
+ const client = new Client({ name: "model-auth-notify-client", version: "1" });
44
+ const [ct, st] = InMemoryTransport.createLinkedPair();
45
+ await s.connect(st);
46
+ await client.connect(ct);
47
+ const report = async (model_auth: string | undefined, asAlias = NODE, nodeId = NODE_ID) => {
48
+ const r: any = await client.callTool({
49
+ name: "report_status",
50
+ arguments: { resume_id: `sdk-${nodeId}`, alias: asAlias, status: "idle", node_id: nodeId, network_id: netId, health: { bridge: "ok", ...(model_auth ? { model_auth } : {}) } },
51
+ });
52
+ expect(r.isError === true).toBe(false);
53
+ };
54
+ return { report, close: async () => { await client.close(); await s.close(); } };
55
+ }
56
+
57
+ const notices = (userId: string) => db.all<{ message_id: string; from_session: string; title: string; content: string; severity: string; meta_json: string; acked: number }>(
58
+ "SELECT message_id, from_session, title, content, severity, meta_json, acked FROM user_inbox WHERE user_id = ?1 AND network_id = ?2 AND kind = ?3 ORDER BY created_at, message_id",
59
+ userId, netId, MODEL_AUTH_NOTICE_KIND,
60
+ );
61
+ const clearNotices = () => db.run("DELETE FROM user_inbox WHERE network_id = ?1 AND kind = ?2", [netId, MODEL_AUTH_NOTICE_KIND]);
62
+
63
+ beforeAll(() => {
64
+ process.env.COMMHUB_UPLOADS_DIR = join(dir, "uploads");
65
+ const boss = register(`ma_boss_${stamp}`, PW);
66
+ expect(boss.ok).toBe(true);
67
+ bossId = boss.user!.user_id; netId = boss.network_id!;
68
+ const nora = register(`ma_nora_${stamp}`, PW);
69
+ const otto = register(`ma_otto_${stamp}`, PW);
70
+ noraId = nora.user!.user_id; ottoId = otto.user!.user_id;
71
+ addNetworkMember(netId, noraId, "member", bossId, { agentAccess: "all" });
72
+ addNetworkMember(netId, ottoId, "member", bossId, { agentAccess: "all" });
73
+ // 节点主人是 nora(她铸的节点令牌 → nodes.owner_user_id),不是网络 owner boss。
74
+ const minted = createNetworkTokenForNode(noraId, netId, NODE, NODE_ID);
75
+ expect(minted.ok).toBe(true);
76
+ nodeTokenId = minted.token_id!;
77
+ const peer = createNetworkTokenForNode(ottoId, netId, PEER, PEER_ID);
78
+ expect(peer.ok).toBe(true);
79
+ peerTokenId = peer.token_id!;
80
+ });
81
+ beforeEach(() => { __resetModelAuthNoticesForTest(); clearNodeHealthStore(); clearNotices(); });
82
+ afterAll(() => { clearNotices(); __resetModelAuthNoticesForTest(); clearNodeHealthStore(); });
83
+
84
+ describe("#462 transition logic", () => {
85
+ test("bad once per entry, re-armed only by ok", () => {
86
+ const seq: Array<Parameters<typeof decideModelAuthNotice>[1]> = ["ok", "expired", "expired", "revoked", "unknown", undefined, "expired", "ok", "unknown", "revoked", "revoked"];
87
+ let mark: ReturnType<typeof decideModelAuthNotice>["mark"];
88
+ const fired: boolean[] = [];
89
+ for (const s of seq) { const d = decideModelAuthNotice(mark, s); fired.push(d.notify); mark = d.mark; }
90
+ expect(fired).toEqual([false, true, false, false, false, false, false, false, false, true, false]);
91
+ });
92
+
93
+ test("first sight is bad → notify; unknown/missing never arms", () => {
94
+ expect(decideModelAuthNotice(undefined, "revoked")).toEqual({ notify: true, mark: "notified" });
95
+ expect(decideModelAuthNotice(undefined, "unknown")).toEqual({ notify: false, mark: undefined });
96
+ expect(decideModelAuthNotice("notified", "unknown")).toEqual({ notify: false, mark: "notified" });
97
+ expect(decideModelAuthNotice("notified", undefined)).toEqual({ notify: false, mark: "notified" });
98
+ expect(decideModelAuthNotice("notified", "ok")).toEqual({ notify: false, mark: "armed" });
99
+ });
100
+
101
+ test("text names the node, the cause and the per-node re-login; never says to copy credentials", () => {
102
+ for (const s of ["revoked", "expired"] as const) {
103
+ const { title, text } = modelAuthNoticeText("node-a", s);
104
+ expect(title).toBe("节点登录失效");
105
+ expect(text).toContain("节点 node-a");
106
+ expect(text).toContain("codex login");
107
+ expect(text).toContain("CODEX_HOME=");
108
+ expect(text).toContain("不要拷别的节点的 auth.json");
109
+ }
110
+ expect(modelAuthNoticeText("node-a", "revoked").text).toContain("作废");
111
+ expect(modelAuthNoticeText("node-a", "expired").text).toContain("过期");
112
+ });
113
+ });
114
+
115
+ describe("#462 report_status → owner notice", () => {
116
+ test("ok → expired → expired → ok → expired = exactly 2 notices, owner only", async () => {
117
+ const n = await connectAs(NODE, noraId, nodeTokenId);
118
+ try {
119
+ for (const s of ["ok", "expired", "expired", "ok", "expired"]) await n.report(s);
120
+ } finally { await n.close(); }
121
+ const mine = notices(noraId);
122
+ expect(mine.length).toBe(2);
123
+ for (const m of mine) {
124
+ expect(m.from_session).toBe(NODE);
125
+ expect(m.title).toBe("节点登录失效");
126
+ expect(m.severity).toBe("warning");
127
+ expect(m.content).toContain(`节点 ${NODE}`);
128
+ expect(m.content).toContain("codex login");
129
+ expect(JSON.parse(m.meta_json)).toEqual({ model_auth_notice: { alias: NODE, state: "expired" } });
130
+ }
131
+ expect(notices(bossId).length).toBe(0);
132
+ expect(notices(ottoId).length).toBe(0);
133
+ });
134
+
135
+ test("a node that only ever reports ok / unknown / nothing gets no notice", async () => {
136
+ const n = await connectAs(NODE, noraId, nodeTokenId);
137
+ try {
138
+ for (const s of ["unknown", "ok", undefined, "unknown", "ok"]) await n.report(s);
139
+ } finally { await n.close(); }
140
+ expect(notices(noraId).length).toBe(0);
141
+ });
142
+
143
+ test("another node or a user login cannot trigger a notice for this node", async () => {
144
+ const p = await connectAs(PEER, ottoId, peerTokenId);
145
+ try { await p.report("revoked", NODE, NODE_ID); } finally { await p.close(); }
146
+ const u = await connectAs("", bossId, "", false);
147
+ try { await u.report("revoked", NODE, NODE_ID); } finally { await u.close(); }
148
+ expect(notices(noraId).length).toBe(0);
149
+ expect(notices(bossId).length).toBe(0);
150
+ expect(notices(ottoId).length).toBe(0);
151
+ });
152
+
153
+ test("hub restart: an unread notice suppresses the repeat; once read, the next sight notifies", async () => {
154
+ const n = await connectAs(NODE, noraId, nodeTokenId);
155
+ try {
156
+ await n.report("revoked");
157
+ expect(notices(noraId).length).toBe(1);
158
+ __resetModelAuthNoticesForTest(); // = Hub 重启,内存标记没了
159
+ await n.report("revoked");
160
+ expect(notices(noraId).length).toBe(1);
161
+ db.run("UPDATE user_inbox SET acked = 1 WHERE user_id = ?1 AND network_id = ?2 AND kind = ?3", [noraId, netId, MODEL_AUTH_NOTICE_KIND]);
162
+ __resetModelAuthNoticesForTest();
163
+ await n.report("revoked");
164
+ expect(notices(noraId).length).toBe(2);
165
+ await n.report("revoked");
166
+ expect(notices(noraId).length).toBe(2);
167
+ } finally { await n.close(); }
168
+ });
169
+
170
+ test("owner no longer in the network → nobody is notified", async () => {
171
+ db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [netId, noraId]);
172
+ try {
173
+ const n = await connectAs(NODE, noraId, nodeTokenId);
174
+ try { await n.report("expired"); } finally { await n.close(); }
175
+ expect(notices(noraId).length).toBe(0);
176
+ expect(notices(bossId).length).toBe(0);
177
+ } finally {
178
+ addNetworkMember(netId, noraId, "member", bossId, { agentAccess: "all" });
179
+ }
180
+ });
181
+ });
@@ -0,0 +1,132 @@
1
+ // #462(#448 的子项)—— 节点模型登录失效时通知节点主人。
2
+ //
3
+ // 现场:codex 节点的 refresh token 被作废 / 过期后,节点自己知道(report_status.health.model_auth),
4
+ // Hub 也会拒绝给它派活(#460 node-health-guard.ts),但**没有人被告诉** —— 主人要等到某次派活被 409
5
+ // 才发现。这里在 report_status 收下健康报告的那一刻看 model_auth 的翻转:进入 revoked / expired 时
6
+ // 给节点主人发**一条**消息,说是哪个节点、出了什么事、怎么重新登录。
7
+ //
8
+ // 🔴 只检测 + 通知。不自动换号、不拷别的节点的 auth.json、不重新 stage 凭据(owner 定的边界)。
9
+ //
10
+ // 去重:每次「进入坏状态」只发一条;回到 ok 才重新上膛。unknown / 没报 model_auth 既不发也不上膛
11
+ // (一次模型调用都还没跑的节点报 unknown,不能把它当「恢复了」,否则 expired→unknown→expired 会刷屏)。
12
+ // revoked ⇄ expired 互相切换不算新的翻转 —— 修法是同一条命令。
13
+ // 状态在内存里(同 node-health-store.ts);Hub 重启后第一次看到坏状态时,先查收件人有没有**还没读**的同类
14
+ // 消息,有就不再发 —— 否则每次升级 Hub 都会给还没修好的节点主人再发一遍。
15
+ //
16
+ // 收件人 = 节点主人:上报令牌绑定的 nodes.owner_user_id(RFC-036,建节点时定死),没有绑定(老令牌)就取
17
+ // 铸这个节点令牌的用户(api_tokens.user_id —— 新令牌里两者相同)。只发给这一个人;不在本网络了就不发。
18
+ // 怎么送:user_inbox(与 send_desktop_message 同一张表、同一条 /events/users/me 推送),from_session = 节点别名,
19
+ // 所以 App 把它显示在**与该节点的会话**里(主动消息)+ 未读角标 + 通知。不新增渠道。
20
+
21
+ import { db, uuidv4 } from "./db.js";
22
+ import { getUserNetworkRole } from "./auth.js";
23
+ import { pushUserEvent } from "./push.js";
24
+ import { degradedLayers } from "./node-health-guard.js";
25
+ import type { NodeHealth } from "./node-health-store.js";
26
+
27
+ export const MODEL_AUTH_NOTICE_KIND = "node_model_auth";
28
+ const MAX_TRACKED = 5_000;
29
+
30
+ type ModelAuth = NodeHealth["model_auth"];
31
+ /** armed = 下一次进入坏状态要发;notified = 这一段坏状态已经发过。 */
32
+ export type NoticeMark = "armed" | "notified";
33
+
34
+ export const isBadModelAuth = (s: ModelAuth): s is "revoked" | "expired" => s === "revoked" || s === "expired";
35
+
36
+ /**
37
+ * 纯函数:上一份标记 + 这一份 model_auth → 发不发、新标记。
38
+ * prev = undefined 表示这个 Hub 进程还没见过这个节点(调用方另查库里有没有未读的旧通知)。
39
+ */
40
+ export function decideModelAuthNotice(prev: NoticeMark | undefined, state: ModelAuth): { notify: boolean; mark: NoticeMark | undefined } {
41
+ if (state === "ok") return { notify: false, mark: "armed" };
42
+ if (isBadModelAuth(state)) {
43
+ if (prev === "notified") return { notify: false, mark: "notified" };
44
+ return { notify: true, mark: "notified" };
45
+ }
46
+ return { notify: false, mark: prev }; // unknown / 没报:保持原样
47
+ }
48
+
49
+ export function modelAuthNoticeText(alias: string, state: "revoked" | "expired"): { title: string; text: string } {
50
+ const what = state === "revoked"
51
+ ? "模型登录凭据已被作废(refresh token revoked),需要重新登录"
52
+ : "模型登录已过期,且没能自动刷新";
53
+ // 修法与派发被拒时(#460)给的同一句,别出现两套说法。
54
+ const hint = degradedLayers({ model_auth: state })[0]?.hint ?? "";
55
+ return {
56
+ title: "节点登录失效",
57
+ text: `节点 ${alias} 的${what}。在它恢复之前,派给它的任务会被拒绝。\n\n怎么修:${hint}。\n登录好之后节点会自动报告恢复,不用再做别的。`,
58
+ };
59
+ }
60
+
61
+ const marks = new Map<string, NoticeMark>();
62
+ const keyOf = (networkId: string, alias: string) => `${networkId}\0${alias}`;
63
+
64
+ export function __resetModelAuthNoticesForTest(): void { marks.clear(); }
65
+
66
+ /** 上报令牌对应的节点主人。 */
67
+ function ownerOfNodeToken(tokenId: string, networkId: string): string | null {
68
+ const row = db.get<{ user_id: string | null; owner_user_id: string | null }>(
69
+ `SELECT t.user_id, n.owner_user_id FROM api_tokens t LEFT JOIN nodes n ON n.node_id = t.bound_node_id
70
+ WHERE t.token_id = ?1 AND t.network_id = ?2`,
71
+ tokenId, networkId,
72
+ );
73
+ return row?.owner_user_id || row?.user_id || null;
74
+ }
75
+
76
+ function hasUnreadNotice(userId: string, networkId: string, alias: string): boolean {
77
+ return !!db.get(
78
+ "SELECT 1 AS hit FROM user_inbox WHERE user_id = ?1 AND network_id = ?2 AND from_session = ?3 AND kind = ?4 AND acked = 0 LIMIT 1",
79
+ userId, networkId, alias, MODEL_AUTH_NOTICE_KIND,
80
+ );
81
+ }
82
+
83
+ /**
84
+ * report_status 收下一份(已验过是本节点令牌报的)健康报告之后调用。返回这次通知到的 user_id(测试用;没发 = [])。
85
+ * 任何异常都吞掉:这是附带的通知,不能让一份正常的 report_status 失败。
86
+ */
87
+ export function noteModelAuthHealth(input: { networkId: string; alias: string; tokenId: string; health: NodeHealth }): string[] {
88
+ try {
89
+ const { networkId, alias, tokenId, health } = input;
90
+ const state = health.model_auth;
91
+ const key = keyOf(networkId, alias);
92
+ const prev = marks.get(key);
93
+ const { notify, mark } = decideModelAuthNotice(prev, state);
94
+ if (mark) {
95
+ marks.delete(key);
96
+ marks.set(key, mark);
97
+ if (marks.size > MAX_TRACKED) marks.delete(marks.keys().next().value!);
98
+ }
99
+ if (!notify || !isBadModelAuth(state)) return [];
100
+
101
+ const owner = ownerOfNodeToken(tokenId, networkId);
102
+ if (!owner || !getUserNetworkRole(owner, networkId)) return [];
103
+ // Hub 重启后的第一眼:主人还有一条没读的同类消息 → 这是同一段坏状态,不再发。
104
+ if (prev === undefined && hasUnreadNotice(owner, networkId, alias)) return [];
105
+
106
+ const { title, text } = modelAuthNoticeText(alias, state);
107
+ const messageId = `dm_auth_${uuidv4()}`;
108
+ const meta = { model_auth_notice: { alias, state } };
109
+ db.run(
110
+ `INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json)
111
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'warning', ?8)
112
+ ON CONFLICT(message_id) DO NOTHING`,
113
+ [messageId, networkId, owner, alias, MODEL_AUTH_NOTICE_KIND, title, text, JSON.stringify(meta)],
114
+ );
115
+ // 推送在写库之后:收到推送的 App 立刻回读得到这一行。
116
+ pushUserEvent(networkId, owner, {
117
+ type: "desktop_message",
118
+ message_id: messageId,
119
+ kind: MODEL_AUTH_NOTICE_KIND,
120
+ from: alias,
121
+ title,
122
+ message: text,
123
+ severity: "warning",
124
+ created_at: new Date().toISOString(),
125
+ meta,
126
+ });
127
+ return [owner];
128
+ } catch (e: any) {
129
+ console.error(`[model-auth-notify] ${input.alias}: ${e?.message ?? e}`);
130
+ return [];
131
+ }
132
+ }
package/src/tools.ts CHANGED
@@ -3,6 +3,7 @@ import { parseDbTimestampMs } from "./db-timestamp.js";
3
3
  import { z } from "zod/v4";
4
4
  import { nodeHealthSchema, recordNodeHealth } from "./node-health-store.js";
5
5
  import { assertNodeHealthy } from "./node-health-guard.js";
6
+ import { noteModelAuthHealth } from "./model-auth-notify.js";
6
7
  import { parseAliasFilter } from "./alias-filter.js";
7
8
  import { createHash } from "node:crypto";
8
9
  import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
@@ -1062,7 +1063,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1062
1063
  const acceptedHealth = nodeHealth && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias
1063
1064
  ? nodeHealth
1064
1065
  : undefined;
1065
- if (acceptedHealth) recordNodeHealth(sessionNetId, effectiveAlias, acceptedHealth);
1066
+ if (acceptedHealth) {
1067
+ recordNodeHealth(sessionNetId, effectiveAlias, acceptedHealth);
1068
+ // #462 — model login went bad → tell the node's owner once (detect + notify only).
1069
+ if (callerTokenId) noteModelAuthHealth({ networkId: sessionNetId, alias: effectiveAlias, tokenId: callerTokenId, health: acceptedHealth });
1070
+ }
1066
1071
  pushEvent(effectiveAlias, {
1067
1072
  type: "status_update",
1068
1073
  alias: effectiveAlias,