@sleep2agi/commhub-server 0.9.0-preview.54 → 0.9.0-preview.56

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.54",
3
+ "version": "0.9.0-preview.56",
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",
@@ -10,8 +10,8 @@ test("no filter given means no filtering, not an empty match", () => {
10
10
  });
11
11
 
12
12
  test("a single alias produces one placeholder", () => {
13
- const f = parseAliasFilter("TM门户马");
14
- expect(f.aliases).toEqual(["TM门户马"]);
13
+ const f = parseAliasFilter("节点甲");
14
+ expect(f.aliases).toEqual(["节点甲"]);
15
15
  expect(f.sql).toBe(" AND alias IN (?)");
16
16
  });
17
17
 
package/src/db.ts CHANGED
@@ -123,6 +123,10 @@ for (const col of [
123
123
  { name: "process_in_flight_count", def: "INTEGER" },
124
124
  { name: "external_schedules", def: "TEXT" },
125
125
  { name: "peer_reply_inbox_capable", def: "INTEGER NOT NULL DEFAULT 0" },
126
+ // app#225 follow-up — a session (incl. claude-code sessions with no `nodes` row)
127
+ // that answers the rules-file doorbell. Sticky: set by a bound node token, never
128
+ // cleared by reports that omit it (agent-node status-only reports do).
129
+ { name: "rules_file_capable", def: "INTEGER NOT NULL DEFAULT 0" },
126
130
  ]) {
127
131
  try { db.exec(`ALTER TABLE sessions ADD COLUMN ${col.name} ${col.def}`); } catch {}
128
132
  }
@@ -0,0 +1,142 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { register } from "./auth.js";
6
+ import { db } from "./db.js";
7
+
8
+ // #1909 —— POST /api/messages/ack 的 `{ agent }` 形态:把调用者收到的、来自该 agent 的
9
+ // 两张表(user_inbox + inbox reply/task/message)全部标已读。角标分母 unread_by_agent
10
+ // 数的是全集,按 id 只能 ack 拉到的那一页 ⇒ 老行永远清不掉,这一形态补上。
11
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-ack-by-agent-http-"));
12
+ let server: any;
13
+ let base = "";
14
+ let tokenA = "";
15
+ let tokenB = "";
16
+ let userA = "";
17
+ let userB = "";
18
+ let userIdA = "";
19
+ let userIdB = "";
20
+ let netA = "";
21
+ let netB = "";
22
+
23
+ const AGENT = "ack-agent-x";
24
+ const OTHER = "ack-agent-y";
25
+
26
+ function seed() {
27
+ const ui = (id: string, net: string, uid: string, from: string) =>
28
+ db.run(
29
+ `INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, content) VALUES (?1, ?2, ?3, ?4, 'agent_message', 'x')`,
30
+ [id, net, uid, from],
31
+ );
32
+ const ib = (id: string, net: string, to: string, from: string, type: string) =>
33
+ db.run(
34
+ `INSERT INTO inbox (id, session_name, type, content, from_session, network_id) VALUES (?1, ?2, ?3, 'x', ?4, ?5)`,
35
+ [id, to, type, from, net],
36
+ );
37
+ ui("ui_a_x_1", netA, userIdA, AGENT);
38
+ ui("ui_a_x_2", netA, userIdA, AGENT);
39
+ ui("ui_a_y_1", netA, userIdA, OTHER);
40
+ ui("ui_b_x_1", netB, userIdB, AGENT);
41
+ ib("ib_a_x_reply", netA, userA, AGENT, "reply");
42
+ ib("ib_a_x_task", netA, userA, AGENT, "task");
43
+ ib("ib_a_x_status", netA, userA, AGENT, "status"); // 不在 reply/task/message 内,不该被 ack
44
+ ib("ib_a_y_reply", netA, userA, OTHER, "reply");
45
+ ib("ib_b_x_reply", netB, userB, AGENT, "reply");
46
+ }
47
+
48
+ beforeAll(async () => {
49
+ process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
50
+ const stamp = `${Date.now()}_${process.pid}`;
51
+ userA = `ack_agent_a_${stamp}`;
52
+ userB = `ack_agent_b_${stamp}`;
53
+ const a = register(userA, "AckAgent123!", undefined, "seed");
54
+ const b = register(userB, "AckAgent123!", undefined, "seed");
55
+ expect(a.ok).toBe(true);
56
+ expect(b.ok).toBe(true);
57
+ tokenA = a.token!; netA = a.network_id!;
58
+ tokenB = b.token!; netB = b.network_id!;
59
+ userIdA = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [userA])!.user_id;
60
+ userIdB = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [userB])!.user_id;
61
+ seed();
62
+ const mod: any = await import("./server.js");
63
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
64
+ base = `http://127.0.0.1:${server.port}`;
65
+ }, 30_000);
66
+
67
+ afterAll(() => {
68
+ try { server?.stop?.(true); } catch {}
69
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
70
+ });
71
+
72
+ async function ack(token: string, body: unknown): Promise<{ status: number; body: any }> {
73
+ const response = await fetch(`${base}/api/messages/ack`, {
74
+ method: "POST",
75
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
76
+ body: JSON.stringify(body),
77
+ });
78
+ return { status: response.status, body: await response.json() };
79
+ }
80
+
81
+ const uiAcked = (id: string) => db.get<{ acked: number }>("SELECT acked FROM user_inbox WHERE message_id = ?1", [id])!.acked;
82
+ const ibAcked = (id: string) => db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", [id])!.acked;
83
+
84
+ async function unreadByAgent(token: string): Promise<Record<string, number>> {
85
+ const response = await fetch(`${base}/api/messages?scope=user&limit=5`, { headers: { Authorization: `Bearer ${token}` } });
86
+ expect(response.status).toBe(200);
87
+ return ((await response.json()) as any).unread_by_agent ?? {};
88
+ }
89
+
90
+ describe("POST /api/messages/ack { agent } (#1909)", () => {
91
+ test("missing everything → message_id_required; agent + ids → ambiguous_ack", async () => {
92
+ const none = await ack(tokenA, {});
93
+ expect(none.status).toBe(400);
94
+ expect(none.body.error).toBe("message_id_required");
95
+ const both = await ack(tokenA, { agent: AGENT, message_ids: ["ui_a_x_1"] });
96
+ expect(both.status).toBe(400);
97
+ expect(both.body.error).toBe("ambiguous_ack");
98
+ // 拒绝的那次不能有副作用
99
+ expect(uiAcked("ui_a_x_1")).toBe(0);
100
+ });
101
+
102
+ test("unread_by_agent counts the seeded rows before ack", async () => {
103
+ const by = await unreadByAgent(tokenA);
104
+ expect(by[AGENT]).toBe(4); // 2 user_inbox + reply + task (status 不计)
105
+ expect(by[OTHER]).toBe(2);
106
+ });
107
+
108
+ test("agent form acks both tables, only that agent, only the caller", async () => {
109
+ const r = await ack(tokenA, { agent: ` ${AGENT} ` });
110
+ expect(r.status).toBe(200);
111
+ expect(r.body).toMatchObject({ ok: true, scope: "agent", agent: AGENT, acked: 4, acked_user_inbox: 2, acked_inbox: 2 });
112
+ expect(uiAcked("ui_a_x_1")).toBe(1);
113
+ expect(uiAcked("ui_a_x_2")).toBe(1);
114
+ expect(ibAcked("ib_a_x_reply")).toBe(1);
115
+ expect(ibAcked("ib_a_x_task")).toBe(1);
116
+ // 不该动的:别的 agent、非 reply/task/message 类型、别的用户
117
+ expect(uiAcked("ui_a_y_1")).toBe(0);
118
+ expect(ibAcked("ib_a_x_status")).toBe(0);
119
+ expect(ibAcked("ib_a_y_reply")).toBe(0);
120
+ expect(uiAcked("ui_b_x_1")).toBe(0);
121
+ expect(ibAcked("ib_b_x_reply")).toBe(0);
122
+ const by = await unreadByAgent(tokenA);
123
+ expect(by[AGENT] ?? 0).toBe(0);
124
+ expect(by[OTHER]).toBe(2);
125
+ expect((await unreadByAgent(tokenB))[AGENT]).toBe(2);
126
+ });
127
+
128
+ test("agent form is idempotent: second call changes 0 rows", async () => {
129
+ const r = await ack(tokenA, { agent: AGENT });
130
+ expect(r.status).toBe(200);
131
+ expect(r.body.acked).toBe(0);
132
+ });
133
+
134
+ test("id form still works and now reports scope ids", async () => {
135
+ const r = await ack(tokenA, { message_ids: ["ui_a_y_1", "ib_a_y_reply", "ui_b_x_1"] });
136
+ expect(r.status).toBe(200);
137
+ expect(r.body).toMatchObject({ ok: true, scope: "ids", acked: 2, acked_user_inbox: 1, acked_inbox: 1 });
138
+ expect(uiAcked("ui_a_y_1")).toBe(1);
139
+ expect(ibAcked("ib_a_y_reply")).toBe(1);
140
+ expect(uiAcked("ui_b_x_1")).toBe(0); // 别人的 id 匹配不到
141
+ });
142
+ });
@@ -45,7 +45,7 @@ const GOLDEN_RESPONSE_KEYS = {
45
45
  "process_rss_mb", "process_cpu_pct", "process_uptime_seconds",
46
46
  "process_in_flight_count", "network_id", "registered_at", "updated_at",
47
47
  "node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
48
- "external_schedules", "runtime", "host", "process_telemetry",
48
+ "external_schedules", "rules_file_capable", "runtime", "host", "process_telemetry",
49
49
  ],
50
50
  task: [
51
51
  "task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
@@ -19,7 +19,7 @@ export const SESSION_REST_COLUMNS = [
19
19
  "process_rss_mb", "process_cpu_pct", "process_uptime_seconds",
20
20
  "process_in_flight_count", "network_id", "registered_at", "updated_at",
21
21
  "node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
22
- "external_schedules",
22
+ "external_schedules", "rules_file_capable",
23
23
  ] as const;
24
24
 
25
25
  export const AUDIT_LOG_REST_COLUMNS = [
@@ -0,0 +1,207 @@
1
+ // app#225 follow-up — rules-file requests to a SESSION that has no `nodes` row
2
+ // (claude-code sessions launched by `anet node start` + the channel server).
3
+ //
4
+ // Hub-side contract verified here:
5
+ // 1. report_status { rules_file_capable: true } marks the session capable only
6
+ // when the caller is a node token bound to that same alias (a user token or
7
+ // another node's token cannot mark it); omitting the field later does not clear it.
8
+ // 2. read/write_node_rules_file { alias } reach such a session: the node pulls
9
+ // with its own token (queue key `session:<alias>`), acks, the client polls done.
10
+ // 3. An alias that has neither a node row nor a capable session is refused up
11
+ // front (no request row, no 60 s timeout for the user to sit through).
12
+ // 4. Network isolation: a same-named alias in another network neither sees nor
13
+ // acks the request; a caller in the other network cannot enqueue to it.
14
+ // 5. The original node_id path is unchanged (node row wins when both exist).
15
+ //
16
+ // 跑法:cd server && COMMHUB_DB=/tmp/rules-session.db bun test src/rules-file-session-target.test.ts
17
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
18
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
19
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
20
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
21
+ import { db } from "./db.js";
22
+ import { registerTools } from "./tools.js";
23
+
24
+ const NET = "net_rules_session";
25
+ const OTHER_NET = "net_rules_session_other";
26
+ const USER = "u_rules_session_owner";
27
+ const SESSION_ALIAS = "rules-session-cc";
28
+ const SESSION_RESUME = "cc-rules-session-1";
29
+ const OTHER_ALIAS = "rules-session-peer";
30
+ const NODE_ROW_ALIAS = "rules-session-noderow";
31
+ const NODE_ROW_ID = "node_rules_session_noderow";
32
+ const T_SESSION = "tok_rules_session_cc";
33
+ const T_PEER = "tok_rules_session_peer";
34
+ const T_OTHER_NET_SAME_ALIAS = "tok_rules_session_other_net";
35
+ const T_NODE_ROW = "tok_rules_session_noderow";
36
+
37
+ function cleanup() {
38
+ for (const net of [NET, OTHER_NET]) {
39
+ try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [net]); } catch {}
40
+ try { db.run("DELETE FROM sessions WHERE network_id = ?1", [net]); } catch {}
41
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [net]); } catch {}
42
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [net]); } catch {}
43
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [net]); } catch {}
44
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [net]); } catch {}
45
+ }
46
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
47
+ }
48
+
49
+ function seedWorld() {
50
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER, USER]);
51
+ for (const net of [NET, OTHER_NET]) {
52
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [net, net, USER]);
53
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER, net]);
54
+ }
55
+ const tok = (id: string, net: string, alias: string) => db.run(
56
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
57
+ [id, USER, net, `node:${alias}`, `hash_${id}`],
58
+ );
59
+ tok(T_SESSION, NET, SESSION_ALIAS);
60
+ tok(T_PEER, NET, OTHER_ALIAS);
61
+ tok(T_OTHER_NET_SAME_ALIAS, OTHER_NET, SESSION_ALIAS);
62
+ tok(T_NODE_ROW, NET, NODE_ROW_ALIAS);
63
+ db.run(
64
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
65
+ [NODE_ROW_ID, NODE_ROW_ALIAS, NODE_ROW_ALIAS, NET],
66
+ );
67
+ }
68
+
69
+ type Identity = { net: string; alias: string; isNetworkToken: boolean; tokenId: string };
70
+ const asUser: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_rules_session_user" };
71
+ const asOtherNetUser: Identity = { net: OTHER_NET, alias: USER, isNetworkToken: false, tokenId: "tok_rules_session_user2" };
72
+ const asSession: Identity = { net: NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_SESSION };
73
+ const asPeer: Identity = { net: NET, alias: OTHER_ALIAS, isNetworkToken: true, tokenId: T_PEER };
74
+ const asOtherNetSameAlias: Identity = { net: OTHER_NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_OTHER_NET_SAME_ALIAS };
75
+ const asNodeRow: Identity = { net: NET, alias: NODE_ROW_ALIAS, isNetworkToken: true, tokenId: T_NODE_ROW };
76
+
77
+ async function connect(id: Identity) {
78
+ const server = new McpServer({ name: "rules-session-test", version: "1" });
79
+ registerTools(server, undefined, id.net, USER, id.alias, id.isNetworkToken, id.tokenId);
80
+ const client = new Client({ name: "rules-session-client", version: "1" });
81
+ const [ct, st] = InMemoryTransport.createLinkedPair();
82
+ await server.connect(st);
83
+ await client.connect(ct);
84
+ const call = async (name: string, args: Record<string, unknown>) => {
85
+ const r: any = await client.callTool({ name, arguments: args });
86
+ const first = r.content?.[0];
87
+ if (!first || first.type !== "text") throw new Error(`no text result from ${name}: ${JSON.stringify(r).slice(0, 300)}`);
88
+ return JSON.parse(first.text);
89
+ };
90
+ const close = async () => { await client.close(); await server.close(); };
91
+ return { call, close };
92
+ }
93
+
94
+ const capable = (alias: string, net = NET) =>
95
+ db.get<{ c: number }>("SELECT rules_file_capable AS c FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, net)?.c;
96
+
97
+ async function registerSession(id: Identity, alias: string, resume: string, flag: boolean) {
98
+ const c = await connect(id);
99
+ try {
100
+ return await c.call("report_status", { resume_id: resume, alias, status: "idle", agent: "claude-code", ...(flag ? { rules_file_capable: true } : {}) });
101
+ } finally { await c.close(); }
102
+ }
103
+
104
+ beforeEach(() => { cleanup(); seedWorld(); });
105
+ afterAll(cleanup);
106
+
107
+ describe("rules_file_capable is set only by the session's own node token", () => {
108
+ test("bound node token sets it; a later report without the field does not clear it", async () => {
109
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
110
+ expect(capable(SESSION_ALIAS)).toBe(1);
111
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, false);
112
+ expect(capable(SESSION_ALIAS)).toBe(1);
113
+ }, 20_000);
114
+
115
+ // The hub already refuses these reports outright (network_token_required /
116
+ // alias_identity_mismatch); the bound-token check on the UPDATE is defence in
117
+ // depth. The real session row is registered first so "stays 0" is not vacuous.
118
+ test("a user token reporting for the alias is refused and cannot mark it capable", async () => {
119
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, false);
120
+ expect(capable(SESSION_ALIAS)).toBe(0);
121
+ const r = await registerSession(asUser, SESSION_ALIAS, SESSION_RESUME, true);
122
+ expect(r).toMatchObject({ ok: false, error: "network_token_required" });
123
+ expect(capable(SESSION_ALIAS)).toBe(0);
124
+ }, 20_000);
125
+
126
+ test("another node's token is refused and cannot mark someone else's session capable", async () => {
127
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, false);
128
+ const r = await registerSession(asPeer, SESSION_ALIAS, SESSION_RESUME, true);
129
+ expect(r).toMatchObject({ ok: false, error: "alias_identity_mismatch" });
130
+ expect(capable(SESSION_ALIAS)).toBe(0);
131
+ }, 20_000);
132
+ });
133
+
134
+ describe("read/write by alias reach a session with no nodes row", () => {
135
+ test("read round trip: enqueue by alias → session pulls with its own token → ack → done", async () => {
136
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
137
+ const u = await connect(asUser);
138
+ const s = await connect(asSession);
139
+ try {
140
+ const enq = await u.call("read_node_rules_file", { alias: SESSION_ALIAS });
141
+ expect(enq).toMatchObject({ ok: true, op: "read" });
142
+ expect(db.get<{ node_id: string }>("SELECT node_id FROM node_rules_requests WHERE request_id = ?1", enq.request_id)?.node_id).toBe(`session:${SESSION_ALIAS}`);
143
+ const pulled = await s.call("get_rules_file_request", {});
144
+ expect(pulled.request).toEqual({ request_id: enq.request_id, op: "read" });
145
+ expect(await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "CLAUDE.md", exists: true, content: "# rules\n" })).toMatchObject({ ok: true, status: "done" });
146
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: true, status: "done", file_name: "CLAUDE.md", exists: true, content: "# rules\n" });
147
+ } finally { await u.close(); await s.close(); }
148
+ }, 20_000);
149
+
150
+ test("write carries content to the session", async () => {
151
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
152
+ const u = await connect(asUser);
153
+ const s = await connect(asSession);
154
+ try {
155
+ const enq = await u.call("write_node_rules_file", { alias: SESSION_ALIAS, content: "new body\n" });
156
+ expect(enq).toMatchObject({ ok: true, op: "write" });
157
+ const pulled = await s.call("get_rules_file_request", {});
158
+ expect(pulled.request).toEqual({ request_id: enq.request_id, op: "write", content: "new body\n" });
159
+ } finally { await u.close(); await s.close(); }
160
+ }, 20_000);
161
+
162
+ test("alias with neither a node row nor a capable session is refused before any row is written", async () => {
163
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, false);
164
+ const u = await connect(asUser);
165
+ try {
166
+ expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "rules_file_target_not_found" });
167
+ expect(db.get<{ c: number }>("SELECT COUNT(*) AS c FROM node_rules_requests WHERE network_id = ?1", NET)?.c).toBe(0);
168
+ } finally { await u.close(); }
169
+ }, 20_000);
170
+
171
+ test("network isolation: same alias in another network neither pulls nor acks; other-network user cannot enqueue", async () => {
172
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
173
+ const u = await connect(asUser);
174
+ const x = await connect(asOtherNetSameAlias);
175
+ const ou = await connect(asOtherNetUser);
176
+ try {
177
+ const enq = await u.call("read_node_rules_file", { alias: SESSION_ALIAS });
178
+ expect(enq.ok).toBe(true);
179
+ expect(await x.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
180
+ expect(await x.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "stolen" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
181
+ expect(db.get<{ s: string }>("SELECT status AS s FROM node_rules_requests WHERE request_id = ?1", enq.request_id)?.s).toBe("pending");
182
+ expect(await ou.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "rules_file_target_not_found" });
183
+ } finally { await u.close(); await x.close(); await ou.close(); }
184
+ }, 20_000);
185
+
186
+ test("a peer node in the same network cannot pull or ack the session's request", async () => {
187
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
188
+ const u = await connect(asUser);
189
+ const p = await connect(asPeer);
190
+ try {
191
+ const enq = await u.call("read_node_rules_file", { alias: SESSION_ALIAS });
192
+ expect(await p.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
193
+ expect(await p.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "x" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
194
+ } finally { await u.close(); await p.close(); }
195
+ }, 20_000);
196
+
197
+ test("alias that has a nodes row resolves to that node_id (original queue key)", async () => {
198
+ const u = await connect(asUser);
199
+ const n = await connect(asNodeRow);
200
+ try {
201
+ const enq = await u.call("read_node_rules_file", { alias: NODE_ROW_ALIAS });
202
+ expect(enq.ok).toBe(true);
203
+ expect(db.get<{ node_id: string }>("SELECT node_id FROM node_rules_requests WHERE request_id = ?1", enq.request_id)?.node_id).toBe(NODE_ROW_ID);
204
+ expect((await n.call("get_rules_file_request", {})).request?.request_id).toBe(enq.request_id);
205
+ } finally { await u.close(); await n.close(); }
206
+ }, 20_000);
207
+ });
@@ -105,8 +105,10 @@ describe("app#225 rules file — schema has no path", () => {
105
105
  const shape = schema?.shape ?? schema?._def?.shape?.() ?? schema ?? {};
106
106
  return Object.keys(shape).sort();
107
107
  };
108
- expect(shapeKeys("read_node_rules_file")).toEqual(["child_node_id", "network_id", "node_id"]);
109
- expect(shapeKeys("write_node_rules_file")).toEqual(["child_node_id", "content", "network_id", "node_id"]);
108
+ // `alias` (app#225 follow-up) names a TARGET node/session, not a file —
109
+ // the no-path regex below still covers it.
110
+ expect(shapeKeys("read_node_rules_file")).toEqual(["alias", "child_node_id", "network_id", "node_id"]);
111
+ expect(shapeKeys("write_node_rules_file")).toEqual(["alias", "child_node_id", "content", "network_id", "node_id"]);
110
112
  expect(shapeKeys("get_rules_file_request")).toEqual([]);
111
113
  // ack 的 file_name 是节点**回报**它用了哪个文件,不是客户端**指定**用哪个文件。
112
114
  expect(shapeKeys("ack_rules_file_request")).toEqual(["content", "error", "exists", "file_name", "request_id", "status"]);
package/src/server.ts CHANGED
@@ -1680,6 +1680,8 @@ return Bun.serve({
1680
1680
  return {
1681
1681
  ...publicSession,
1682
1682
  external_schedules: externalSchedules,
1683
+ // app#225 follow-up — sqlite INTEGER 0/1 → boolean on the wire.
1684
+ rules_file_capable: s.rules_file_capable === 1,
1683
1685
  model: s.model ?? null,
1684
1686
  runtime: normalizeRuntime(s.agent),
1685
1687
  host: {
@@ -2755,6 +2757,34 @@ return Bun.serve({
2755
2757
  const ids: string[] = Array.isArray(body?.message_ids)
2756
2758
  ? body.message_ids.filter((x: unknown) => typeof x === "string" && x.length > 0)
2757
2759
  : (typeof body?.message_id === "string" && body.message_id ? [body.message_id] : []);
2760
+ // #1909 —— 按 agent 整体 ack:`{ agent: "<alias>" }`。角标的分母是 `unread_by_agent`,
2761
+ // 它数的是**全部**未 ack 行,而客户端一页只看得到最近 200/300 条 ⇒ 老行永远 ack 不到,
2762
+ // 红点永远清不掉(生产 admin:user_inbox 522 + inbox 5660 行)。这一形态把调用者
2763
+ // 收到的、来自该 agent 的两张表全部标已读;同样只动自己的行。
2764
+ const agentRaw = typeof body?.agent === "string" ? body.agent.trim() : "";
2765
+ if (agentRaw && ids.length > 0) {
2766
+ return withCors(req, Response.json({ ok: false, error: "ambiguous_ack" }, { status: 400 }));
2767
+ }
2768
+ if (agentRaw) {
2769
+ const uiParams: any[] = [callerUserId, agentRaw];
2770
+ let uiSql = `UPDATE user_inbox SET acked = 1, acked_at = datetime('now')
2771
+ WHERE user_id = ?1 AND from_session = ?2 AND acked = 0`;
2772
+ uiSql = addNetworkScope(uiSql, uiParams, restScope);
2773
+ const uiAcked = db.run(uiSql, uiParams).changes ?? 0;
2774
+ let agentInboxAcked = 0;
2775
+ const agentCallerUsername = restAuth?.username ?? "";
2776
+ if (agentCallerUsername && !userInboxAliasCollides(agentCallerUsername, restScope)) {
2777
+ const ibAgentParams: any[] = [agentCallerUsername, agentRaw];
2778
+ let ibAgentSql = `UPDATE inbox SET acked = 1
2779
+ WHERE session_name = ?1 AND from_session = ?2 AND acked = 0 AND type IN ('reply', 'task', 'message')`;
2780
+ ibAgentSql = addNetworkScope(ibAgentSql, ibAgentParams, restScope);
2781
+ agentInboxAcked = db.run(ibAgentSql, ibAgentParams).changes ?? 0;
2782
+ }
2783
+ return withCors(req, Response.json({
2784
+ ok: true, scope: "agent", agent: agentRaw,
2785
+ acked: uiAcked + agentInboxAcked, acked_user_inbox: uiAcked, acked_inbox: agentInboxAcked,
2786
+ }));
2787
+ }
2758
2788
  if (ids.length === 0) {
2759
2789
  return withCors(req, Response.json({ ok: false, error: "message_id_required" }, { status: 400 }));
2760
2790
  }
@@ -2781,7 +2811,7 @@ return Bun.serve({
2781
2811
  ibSql = addNetworkScope(ibSql, ibParams, restScope);
2782
2812
  inboxAcked = db.run(ibSql, ibParams).changes ?? 0;
2783
2813
  }
2784
- return withCors(req, Response.json({ ok: true, acked: (res.changes ?? 0) + inboxAcked, acked_user_inbox: res.changes ?? 0, acked_inbox: inboxAcked }));
2814
+ return withCors(req, Response.json({ ok: true, scope: "ids", acked: (res.changes ?? 0) + inboxAcked, acked_user_inbox: res.changes ?? 0, acked_inbox: inboxAcked }));
2785
2815
  }
2786
2816
 
2787
2817
  // ── REST: stats summary ──
package/src/tools.ts CHANGED
@@ -713,8 +713,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
713
713
  create_capability_observed_ms_ago: z.number().nullable().catch(null).optional(),
714
714
  }).optional(),
715
715
  }).optional().describe("RFC-024 — masked node config snapshot"),
716
+ // app#225 follow-up — the reporting process answers the `rules_file`
717
+ // doorbell (agent-node, and the claude-code channel server node-server).
718
+ // Top-level instead of config_snapshot: claude-code sessions usually have
719
+ // no `nodes` row, and config_snapshot is persisted per node_id. Only a
720
+ // node token bound to this same alias can set it; see the UPDATE below.
721
+ rules_file_capable: z.literal(true).optional(),
716
722
  },
717
- async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap }) => {
723
+ async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap, rules_file_capable: rulesFileCapable }) => {
718
724
  const effectiveNetId = getNetworkId(netId);
719
725
  const sessionNetId = effectiveNetId ?? "default";
720
726
  if (!callerTokenIsNetwork || !enforceNetworkId) {
@@ -913,6 +919,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
913
919
  handover.registered_at ?? null],
914
920
  );
915
921
  }
922
+ // app#225 follow-up — separate statement on purpose: the INSERT above is
923
+ // byte-pinned by test698's mutation harness. Sticky: only ever set to 1,
924
+ // and only by a node token bound to this alias (a user token or another
925
+ // node cannot mark someone else's session as able to serve its files).
926
+ if (rulesFileCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
927
+ db.run("UPDATE sessions SET rules_file_capable = 1 WHERE resume_id = ?1", [resume_id]);
928
+ }
916
929
  if (host || proc) {
917
930
  db.run(
918
931
  `INSERT INTO agent_telemetry (id, network_id, resume_id, alias, hostname, ip, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb, disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count, created_at)
@@ -3102,21 +3115,51 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3102
3115
 
3103
3116
  const enqueueRulesFileRequest = (
3104
3117
  op: "read" | "write",
3105
- a: { node_id?: string; child_node_id?: string; network_id?: string; content?: string },
3118
+ a: { node_id?: string; child_node_id?: string; alias?: string; network_id?: string; content?: string },
3106
3119
  ) => {
3107
- const idArg = resolveNodeIdArg({ node_id: a.node_id, child_node_id: a.child_node_id });
3108
- if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
3109
- const nodeId = idArg.node_id;
3110
3120
  const effectiveNetId = getNetworkId(a.network_id);
3111
3121
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, `${op} rules file`);
3112
3122
 
3113
- const { row: node, sec1Ok } = resolveTargetNode(nodeId, effectiveNetId);
3114
- if (!node) {
3115
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: nodeId }) }] };
3116
- }
3117
- if (!sec1Ok) {
3118
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node", message: "node belongs to another network" }) }] };
3123
+ // Target: a `nodes` row by node_id (original path), or — app#225 follow-up —
3124
+ // an alias. An alias resolves to its `nodes` row when there is one; otherwise
3125
+ // to a live session in the caller's network that advertised
3126
+ // rules_file_capable (claude-code sessions mostly have no `nodes` row). The
3127
+ // queue key for such a session is `session:<alias>`, network-scoped.
3128
+ let node: { node_id: string; alias: string; network_id: string | null } | null = null;
3129
+ if (a.node_id || a.child_node_id || !a.alias) {
3130
+ const idArg = resolveNodeIdArg({ node_id: a.node_id, child_node_id: a.child_node_id });
3131
+ if (!idArg.ok) return { content: [{ type: "text" as const, text: JSON.stringify(idArg) }] };
3132
+ const nodeId = idArg.node_id;
3133
+ const resolved = resolveTargetNode(nodeId, effectiveNetId);
3134
+ if (!resolved.row) {
3135
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: nodeId }) }] };
3136
+ }
3137
+ if (!resolved.sec1Ok) {
3138
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node", message: "node belongs to another network" }) }] };
3139
+ }
3140
+ node = resolved.row as any;
3141
+ } else {
3142
+ const scopeNet = effectiveNetId || "default";
3143
+ const byAlias = db.get<{ node_id: string; alias: string; network_id: string | null }>(
3144
+ "SELECT node_id, alias, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
3145
+ a.alias,
3146
+ scopeNet,
3147
+ );
3148
+ if (byAlias) {
3149
+ node = byAlias;
3150
+ } else {
3151
+ const session = db.get<{ alias: string; network_id: string | null }>(
3152
+ "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND rules_file_capable = 1 ORDER BY updated_at DESC LIMIT 1",
3153
+ a.alias,
3154
+ scopeNet,
3155
+ );
3156
+ if (!session) {
3157
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "rules_file_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve rules files (its channel server may be too old)" }) }] };
3158
+ }
3159
+ node = { node_id: `session:${session.alias}`, alias: session.alias, network_id: session.network_id };
3160
+ }
3119
3161
  }
3162
+ const nodeId = node.node_id;
3120
3163
  if (op === "write") {
3121
3164
  if (typeof a.content !== "string") {
3122
3165
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_content", reason: "content must be a string" }) }] };
@@ -3157,8 +3200,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3157
3200
  server.tool(
3158
3201
  "read_node_rules_file",
3159
3202
  "Ask a node to send back its rules file (CLAUDE.md for claude nodes, AGENTS.md otherwise) from its working directory. No path argument by design. Poll get_rules_file_result with the returned request_id. app#225.",
3160
- { ...NODE_ID_ALIAS_FIELDS, network_id: z.string().max(200).optional() },
3161
- async ({ node_id, child_node_id, network_id }) => enqueueRulesFileRequest("read", { node_id, child_node_id, network_id }),
3203
+ {
3204
+ ...NODE_ID_ALIAS_FIELDS,
3205
+ alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported rules_file_capable (claude-code sessions)."),
3206
+ network_id: z.string().max(200).optional(),
3207
+ },
3208
+ async ({ node_id, child_node_id, alias, network_id }) => enqueueRulesFileRequest("read", { node_id, child_node_id, alias, network_id }),
3162
3209
  );
3163
3210
 
3164
3211
  server.tool(
@@ -3166,10 +3213,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3166
3213
  "Ask a node to overwrite its rules file (CLAUDE.md for claude nodes, AGENTS.md otherwise) in its working directory with `content`. No path argument by design. Poll get_rules_file_result with the returned request_id. app#225.",
3167
3214
  {
3168
3215
  ...NODE_ID_ALIAS_FIELDS,
3216
+ alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported rules_file_capable (claude-code sessions)."),
3169
3217
  content: z.string().max(RULES_FILE_MAX_BYTES).describe("Full new file content (UTF-8). The node writes it atomically."),
3170
3218
  network_id: z.string().max(200).optional(),
3171
3219
  },
3172
- async ({ node_id, child_node_id, content, network_id }) => enqueueRulesFileRequest("write", { node_id, child_node_id, content, network_id }),
3220
+ async ({ node_id, child_node_id, alias, content, network_id }) => enqueueRulesFileRequest("write", { node_id, child_node_id, alias, content, network_id }),
3173
3221
  );
3174
3222
 
3175
3223
  server.tool(
@@ -3189,12 +3237,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3189
3237
  callerAlias,
3190
3238
  enforceNetworkId,
3191
3239
  );
3192
- if (!node) {
3193
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request: null }) }] };
3194
- }
3240
+ // app#225 follow-up — no `nodes` row: this is a session-only target
3241
+ // (claude-code). Its queue key is `session:<alias>`, always paired with the
3242
+ // caller's network so a same-named alias in another network never matches.
3243
+ const queueKey: string = node ? node.node_id : `session:${callerAlias}`;
3195
3244
  const req = db.get<any>(
3196
- "SELECT request_id, op, content FROM node_rules_requests WHERE node_id = ?1 AND status = 'pending' ORDER BY created_at ASC LIMIT 1",
3197
- node.node_id,
3245
+ "SELECT request_id, op, content FROM node_rules_requests WHERE node_id = ?1 AND network_id = ?2 AND status = 'pending' ORDER BY created_at ASC LIMIT 1",
3246
+ queueKey,
3247
+ enforceNetworkId,
3198
3248
  );
3199
3249
  if (!req) {
3200
3250
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request: null }) }] };
@@ -3242,15 +3292,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3242
3292
  callerAlias,
3243
3293
  enforceNetworkId,
3244
3294
  );
3245
- if (!node) {
3246
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "alias_unknown" }) }] };
3247
- }
3295
+ const queueKey: string = node ? node.node_id : `session:${callerAlias}`;
3248
3296
  const req = db.get<any>(
3249
- "SELECT request_id, node_id, status FROM node_rules_requests WHERE request_id = ?1",
3297
+ "SELECT request_id, node_id, network_id, status FROM node_rules_requests WHERE request_id = ?1",
3250
3298
  requestId,
3251
3299
  );
3252
- // 跨租户闸:只能 ack 自己的请求;别人的当不存在处理。
3253
- if (!req || req.node_id !== node.node_id) {
3300
+ // 跨租户闸:只能 ack 自己的请求;别人的当不存在处理。session 键另外核网络。
3301
+ if (!req || req.node_id !== queueKey || req.network_id !== enforceNetworkId) {
3254
3302
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "unknown_or_foreign_request" }) }] };
3255
3303
  }
3256
3304
  if (req.status === "done" || req.status === "failed" || req.status === "timeout") {
package/src/uploads.ts CHANGED
@@ -336,7 +336,7 @@ export type TaskAttachment = {
336
336
  * returns `{file_id, path}` where `path` is a hub-machine-local
337
337
  * absolute path. Senders sometimes echo `path` back into meta.attachments.
338
338
  * When the receiving agent runs on a different host (Vincent 2026-06-30
339
- * toodadev2 case), that path string is meaningless. agent-node's
339
+ * a partner-team host case), that path string is meaningless. agent-node's
340
340
  * fetch-attachment.ts will fetch via `file_id` regardless, but leaving
341
341
  * the stale `path` in the JSON misleads any tooling that reads it.
342
342
  *