@sleep2agi/commhub-server 0.9.0-preview.57 → 0.9.0-preview.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/db.ts +3 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/rest-projections.ts +1 -1
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/server.ts +1 -0
- package/src/task-consumption.test.ts +11 -0
- package/src/tools.ts +134 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.59",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/db.ts
CHANGED
|
@@ -130,6 +130,9 @@ for (const col of [
|
|
|
130
130
|
// Node skills view — the same session answers skills_list / skill_read on the
|
|
131
131
|
// rules-file doorbell (node-skills.ts). Sticky, bound-token only, like above.
|
|
132
132
|
{ name: "skills_capable", def: "INTEGER NOT NULL DEFAULT 0" },
|
|
133
|
+
// Project folder view — the same session answers files_list / file_read on the
|
|
134
|
+
// rules-file doorbell (node-files.ts). Sticky, bound-token only, like above.
|
|
135
|
+
{ name: "files_capable", def: "INTEGER NOT NULL DEFAULT 0" },
|
|
133
136
|
]) {
|
|
134
137
|
try { db.exec(`ALTER TABLE sessions ADD COLUMN ${col.name} ${col.def}`); } catch {}
|
|
135
138
|
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// Project folder view — list_node_files / read_node_file ride the rules-file
|
|
2
|
+
// queue + doorbell (ops files_list / file_read).
|
|
3
|
+
//
|
|
4
|
+
// Hub-side contract verified here:
|
|
5
|
+
// 1. report_status { files_capable: true } is sticky and set only by the
|
|
6
|
+
// session's own node token (bound or legacy-unbound, same rule as skills).
|
|
7
|
+
// 2. Both tools reach a session with no nodes row by alias; the pulled
|
|
8
|
+
// request carries the normalized RELATIVE path; results come back through
|
|
9
|
+
// get_rules_file_result.
|
|
10
|
+
// 3. Absolute paths, `..`, `~`, drive letters, backslashes and NUL are refused
|
|
11
|
+
// before any request row exists (the node re-checks; this is the first wall).
|
|
12
|
+
// 4. A node token may not enqueue a files op (it would read another node's
|
|
13
|
+
// disk), and only the token that asked may read a files result.
|
|
14
|
+
// 5. A token bound to node A never pulls or acks node B's files request; an
|
|
15
|
+
// unbound legacy token falls back to its alias exactly as it does for skills.
|
|
16
|
+
// 6. Its own single-flight lane; rules-capable-only sessions are refused.
|
|
17
|
+
// 7. Files acks may carry up to 1 MiB (escaped JSON of a 256 KiB file); rules
|
|
18
|
+
// acks keep their 256 KiB cap.
|
|
19
|
+
//
|
|
20
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/files-transport.db bun test src/node-files-transport.test.ts
|
|
21
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
22
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
23
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
24
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
25
|
+
import { db } from "./db.js";
|
|
26
|
+
import { registerTools } from "./tools.js";
|
|
27
|
+
|
|
28
|
+
const NET = "net_files_session";
|
|
29
|
+
const OTHER_NET = "net_files_session_other";
|
|
30
|
+
const USER = "u_files_session_owner";
|
|
31
|
+
const SESSION_ALIAS = "files-session-cc";
|
|
32
|
+
const SESSION_RESUME = "cc-files-session-1";
|
|
33
|
+
const PEER_ALIAS = "files-session-peer";
|
|
34
|
+
const NODE_A = "files-node-a";
|
|
35
|
+
const NODE_B = "files-node-b";
|
|
36
|
+
const NODE_A_ID = "node_files_a";
|
|
37
|
+
const NODE_B_ID = "node_files_b";
|
|
38
|
+
const LEGACY_ALIAS = "files-node-legacy";
|
|
39
|
+
const LEGACY_ID = "node_files_legacy";
|
|
40
|
+
const T_SESSION = "tok_files_session_cc";
|
|
41
|
+
const T_PEER = "tok_files_session_peer";
|
|
42
|
+
const T_OTHER_NET_SAME_ALIAS = "tok_files_session_other_net";
|
|
43
|
+
const T_A = "tok_files_node_a";
|
|
44
|
+
const T_B = "tok_files_node_b";
|
|
45
|
+
const T_LEGACY = "tok_files_node_legacy";
|
|
46
|
+
|
|
47
|
+
function cleanup() {
|
|
48
|
+
for (const net of [NET, OTHER_NET]) {
|
|
49
|
+
try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [net]); } catch {}
|
|
50
|
+
try { db.run("DELETE FROM rename_txn WHERE network_id = ?1", [net]); } catch {}
|
|
51
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [net]); } catch {}
|
|
52
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [net]); } catch {}
|
|
53
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [net]); } catch {}
|
|
54
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [net]); } catch {}
|
|
55
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [net]); } catch {}
|
|
56
|
+
}
|
|
57
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function seedWorld() {
|
|
61
|
+
db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER, USER]);
|
|
62
|
+
for (const net of [NET, OTHER_NET]) {
|
|
63
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [net, net, USER]);
|
|
64
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER, net]);
|
|
65
|
+
}
|
|
66
|
+
const tok = (id: string, net: string, alias: string, bound: string | null = null) => db.run(
|
|
67
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at, bound_node_id) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL, ?6)`,
|
|
68
|
+
[id, USER, net, `node:${alias}`, `hash_${id}`, bound],
|
|
69
|
+
);
|
|
70
|
+
const node = (id: string, alias: string) => db.run(
|
|
71
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
|
|
72
|
+
[id, alias, alias, NET],
|
|
73
|
+
);
|
|
74
|
+
node(NODE_A_ID, NODE_A);
|
|
75
|
+
node(NODE_B_ID, NODE_B);
|
|
76
|
+
node(LEGACY_ID, LEGACY_ALIAS);
|
|
77
|
+
tok(T_SESSION, NET, SESSION_ALIAS);
|
|
78
|
+
tok(T_PEER, NET, PEER_ALIAS);
|
|
79
|
+
tok(T_OTHER_NET_SAME_ALIAS, OTHER_NET, SESSION_ALIAS);
|
|
80
|
+
tok(T_A, NET, NODE_A, NODE_A_ID);
|
|
81
|
+
tok(T_B, NET, NODE_B, NODE_B_ID);
|
|
82
|
+
tok(T_LEGACY, NET, LEGACY_ALIAS, null);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
type Identity = { net: string; alias: string; isNetworkToken: boolean; tokenId: string };
|
|
86
|
+
const asUser: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_files_session_user" };
|
|
87
|
+
const asUserOtherLogin: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_files_session_user_other_login" };
|
|
88
|
+
const asSession: Identity = { net: NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_SESSION };
|
|
89
|
+
const asPeer: Identity = { net: NET, alias: PEER_ALIAS, isNetworkToken: true, tokenId: T_PEER };
|
|
90
|
+
const asOtherNetSameAlias: Identity = { net: OTHER_NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_OTHER_NET_SAME_ALIAS };
|
|
91
|
+
const asA: Identity = { net: NET, alias: NODE_A, isNetworkToken: true, tokenId: T_A };
|
|
92
|
+
const asB: Identity = { net: NET, alias: NODE_B, isNetworkToken: true, tokenId: T_B };
|
|
93
|
+
const asLegacy: Identity = { net: NET, alias: LEGACY_ALIAS, isNetworkToken: true, tokenId: T_LEGACY };
|
|
94
|
+
|
|
95
|
+
async function connect(id: Identity) {
|
|
96
|
+
const server = new McpServer({ name: "files-session-test", version: "1" });
|
|
97
|
+
registerTools(server, undefined, id.net, USER, id.alias, id.isNetworkToken, id.tokenId);
|
|
98
|
+
const client = new Client({ name: "files-session-client", version: "1" });
|
|
99
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
100
|
+
await server.connect(st);
|
|
101
|
+
await client.connect(ct);
|
|
102
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
103
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
104
|
+
const first = r.content?.[0];
|
|
105
|
+
if (!first || first.type !== "text") throw new Error(`no text result from ${name}: ${JSON.stringify(r).slice(0, 300)}`);
|
|
106
|
+
try { return JSON.parse(first.text); } catch { return { raw: first.text, isError: r.isError === true }; }
|
|
107
|
+
};
|
|
108
|
+
const close = async () => { await client.close(); await server.close(); };
|
|
109
|
+
return { call, close };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const capable = (alias: string, net = NET) =>
|
|
113
|
+
db.get<{ c: number }>("SELECT files_capable AS c FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, net)?.c;
|
|
114
|
+
const rowCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM node_rules_requests WHERE network_id = ?1", NET)!.n;
|
|
115
|
+
|
|
116
|
+
async function report(id: Identity, alias: string, resume: string, flags: Record<string, true>) {
|
|
117
|
+
const c = await connect(id);
|
|
118
|
+
try { return await c.call("report_status", { resume_id: resume, alias, status: "idle", agent: "claude-code", ...flags }); }
|
|
119
|
+
finally { await c.close(); }
|
|
120
|
+
}
|
|
121
|
+
const ALL_FLAGS = { rules_file_capable: true, skills_capable: true, files_capable: true } as const;
|
|
122
|
+
|
|
123
|
+
beforeEach(() => { cleanup(); seedWorld(); });
|
|
124
|
+
afterAll(cleanup);
|
|
125
|
+
|
|
126
|
+
describe("files_capable is set only by the session's own node token", () => {
|
|
127
|
+
test("own token sets it; a later report without it does not clear it; a peer cannot set it", async () => {
|
|
128
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
129
|
+
expect(capable(SESSION_ALIAS)).toBe(1);
|
|
130
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, {});
|
|
131
|
+
expect(capable(SESSION_ALIAS)).toBe(1);
|
|
132
|
+
const r = await report(asPeer, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
133
|
+
expect(r).toMatchObject({ ok: false });
|
|
134
|
+
// A user login cannot set it either.
|
|
135
|
+
await report(asUser, PEER_ALIAS, "cc-files-user-report", ALL_FLAGS);
|
|
136
|
+
expect(capable(PEER_ALIAS) ?? 0).toBe(0);
|
|
137
|
+
}, 20_000);
|
|
138
|
+
|
|
139
|
+
test("a stale process whose report is rewritten to a renamed alias does not grant that alias the flag", async () => {
|
|
140
|
+
const NEW_ALIAS = "files-session-renamed";
|
|
141
|
+
db.run(
|
|
142
|
+
`INSERT INTO rename_txn (txn_id, network_id, old_alias, new_alias, status, committed_at) VALUES ('txn_files_rename', ?1, ?2, ?3, 'committed', datetime('now'))`,
|
|
143
|
+
[NET, PEER_ALIAS, NEW_ALIAS],
|
|
144
|
+
);
|
|
145
|
+
// Token still carries the old alias; the hub rewrites the report to NEW_ALIAS.
|
|
146
|
+
const r = await report(asPeer, PEER_ALIAS, "cc-files-renamed", ALL_FLAGS);
|
|
147
|
+
expect(r.ok).toBe(true);
|
|
148
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM sessions WHERE alias = ?1 AND network_id = ?2", NEW_ALIAS, NET)!.n).toBe(1);
|
|
149
|
+
expect(capable(NEW_ALIAS)).toBe(0);
|
|
150
|
+
}, 20_000);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("list / read by alias reach a session with no nodes row", () => {
|
|
154
|
+
test("list: pulled request carries the normalized relative path; result comes back", async () => {
|
|
155
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
156
|
+
const u = await connect(asUser);
|
|
157
|
+
const s = await connect(asSession);
|
|
158
|
+
try {
|
|
159
|
+
const enq = await u.call("list_node_files", { alias: SESSION_ALIAS, path: "./src//lib/" });
|
|
160
|
+
expect(enq).toMatchObject({ ok: true, op: "files_list" });
|
|
161
|
+
const pulled = await s.call("get_rules_file_request", {});
|
|
162
|
+
expect(pulled.request).toEqual({ request_id: enq.request_id, op: "files_list", content: "src/lib" });
|
|
163
|
+
const payload = JSON.stringify({ path: "src/lib", entries: [{ name: "a.ts", type: "file", size: 3, mtime: 1 }], truncated: false, total: 1 });
|
|
164
|
+
expect(await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "files", exists: true, content: payload })).toMatchObject({ ok: true, status: "done" });
|
|
165
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: true, op: "files_list", status: "done", content: payload });
|
|
166
|
+
} finally { await u.close(); await s.close(); }
|
|
167
|
+
}, 20_000);
|
|
168
|
+
|
|
169
|
+
test("list with no path targets the root (empty content)", async () => {
|
|
170
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
171
|
+
const u = await connect(asUser);
|
|
172
|
+
const s = await connect(asSession);
|
|
173
|
+
try {
|
|
174
|
+
const enq = await u.call("list_node_files", { alias: SESSION_ALIAS });
|
|
175
|
+
expect((await s.call("get_rules_file_request", {})).request).toEqual({ request_id: enq.request_id, op: "files_list", content: "" });
|
|
176
|
+
} finally { await u.close(); await s.close(); }
|
|
177
|
+
}, 20_000);
|
|
178
|
+
|
|
179
|
+
test("read round trip", async () => {
|
|
180
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
181
|
+
const u = await connect(asUser);
|
|
182
|
+
const s = await connect(asSession);
|
|
183
|
+
try {
|
|
184
|
+
const enq = await u.call("read_node_file", { alias: SESSION_ALIAS, path: "docs/README.md" });
|
|
185
|
+
expect(enq).toMatchObject({ ok: true, op: "file_read" });
|
|
186
|
+
expect((await s.call("get_rules_file_request", {})).request).toEqual({ request_id: enq.request_id, op: "file_read", content: "docs/README.md" });
|
|
187
|
+
const payload = JSON.stringify({ path: "docs/README.md", name: "README.md", kind: "text", size: 6, content: "# hi\n" });
|
|
188
|
+
await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "README.md", exists: true, content: payload });
|
|
189
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "done", content: payload });
|
|
190
|
+
} finally { await u.close(); await s.close(); }
|
|
191
|
+
}, 20_000);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("path validation happens before any row is written", () => {
|
|
195
|
+
test("absolute, .., ~, drive letters, backslashes, NUL, and an empty read path are refused", async () => {
|
|
196
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
197
|
+
const u = await connect(asUser);
|
|
198
|
+
try {
|
|
199
|
+
const bad = ["/etc/passwd", "../outside", "a/../../b", "src/..", "~/.ssh/id_rsa", "C:/Windows", "a\\b", "..\\x", "a\u0000b", "x".repeat(1025)];
|
|
200
|
+
for (const path of bad) {
|
|
201
|
+
// Over-long paths are stopped by the zod schema (an MCP input error); the rest by invalid_path.
|
|
202
|
+
const r = await u.call("read_node_file", { alias: SESSION_ALIAS, path });
|
|
203
|
+
expect(r.ok).not.toBe(true);
|
|
204
|
+
if (path.length <= 1024) expect(r).toMatchObject({ ok: false, error: "invalid_path" });
|
|
205
|
+
const l = await u.call("list_node_files", { alias: SESSION_ALIAS, path });
|
|
206
|
+
expect(l.ok).not.toBe(true);
|
|
207
|
+
}
|
|
208
|
+
expect(await u.call("read_node_file", { alias: SESSION_ALIAS, path: "/" })).toMatchObject({ ok: false, error: "invalid_path" });
|
|
209
|
+
expect(await u.call("read_node_file", { alias: SESSION_ALIAS, path: "./" })).toMatchObject({ ok: false, error: "invalid_path" });
|
|
210
|
+
expect(rowCount()).toBe(0);
|
|
211
|
+
} finally { await u.close(); }
|
|
212
|
+
}, 20_000);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("who may ask, and who may read the answer", () => {
|
|
216
|
+
test("a node token cannot enqueue a files op on another node (or itself)", async () => {
|
|
217
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
218
|
+
const a = await connect(asA);
|
|
219
|
+
try {
|
|
220
|
+
expect(await a.call("list_node_files", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "node_token_cannot_browse_files" });
|
|
221
|
+
expect(await a.call("read_node_file", { node_id: NODE_B_ID, path: ".env" })).toMatchObject({ ok: false, error: "node_token_cannot_browse_files" });
|
|
222
|
+
expect(await a.call("list_node_files", { node_id: NODE_A_ID })).toMatchObject({ ok: false, error: "node_token_cannot_browse_files" });
|
|
223
|
+
expect(rowCount()).toBe(0);
|
|
224
|
+
} finally { await a.close(); }
|
|
225
|
+
}, 20_000);
|
|
226
|
+
|
|
227
|
+
test("only the token that asked can read a files result; node tokens never can", async () => {
|
|
228
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
229
|
+
const u = await connect(asUser);
|
|
230
|
+
const other = await connect(asUserOtherLogin);
|
|
231
|
+
const s = await connect(asSession);
|
|
232
|
+
const a = await connect(asA);
|
|
233
|
+
try {
|
|
234
|
+
const enq = await u.call("read_node_file", { alias: SESSION_ALIAS, path: "notes.txt" });
|
|
235
|
+
await s.call("get_rules_file_request", {});
|
|
236
|
+
await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: JSON.stringify({ path: "notes.txt", kind: "text", content: "x" }) });
|
|
237
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: true, status: "done" });
|
|
238
|
+
expect(await other.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: false, error: "request_not_found" });
|
|
239
|
+
expect(await a.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: false, error: "request_not_found" });
|
|
240
|
+
// The serving session itself cannot read the stored result back through the client tool either.
|
|
241
|
+
expect(await s.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: false, error: "request_not_found" });
|
|
242
|
+
} finally { await u.close(); await other.close(); await s.close(); await a.close(); }
|
|
243
|
+
}, 20_000);
|
|
244
|
+
|
|
245
|
+
test("rules/skills results stay readable by any writer of the network (unchanged)", async () => {
|
|
246
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
247
|
+
const u = await connect(asUser);
|
|
248
|
+
const other = await connect(asUserOtherLogin);
|
|
249
|
+
try {
|
|
250
|
+
const enq = await u.call("list_node_skills", { alias: SESSION_ALIAS });
|
|
251
|
+
expect(await other.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ ok: true, status: "pending" });
|
|
252
|
+
} finally { await u.close(); await other.close(); }
|
|
253
|
+
}, 20_000);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe("a token bound to node A cannot touch node B's files request", () => {
|
|
257
|
+
test("B's request: A pulls nothing, A's ack is ignored, B pulls and acks it", async () => {
|
|
258
|
+
const u = await connect(asUser);
|
|
259
|
+
const a = await connect(asA);
|
|
260
|
+
const b = await connect(asB);
|
|
261
|
+
try {
|
|
262
|
+
const enq = await u.call("read_node_file", { node_id: NODE_B_ID, path: "secret-plan.md" });
|
|
263
|
+
expect(enq).toMatchObject({ ok: true });
|
|
264
|
+
expect((await a.call("get_rules_file_request", {})).request).toBeNull();
|
|
265
|
+
expect(await a.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{\"forged\":true}" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
|
|
266
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
|
|
267
|
+
expect((await b.call("get_rules_file_request", {})).request).toEqual({ request_id: enq.request_id, op: "file_read", content: "secret-plan.md" });
|
|
268
|
+
expect(await b.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{}" })).toMatchObject({ ok: true, status: "done" });
|
|
269
|
+
} finally { await u.close(); await a.close(); await b.close(); }
|
|
270
|
+
}, 20_000);
|
|
271
|
+
|
|
272
|
+
test("same alias in another network neither pulls nor acks; a peer's ack is ignored", async () => {
|
|
273
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
274
|
+
const u = await connect(asUser);
|
|
275
|
+
const other = await connect(asOtherNetSameAlias);
|
|
276
|
+
const peer = await connect(asPeer);
|
|
277
|
+
try {
|
|
278
|
+
const enq = await u.call("list_node_files", { alias: SESSION_ALIAS });
|
|
279
|
+
expect((await other.call("get_rules_file_request", {})).request).toBeNull();
|
|
280
|
+
expect(await other.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{}" })).toMatchObject({ ignored: "unknown_or_foreign_request" });
|
|
281
|
+
expect(await peer.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{}" })).toMatchObject({ ignored: "unknown_or_foreign_request" });
|
|
282
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
|
|
283
|
+
} finally { await u.close(); await other.close(); await peer.close(); }
|
|
284
|
+
}, 20_000);
|
|
285
|
+
|
|
286
|
+
test("an unbound legacy token gets what skills gives it: alias fallback pull + ack, and it can set its own flag", async () => {
|
|
287
|
+
const u = await connect(asUser);
|
|
288
|
+
const legacy = await connect(asLegacy);
|
|
289
|
+
try {
|
|
290
|
+
await report(asLegacy, LEGACY_ALIAS, `sdk-${LEGACY_ID}`, ALL_FLAGS);
|
|
291
|
+
expect(capable(LEGACY_ALIAS)).toBe(1);
|
|
292
|
+
const skills = await u.call("list_node_skills", { node_id: LEGACY_ID });
|
|
293
|
+
const files = await u.call("list_node_files", { node_id: LEGACY_ID, path: "src" });
|
|
294
|
+
expect(skills).toMatchObject({ ok: true });
|
|
295
|
+
expect(files).toMatchObject({ ok: true });
|
|
296
|
+
const first = (await legacy.call("get_rules_file_request", {})).request;
|
|
297
|
+
const second = (await legacy.call("get_rules_file_request", {})).request;
|
|
298
|
+
expect([first.request_id, second.request_id].sort()).toEqual([skills.request_id, files.request_id].sort());
|
|
299
|
+
expect(await legacy.call("ack_rules_file_request", { request_id: files.request_id, status: "done", content: "{}" })).toMatchObject({ ok: true, status: "done" });
|
|
300
|
+
expect(await legacy.call("ack_rules_file_request", { request_id: skills.request_id, status: "done", content: "{}" })).toMatchObject({ ok: true, status: "done" });
|
|
301
|
+
} finally { await u.close(); await legacy.close(); }
|
|
302
|
+
}, 20_000);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe("lanes, capability gate and ack size", () => {
|
|
306
|
+
test("files has its own single-flight lane", async () => {
|
|
307
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
308
|
+
const u = await connect(asUser);
|
|
309
|
+
try {
|
|
310
|
+
expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: true });
|
|
311
|
+
expect(await u.call("list_node_skills", { alias: SESSION_ALIAS })).toMatchObject({ ok: true });
|
|
312
|
+
const list = await u.call("list_node_files", { alias: SESSION_ALIAS });
|
|
313
|
+
expect(list).toMatchObject({ ok: true });
|
|
314
|
+
expect(await u.call("read_node_file", { alias: SESSION_ALIAS, path: "a.txt" })).toMatchObject({ ok: false, error: "request_in_flight", existing_request_id: list.request_id });
|
|
315
|
+
} finally { await u.close(); }
|
|
316
|
+
}, 20_000);
|
|
317
|
+
|
|
318
|
+
test("rules+skills capable but not files capable → files_target_not_found, no row", async () => {
|
|
319
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, { rules_file_capable: true, skills_capable: true });
|
|
320
|
+
const u = await connect(asUser);
|
|
321
|
+
try {
|
|
322
|
+
expect(await u.call("list_node_files", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "files_target_not_found" });
|
|
323
|
+
expect(rowCount()).toBe(0);
|
|
324
|
+
} finally { await u.close(); }
|
|
325
|
+
}, 20_000);
|
|
326
|
+
|
|
327
|
+
test("a files ack may carry up to 1 MiB; a rules ack keeps its 256 KiB cap", async () => {
|
|
328
|
+
await report(asSession, SESSION_ALIAS, SESSION_RESUME, ALL_FLAGS);
|
|
329
|
+
const u = await connect(asUser);
|
|
330
|
+
const s = await connect(asSession);
|
|
331
|
+
try {
|
|
332
|
+
const big = JSON.stringify({ path: "big.txt", kind: "text", content: "\n".repeat(256 * 1024) });
|
|
333
|
+
expect(big.length).toBeGreaterThan(256 * 1024);
|
|
334
|
+
const f = await u.call("read_node_file", { alias: SESSION_ALIAS, path: "big.txt" });
|
|
335
|
+
const r = await u.call("read_node_rules_file", { alias: SESSION_ALIAS });
|
|
336
|
+
await s.call("get_rules_file_request", {});
|
|
337
|
+
await s.call("get_rules_file_request", {});
|
|
338
|
+
expect(await s.call("ack_rules_file_request", { request_id: f.request_id, status: "done", content: big })).toMatchObject({ ok: true, status: "done" });
|
|
339
|
+
expect(await s.call("ack_rules_file_request", { request_id: r.request_id, status: "done", content: "x".repeat(256 * 1024 + 1) })).toMatchObject({ ok: false, error: "content_too_large" });
|
|
340
|
+
const tooBig = await s.call("ack_rules_file_request", { request_id: r.request_id, status: "done", content: "x".repeat(1024 * 1024 + 1) });
|
|
341
|
+
expect(tooBig.ok).not.toBe(true);
|
|
342
|
+
} finally { await u.close(); await s.close(); }
|
|
343
|
+
}, 20_000);
|
|
344
|
+
});
|
|
@@ -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", "rules_file_capable", "skills_capable", "runtime", "host", "process_telemetry",
|
|
48
|
+
"external_schedules", "rules_file_capable", "skills_capable", "files_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",
|
package/src/rest-projections.ts
CHANGED
|
@@ -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", "rules_file_capable", "skills_capable",
|
|
22
|
+
"external_schedules", "rules_file_capable", "skills_capable", "files_capable",
|
|
23
23
|
] as const;
|
|
24
24
|
|
|
25
25
|
export const AUDIT_LOG_REST_COLUMNS = [
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Duplicate-alias node rows vs the node pull/ack paths (field report).
|
|
2
|
+
//
|
|
3
|
+
// Re-creating a node under the same alias leaves the old `nodes` row behind, so
|
|
4
|
+
// one alias can own several rows in a network. The pull/ack tools used to
|
|
5
|
+
// resolve the caller by `WHERE alias = ? AND network_id = ?` + `db.get`, i.e.
|
|
6
|
+
// the first row SQLite yields — the stale one. Field shape: the client queued a
|
|
7
|
+
// rules-file read for the live node, the node pulled with its own (bound)
|
|
8
|
+
// token, the hub looked under the stale node_id, answered `request: null`, and
|
|
9
|
+
// the read timed out with only `[rules-file] doorbell received` in the node log.
|
|
10
|
+
//
|
|
11
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/rules-dup.db bun test src/rules-file-duplicate-alias.test.ts
|
|
12
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
14
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
15
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
+
import { db } from "./db.js";
|
|
17
|
+
import { registerTools } from "./tools.js";
|
|
18
|
+
|
|
19
|
+
const NET = "net_rules_dup";
|
|
20
|
+
const USER = "u_rules_dup_owner";
|
|
21
|
+
const ALIAS = "rules-dup-node";
|
|
22
|
+
// Inserted first → the row a plain `db.get` on the alias returns.
|
|
23
|
+
const STALE_ID = "node_rules_dup_stale";
|
|
24
|
+
const LIVE_ID = "node_rules_dup_live";
|
|
25
|
+
const STALE_TOKEN = "tok_rules_dup_stale";
|
|
26
|
+
const LIVE_TOKEN = "tok_rules_dup_live";
|
|
27
|
+
const UNBOUND_TOKEN = "tok_rules_dup_unbound";
|
|
28
|
+
|
|
29
|
+
function cleanup() {
|
|
30
|
+
try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [NET]); } catch {}
|
|
31
|
+
try { db.run("DELETE FROM node_config_updates WHERE network_id = ?1", [NET]); } catch {}
|
|
32
|
+
try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [NET]); } catch {}
|
|
33
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET]); } catch {}
|
|
34
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET]); } catch {}
|
|
35
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET]); } catch {}
|
|
36
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
|
|
37
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
38
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function seedWorld(opts: { liveSession?: boolean } = {}) {
|
|
42
|
+
db.run(
|
|
43
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
44
|
+
[USER, USER],
|
|
45
|
+
);
|
|
46
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET, NET, USER]);
|
|
47
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER, NET]);
|
|
48
|
+
// Stale row first (older created_at AND lower rowid), live row second.
|
|
49
|
+
db.run(
|
|
50
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, '2026-09-16 01:40:40', '2026-09-16 01:40:40')`,
|
|
51
|
+
[STALE_ID, ALIAS, ALIAS, NET],
|
|
52
|
+
);
|
|
53
|
+
db.run(
|
|
54
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, '2026-09-16 01:42:46', '2026-09-16 01:42:46')`,
|
|
55
|
+
[LIVE_ID, ALIAS, ALIAS, NET],
|
|
56
|
+
);
|
|
57
|
+
for (const [tok, bound, hash] of [
|
|
58
|
+
[STALE_TOKEN, STALE_ID, "hash_rules_dup_stale"],
|
|
59
|
+
[LIVE_TOKEN, LIVE_ID, "hash_rules_dup_live"],
|
|
60
|
+
[UNBOUND_TOKEN, null, "hash_rules_dup_unbound"],
|
|
61
|
+
] as const) {
|
|
62
|
+
db.run(
|
|
63
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at, bound_node_id) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL, ?6)`,
|
|
64
|
+
[tok, USER, NET, `node:${ALIAS}`, hash, bound],
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (opts.liveSession !== false) {
|
|
68
|
+
db.run(
|
|
69
|
+
`INSERT INTO sessions (resume_id, alias, network_id, node_id, status, updated_at) VALUES (?1, ?2, ?3, ?4, 'idle', datetime('now'))`,
|
|
70
|
+
[`sdk-${LIVE_ID}`, ALIAS, NET, LIVE_ID],
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type Identity = { alias: string; isNetworkToken: boolean; tokenId: string };
|
|
76
|
+
const asUser: Identity = { alias: USER, isNetworkToken: false, tokenId: "tok_rules_dup_user" };
|
|
77
|
+
const asLive: Identity = { alias: ALIAS, isNetworkToken: true, tokenId: LIVE_TOKEN };
|
|
78
|
+
const asStale: Identity = { alias: ALIAS, isNetworkToken: true, tokenId: STALE_TOKEN };
|
|
79
|
+
const asUnbound: Identity = { alias: ALIAS, isNetworkToken: true, tokenId: UNBOUND_TOKEN };
|
|
80
|
+
|
|
81
|
+
async function connect(id: Identity) {
|
|
82
|
+
const server = new McpServer({ name: "rules-dup-test", version: "1" });
|
|
83
|
+
registerTools(server, undefined, NET, USER, id.alias, id.isNetworkToken, id.tokenId);
|
|
84
|
+
const client = new Client({ name: "rules-dup-client", version: "1" });
|
|
85
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
86
|
+
await server.connect(st);
|
|
87
|
+
await client.connect(ct);
|
|
88
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
89
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
90
|
+
const first = r.content?.[0];
|
|
91
|
+
if (!first || first.type !== "text") throw new Error(`no text result from ${name}`);
|
|
92
|
+
return JSON.parse(first.text);
|
|
93
|
+
};
|
|
94
|
+
const close = async () => { await client.close(); await server.close(); };
|
|
95
|
+
return { call, close };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function queuedNodeId(requestId: string): string | undefined {
|
|
99
|
+
return db.get<{ node_id: string }>("SELECT node_id FROM node_rules_requests WHERE request_id = ?1", requestId)?.node_id;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
beforeEach(() => { cleanup(); });
|
|
103
|
+
afterAll(cleanup);
|
|
104
|
+
|
|
105
|
+
describe("duplicate alias — rules file pull/ack", () => {
|
|
106
|
+
test("field case: read queued for the live node_id is pulled and acked by the live node's bound token", async () => {
|
|
107
|
+
seedWorld();
|
|
108
|
+
const u = await connect(asUser);
|
|
109
|
+
const n = await connect(asLive);
|
|
110
|
+
try {
|
|
111
|
+
const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
|
|
112
|
+
expect(enq).toMatchObject({ ok: true, op: "read" });
|
|
113
|
+
const pulled = await n.call("get_rules_file_request", {});
|
|
114
|
+
expect(pulled).toEqual({ ok: true, request: { request_id: enq.request_id, op: "read" } });
|
|
115
|
+
const ack = await n.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "AGENTS.md", exists: true, content: "# live" });
|
|
116
|
+
expect(ack).toMatchObject({ ok: true, status: "done" });
|
|
117
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "done", content: "# live" });
|
|
118
|
+
} finally { await u.close(); await n.close(); }
|
|
119
|
+
}, 20_000);
|
|
120
|
+
|
|
121
|
+
test("a token bound to the stale row can neither pull nor ack the live node's request", async () => {
|
|
122
|
+
seedWorld();
|
|
123
|
+
const u = await connect(asUser);
|
|
124
|
+
const stale = await connect(asStale);
|
|
125
|
+
try {
|
|
126
|
+
const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
|
|
127
|
+
expect(await stale.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
|
|
128
|
+
expect(await stale.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "# forged" }))
|
|
129
|
+
.toEqual({ ok: true, ignored: "unknown_or_foreign_request" });
|
|
130
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
|
|
131
|
+
} finally { await u.close(); await stale.close(); }
|
|
132
|
+
}, 20_000);
|
|
133
|
+
|
|
134
|
+
test("the live token does not pull a request queued for the stale row", async () => {
|
|
135
|
+
seedWorld();
|
|
136
|
+
const u = await connect(asUser);
|
|
137
|
+
const n = await connect(asLive);
|
|
138
|
+
try {
|
|
139
|
+
await u.call("read_node_rules_file", { node_id: STALE_ID });
|
|
140
|
+
expect(await n.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
|
|
141
|
+
} finally { await u.close(); await n.close(); }
|
|
142
|
+
}, 20_000);
|
|
143
|
+
|
|
144
|
+
test("client targeting by alias queues under the row the alias's session points at", async () => {
|
|
145
|
+
seedWorld();
|
|
146
|
+
const u = await connect(asUser);
|
|
147
|
+
try {
|
|
148
|
+
const enq = await u.call("read_node_rules_file", { alias: ALIAS });
|
|
149
|
+
expect(enq).toMatchObject({ ok: true, op: "read" });
|
|
150
|
+
expect(queuedNodeId(enq.request_id)).toBe(LIVE_ID);
|
|
151
|
+
const sk = await u.call("list_node_skills", { alias: ALIAS });
|
|
152
|
+
expect(sk).toMatchObject({ ok: true });
|
|
153
|
+
expect(queuedNodeId(sk.request_id)).toBe(LIVE_ID);
|
|
154
|
+
} finally { await u.close(); }
|
|
155
|
+
}, 20_000);
|
|
156
|
+
|
|
157
|
+
test("without a session row, alias targeting falls back to the newest row", async () => {
|
|
158
|
+
seedWorld({ liveSession: false });
|
|
159
|
+
const u = await connect(asUser);
|
|
160
|
+
try {
|
|
161
|
+
const enq = await u.call("read_node_rules_file", { alias: ALIAS });
|
|
162
|
+
expect(queuedNodeId(enq.request_id)).toBe(LIVE_ID);
|
|
163
|
+
} finally { await u.close(); }
|
|
164
|
+
}, 20_000);
|
|
165
|
+
|
|
166
|
+
test("the session mapping wins over recency: alias targeting picks the older row when the session points at it", async () => {
|
|
167
|
+
seedWorld({ liveSession: false });
|
|
168
|
+
db.run(
|
|
169
|
+
`INSERT INTO sessions (resume_id, alias, network_id, node_id, status, updated_at) VALUES (?1, ?2, ?3, ?4, 'idle', datetime('now'))`,
|
|
170
|
+
[`sdk-${STALE_ID}`, ALIAS, NET, STALE_ID],
|
|
171
|
+
);
|
|
172
|
+
const u = await connect(asUser);
|
|
173
|
+
try {
|
|
174
|
+
const enq = await u.call("read_node_rules_file", { alias: ALIAS });
|
|
175
|
+
expect(queuedNodeId(enq.request_id)).toBe(STALE_ID);
|
|
176
|
+
} finally { await u.close(); }
|
|
177
|
+
}, 20_000);
|
|
178
|
+
|
|
179
|
+
test("a token bound to a node whose row is gone does not fall back to another row with its alias", async () => {
|
|
180
|
+
seedWorld();
|
|
181
|
+
db.run("UPDATE api_tokens SET bound_node_id = 'node_rules_dup_deleted' WHERE token_id = ?1", [STALE_TOKEN]);
|
|
182
|
+
const u = await connect(asUser);
|
|
183
|
+
const stale = await connect(asStale);
|
|
184
|
+
try {
|
|
185
|
+
const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
|
|
186
|
+
expect(await stale.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
|
|
187
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
|
|
188
|
+
} finally { await u.close(); await stale.close(); }
|
|
189
|
+
}, 20_000);
|
|
190
|
+
|
|
191
|
+
test("an unbound legacy token resolves its alias to the session-mapped row", async () => {
|
|
192
|
+
seedWorld();
|
|
193
|
+
const u = await connect(asUser);
|
|
194
|
+
const n = await connect(asUnbound);
|
|
195
|
+
try {
|
|
196
|
+
const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
|
|
197
|
+
expect(await n.call("get_rules_file_request", {})).toEqual({ ok: true, request: { request_id: enq.request_id, op: "read" } });
|
|
198
|
+
} finally { await u.close(); await n.close(); }
|
|
199
|
+
}, 20_000);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("duplicate alias — config update pull/ack", () => {
|
|
203
|
+
function queueUpdate(nodeId: string, updateId: string) {
|
|
204
|
+
db.run(
|
|
205
|
+
`INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, '{"model":"m"}', 'hot', 0, 'pending', ?4, 'tok_rules_dup_user')`,
|
|
206
|
+
[updateId, nodeId, NET, Date.now()],
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
test("the live node's bound token pulls and acks its own pending update", async () => {
|
|
211
|
+
seedWorld();
|
|
212
|
+
queueUpdate(LIVE_ID, "cu_rules_dup_live");
|
|
213
|
+
const n = await connect(asLive);
|
|
214
|
+
try {
|
|
215
|
+
const pulled = await n.call("get_config_update", {});
|
|
216
|
+
expect(pulled.update?.update_id).toBe("cu_rules_dup_live");
|
|
217
|
+
const ack = await n.call("ack_config_update", { update_id: "cu_rules_dup_live", status: "applied", new_revision: 1 });
|
|
218
|
+
expect(ack.ok).toBe(true);
|
|
219
|
+
expect(ack.ignored).toBeUndefined();
|
|
220
|
+
expect(db.get<{ status: string }>("SELECT status FROM node_config_updates WHERE update_id = 'cu_rules_dup_live'")?.status).toBe("applied");
|
|
221
|
+
} finally { await n.close(); }
|
|
222
|
+
}, 20_000);
|
|
223
|
+
|
|
224
|
+
test("a token bound to the stale row cannot pull or ack the live node's update", async () => {
|
|
225
|
+
seedWorld();
|
|
226
|
+
queueUpdate(LIVE_ID, "cu_rules_dup_live2");
|
|
227
|
+
const stale = await connect(asStale);
|
|
228
|
+
try {
|
|
229
|
+
expect((await stale.call("get_config_update", {})).update).toBeNull();
|
|
230
|
+
await stale.call("ack_config_update", { update_id: "cu_rules_dup_live2", status: "applied", new_revision: 1 });
|
|
231
|
+
expect(db.get<{ status: string }>("SELECT status FROM node_config_updates WHERE update_id = 'cu_rules_dup_live2'")?.status).toBe("pending");
|
|
232
|
+
} finally { await stale.close(); }
|
|
233
|
+
}, 20_000);
|
|
234
|
+
});
|
package/src/server.ts
CHANGED
|
@@ -1683,6 +1683,7 @@ return Bun.serve({
|
|
|
1683
1683
|
// app#225 follow-up — sqlite INTEGER 0/1 → boolean on the wire.
|
|
1684
1684
|
rules_file_capable: s.rules_file_capable === 1,
|
|
1685
1685
|
skills_capable: s.skills_capable === 1,
|
|
1686
|
+
files_capable: s.files_capable === 1,
|
|
1686
1687
|
model: s.model ?? null,
|
|
1687
1688
|
runtime: normalizeRuntime(s.agent),
|
|
1688
1689
|
host: {
|
|
@@ -126,6 +126,17 @@ describe("task consumed_at identity and lifecycle", () => {
|
|
|
126
126
|
expect([...first.tasks, ...second.tasks].some((task: any) => task.task_id === "foreign-node-row")).toBe(false);
|
|
127
127
|
expect((await call(handler, { from_node_id: `id_${NODE_B}`, durable_cursor: true, limit: 100 })).error).toBe("from_node_id_identity_mismatch");
|
|
128
128
|
});
|
|
129
|
+
test("a legacy unbound node token is refused the durable cursor even for its own node", async () => {
|
|
130
|
+
// Pre-RFC-036 tokens carry no immutable node binding. The durable outbox
|
|
131
|
+
// cursor must stay closed to them (no bind-on-first-use); agent-node
|
|
132
|
+
// degrades to inbox-only compensation on this exact error code.
|
|
133
|
+
db.run("UPDATE api_tokens SET bound_node_id = NULL WHERE token_id = ?1", [`token_${NODE_A}`]);
|
|
134
|
+
const handler = toolsFor({ alias: NODE_A, nodeToken: true }).list_tasks;
|
|
135
|
+
expect((await call(handler, { from_node_id: `id_${NODE_A}`, durable_cursor: true, limit: 10 })).error).toBe("from_node_id_identity_mismatch");
|
|
136
|
+
expect((await call(handler, { from_node_id: `id_${NODE_B}`, durable_cursor: true, limit: 10 })).error).toBe("from_node_id_identity_mismatch");
|
|
137
|
+
const bound = db.get<{ bound_node_id: string | null }>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", `token_${NODE_A}`);
|
|
138
|
+
expect(bound?.bound_node_id).toBeNull();
|
|
139
|
+
});
|
|
129
140
|
test("terminal sequence paginates beyond 2000 and admits late completion of an old task", async () => {
|
|
130
141
|
db.run(
|
|
131
142
|
`INSERT INTO tasks (task_id, from_node_id, from_name, to_name, status, content, network_id, created_at)
|
package/src/tools.ts
CHANGED
|
@@ -721,8 +721,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
721
721
|
rules_file_capable: z.literal(true).optional(),
|
|
722
722
|
// Node skills view — same doorbell, ops skills_list / skill_read.
|
|
723
723
|
skills_capable: z.literal(true).optional(),
|
|
724
|
+
// Project folder view — same doorbell, ops files_list / file_read.
|
|
725
|
+
files_capable: z.literal(true).optional(),
|
|
724
726
|
},
|
|
725
|
-
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, skills_capable: skillsCapable }) => {
|
|
727
|
+
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, skills_capable: skillsCapable, files_capable: filesCapable }) => {
|
|
726
728
|
const effectiveNetId = getNetworkId(netId);
|
|
727
729
|
const sessionNetId = effectiveNetId ?? "default";
|
|
728
730
|
if (!callerTokenIsNetwork || !enforceNetworkId) {
|
|
@@ -931,6 +933,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
931
933
|
if (skillsCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
|
|
932
934
|
db.run("UPDATE sessions SET skills_capable = 1 WHERE resume_id = ?1", [resume_id]);
|
|
933
935
|
}
|
|
936
|
+
if (filesCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
|
|
937
|
+
db.run("UPDATE sessions SET files_capable = 1 WHERE resume_id = ?1", [resume_id]);
|
|
938
|
+
}
|
|
934
939
|
if (host || proc) {
|
|
935
940
|
db.run(
|
|
936
941
|
`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)
|
|
@@ -2982,6 +2987,51 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2982
2987
|
},
|
|
2983
2988
|
);
|
|
2984
2989
|
|
|
2990
|
+
// One alias can own several `nodes` rows in a network: re-creating a node
|
|
2991
|
+
// under the same alias leaves the old row behind, and `db.get` on
|
|
2992
|
+
// `WHERE alias = ? AND network_id = ?` then returns whichever row SQLite
|
|
2993
|
+
// happens to yield first. A field report showed the result: the client
|
|
2994
|
+
// queued a rules-file read for the live node, the node pulled with its own
|
|
2995
|
+
// token, the hub resolved its alias to the stale row, found nothing pending
|
|
2996
|
+
// and returned `request: null` until the read timed out.
|
|
2997
|
+
//
|
|
2998
|
+
// resolveNodeByAlias: prefer the row the alias's session points at (sessions
|
|
2999
|
+
// are UNIQUE per network+alias and carry the node_id that last reported),
|
|
3000
|
+
// then the newest row. Used where only an alias is known (a client targeting
|
|
3001
|
+
// by alias; unbound legacy node tokens).
|
|
3002
|
+
const resolveNodeByAlias = (alias: string, networkId: string) =>
|
|
3003
|
+
db.get<{ node_id: string; alias: string; network_id: string | null }>(
|
|
3004
|
+
`SELECT n.node_id, n.alias, n.network_id FROM nodes n
|
|
3005
|
+
LEFT JOIN sessions s ON s.alias = n.alias AND s.network_id = n.network_id AND s.node_id = n.node_id
|
|
3006
|
+
WHERE n.alias = ?1 AND n.network_id = ?2
|
|
3007
|
+
ORDER BY (s.node_id IS NOT NULL) DESC, n.created_at DESC, n.rowid DESC
|
|
3008
|
+
LIMIT 1`,
|
|
3009
|
+
alias,
|
|
3010
|
+
networkId,
|
|
3011
|
+
) ?? null;
|
|
3012
|
+
// resolveCallerNode: the node a network token pulls/acks for. A token bound
|
|
3013
|
+
// to a node resolves to exactly that node (and never to another row that
|
|
3014
|
+
// shares its alias); only unbound legacy tokens fall back to the alias.
|
|
3015
|
+
// Callers have already required callerTokenIsNetwork + enforceNetworkId +
|
|
3016
|
+
// callerAlias.
|
|
3017
|
+
const resolveCallerNode = (): { node_id: string; network_id: string | null } | null => {
|
|
3018
|
+
const bound = callerTokenId
|
|
3019
|
+
? db.get<{ bound_node_id: string | null }>(
|
|
3020
|
+
"SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
|
|
3021
|
+
callerTokenId,
|
|
3022
|
+
enforceNetworkId,
|
|
3023
|
+
)?.bound_node_id ?? null
|
|
3024
|
+
: null;
|
|
3025
|
+
if (bound) {
|
|
3026
|
+
return db.get<{ node_id: string; network_id: string | null }>(
|
|
3027
|
+
"SELECT node_id, network_id FROM nodes WHERE node_id = ?1 AND network_id = ?2",
|
|
3028
|
+
bound,
|
|
3029
|
+
enforceNetworkId,
|
|
3030
|
+
) ?? null;
|
|
3031
|
+
}
|
|
3032
|
+
return resolveNodeByAlias(callerAlias!, enforceNetworkId!);
|
|
3033
|
+
};
|
|
3034
|
+
|
|
2985
3035
|
server.tool(
|
|
2986
3036
|
"get_config_update",
|
|
2987
3037
|
"Node pulls its pending config update (called from agent-node when SSE config_update doorbell arrives). RFC-024.",
|
|
@@ -3005,11 +3055,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3005
3055
|
// Unconditional network_id filter (was previously conditional on
|
|
3006
3056
|
// enforceNetworkId being set; the new ntok guard above guarantees
|
|
3007
3057
|
// it's non-null so the filter is always applied).
|
|
3008
|
-
const node =
|
|
3009
|
-
"SELECT node_id, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
3010
|
-
callerAlias,
|
|
3011
|
-
enforceNetworkId,
|
|
3012
|
-
);
|
|
3058
|
+
const node = resolveCallerNode();
|
|
3013
3059
|
if (!node) {
|
|
3014
3060
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update: null }) }] };
|
|
3015
3061
|
}
|
|
@@ -3062,11 +3108,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3062
3108
|
// Cross-tenant guard: the ack-er must own the update being acked.
|
|
3063
3109
|
// Resolve node by caller's alias under the enforced network.
|
|
3064
3110
|
// Network filter is unconditional (guard above guarantees non-null).
|
|
3065
|
-
const node =
|
|
3066
|
-
"SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
3067
|
-
callerAlias,
|
|
3068
|
-
enforceNetworkId,
|
|
3069
|
-
);
|
|
3111
|
+
const node = resolveCallerNode();
|
|
3070
3112
|
if (!node) {
|
|
3071
3113
|
// Silently ignore stale ack — return ok so the node doesn't retry forever.
|
|
3072
3114
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "alias_unknown" }) }] };
|
|
@@ -3125,11 +3167,42 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3125
3167
|
const SKILL_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
3126
3168
|
const isSkillsOp = (op: string) => op === "skills_list" || op === "skill_read";
|
|
3127
3169
|
|
|
3170
|
+
// Project folder view — files_list / file_read ride the same queue + doorbell.
|
|
3171
|
+
// `content` carries a path RELATIVE to the node's work dir. The hub refuses
|
|
3172
|
+
// absolute paths, `..` segments, backslashes and NUL before any row exists;
|
|
3173
|
+
// the node re-validates, realpath-contains the result in its work dir and
|
|
3174
|
+
// refuses secret-looking files (node-files.ts). Stricter than skills on the
|
|
3175
|
+
// caller: a node token may not browse another node's disk, and only the token
|
|
3176
|
+
// that asked can read the answer.
|
|
3177
|
+
const NODE_FILE_PATH_MAX = 1024;
|
|
3178
|
+
const NODE_FILES_RESULT_MAX_CHARS = 1024 * 1024;
|
|
3179
|
+
const isFilesOp = (op: string) => op === "files_list" || op === "file_read";
|
|
3180
|
+
const normalizeNodeRelPath = (raw: unknown): string | null => {
|
|
3181
|
+
if (raw === undefined || raw === null) return "";
|
|
3182
|
+
if (typeof raw !== "string" || raw.length > NODE_FILE_PATH_MAX) return null;
|
|
3183
|
+
if (raw.includes("\0") || raw.includes("\\")) return null;
|
|
3184
|
+
if (raw.startsWith("/") || raw.startsWith("~") || /^[A-Za-z]:/.test(raw)) return null;
|
|
3185
|
+
const segs = raw.split("/").filter((s) => s !== "" && s !== ".");
|
|
3186
|
+
if (segs.some((s) => s === "..")) return null;
|
|
3187
|
+
return segs.join("/");
|
|
3188
|
+
};
|
|
3189
|
+
|
|
3128
3190
|
const enqueueRulesFileRequest = (
|
|
3129
|
-
op: "read" | "write" | "skills_list" | "skill_read",
|
|
3191
|
+
op: "read" | "write" | "skills_list" | "skill_read" | "files_list" | "file_read",
|
|
3130
3192
|
a: { node_id?: string; child_node_id?: string; alias?: string; network_id?: string; content?: string },
|
|
3131
3193
|
) => {
|
|
3132
3194
|
const effectiveNetId = getNetworkId(a.network_id);
|
|
3195
|
+
if (isFilesOp(op)) {
|
|
3196
|
+
if (callerTokenIsNetwork) {
|
|
3197
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_token_cannot_browse_files", message: "a node token cannot browse another node's project folder; use a user login" }) }] };
|
|
3198
|
+
}
|
|
3199
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, `${op} node files`);
|
|
3200
|
+
const rel = normalizeNodeRelPath(a.content);
|
|
3201
|
+
if (rel === null || (op === "file_read" && rel === "")) {
|
|
3202
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_path", reason: "path must be relative to the node work dir: no leading / or ~, no .. segment, no backslash or NUL, at most 1024 chars" }) }] };
|
|
3203
|
+
}
|
|
3204
|
+
a = { ...a, content: rel };
|
|
3205
|
+
}
|
|
3133
3206
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, isSkillsOp(op) ? `${op} node skills` : `${op} rules file`);
|
|
3134
3207
|
if (op === "skill_read" && (typeof a.content !== "string" || !SKILL_NAME_RE.test(a.content) || a.content === "." || a.content === "..")) {
|
|
3135
3208
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_skill_name", reason: "name must match [A-Za-z0-9._-]{1,64} and not be . or .." }) }] };
|
|
@@ -3155,22 +3228,23 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3155
3228
|
node = resolved.row as any;
|
|
3156
3229
|
} else {
|
|
3157
3230
|
const scopeNet = effectiveNetId || "default";
|
|
3158
|
-
const byAlias =
|
|
3159
|
-
"SELECT node_id, alias, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
3160
|
-
a.alias,
|
|
3161
|
-
scopeNet,
|
|
3162
|
-
);
|
|
3231
|
+
const byAlias = resolveNodeByAlias(a.alias, scopeNet);
|
|
3163
3232
|
if (byAlias) {
|
|
3164
3233
|
node = byAlias;
|
|
3165
3234
|
} else {
|
|
3166
3235
|
const session = db.get<{ alias: string; network_id: string | null }>(
|
|
3167
|
-
|
|
3236
|
+
isFilesOp(op)
|
|
3237
|
+
? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND files_capable = 1 ORDER BY updated_at DESC LIMIT 1"
|
|
3238
|
+
: isSkillsOp(op)
|
|
3168
3239
|
? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND skills_capable = 1 ORDER BY updated_at DESC LIMIT 1"
|
|
3169
3240
|
: "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",
|
|
3170
3241
|
a.alias,
|
|
3171
3242
|
scopeNet,
|
|
3172
3243
|
);
|
|
3173
3244
|
if (!session) {
|
|
3245
|
+
if (isFilesOp(op)) {
|
|
3246
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "files_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve its project folder (its channel server may be too old)" }) }] };
|
|
3247
|
+
}
|
|
3174
3248
|
return isSkillsOp(op)
|
|
3175
3249
|
? { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "skills_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve its skills (its channel server may be too old)" }) }] }
|
|
3176
3250
|
: { 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)" }) }] };
|
|
@@ -3194,7 +3268,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3194
3268
|
// Separate single-flight lanes: the client opens the rules file and the
|
|
3195
3269
|
// skills list together; one must not block the other.
|
|
3196
3270
|
const inFlight = db.get<{ request_id: string; created_at: number; pulled_at: number | null }>(
|
|
3197
|
-
|
|
3271
|
+
isFilesOp(op)
|
|
3272
|
+
? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('files_list', 'file_read') ORDER BY created_at DESC LIMIT 1"
|
|
3273
|
+
: isSkillsOp(op)
|
|
3198
3274
|
? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('skills_list', 'skill_read') ORDER BY created_at DESC LIMIT 1"
|
|
3199
3275
|
: "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('read', 'write') ORDER BY created_at DESC LIMIT 1",
|
|
3200
3276
|
nodeId,
|
|
@@ -3214,7 +3290,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3214
3290
|
const networkId = node.network_id || "default";
|
|
3215
3291
|
db.run(
|
|
3216
3292
|
`INSERT INTO node_rules_requests (request_id, node_id, network_id, op, content, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)`,
|
|
3217
|
-
[requestId, nodeId, networkId, op, op === "write" || op === "skill_read" ? a.content! : null, Date.now(), callerTokenId || "unknown"],
|
|
3293
|
+
[requestId, nodeId, networkId, op, op === "write" || op === "skill_read" || isFilesOp(op) ? a.content! : null, Date.now(), callerTokenId || "unknown"],
|
|
3218
3294
|
);
|
|
3219
3295
|
pushEvent(node.alias, { type: "rules_file", request_id: requestId }, networkId);
|
|
3220
3296
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, op }) }] };
|
|
@@ -3266,6 +3342,30 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3266
3342
|
async ({ node_id, child_node_id, alias, name, network_id }) => enqueueRulesFileRequest("skill_read", { node_id, child_node_id, alias, content: name, network_id }),
|
|
3267
3343
|
);
|
|
3268
3344
|
|
|
3345
|
+
server.tool(
|
|
3346
|
+
"list_node_files",
|
|
3347
|
+
"Ask a node to list one directory level of its work dir (project folder view, read-only). `path` is relative to the work dir (default: the root); absolute paths and .. are refused. Poll get_rules_file_result — content is JSON {path, entries:[{name,type,size?,mtime?,hidden_reason?,no_descend?}], truncated, total}. User logins only.",
|
|
3348
|
+
{
|
|
3349
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3350
|
+
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 files_capable."),
|
|
3351
|
+
path: z.string().max(NODE_FILE_PATH_MAX).optional().describe("Directory relative to the node's work dir; omit or \"\" for the root."),
|
|
3352
|
+
network_id: z.string().max(200).optional(),
|
|
3353
|
+
},
|
|
3354
|
+
async ({ node_id, child_node_id, alias, path: relPath, network_id }) => enqueueRulesFileRequest("files_list", { node_id, child_node_id, alias, content: relPath ?? "", network_id }),
|
|
3355
|
+
);
|
|
3356
|
+
|
|
3357
|
+
server.tool(
|
|
3358
|
+
"read_node_file",
|
|
3359
|
+
"Ask a node for one text file under its work dir (project folder view, read-only, 256 KiB cap; binary / too large → size only; secret-looking files → name only). `path` is relative to the work dir. Poll get_rules_file_result — content is JSON {path, name, kind, size?, mtime?, content?, hidden_reason?}. User logins only.",
|
|
3360
|
+
{
|
|
3361
|
+
...NODE_ID_ALIAS_FIELDS,
|
|
3362
|
+
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 files_capable."),
|
|
3363
|
+
path: z.string().min(1).max(NODE_FILE_PATH_MAX).describe("File path relative to the node's work dir."),
|
|
3364
|
+
network_id: z.string().max(200).optional(),
|
|
3365
|
+
},
|
|
3366
|
+
async ({ node_id, child_node_id, alias, path: relPath, network_id }) => enqueueRulesFileRequest("file_read", { node_id, child_node_id, alias, content: relPath, network_id }),
|
|
3367
|
+
);
|
|
3368
|
+
|
|
3269
3369
|
server.tool(
|
|
3270
3370
|
"get_rules_file_request",
|
|
3271
3371
|
"Node pulls its oldest pending rules-file request (called from agent-node when the SSE rules_file doorbell arrives). app#225.",
|
|
@@ -3278,11 +3378,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3278
3378
|
if (!callerAlias) {
|
|
3279
3379
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
|
|
3280
3380
|
}
|
|
3281
|
-
const node =
|
|
3282
|
-
"SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
3283
|
-
callerAlias,
|
|
3284
|
-
enforceNetworkId,
|
|
3285
|
-
);
|
|
3381
|
+
const node = resolveCallerNode();
|
|
3286
3382
|
// app#225 follow-up — no `nodes` row: this is a session-only target
|
|
3287
3383
|
// (claude-code). Its queue key is `session:<alias>`, always paired with the
|
|
3288
3384
|
// caller's network so a same-named alias in another network never matches.
|
|
@@ -3307,7 +3403,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3307
3403
|
request: {
|
|
3308
3404
|
request_id: req.request_id,
|
|
3309
3405
|
op: req.op,
|
|
3310
|
-
...(req.op === "write" || req.op === "skill_read" ? { content: req.content ?? "" } : {}),
|
|
3406
|
+
...(req.op === "write" || req.op === "skill_read" || isFilesOp(req.op) ? { content: req.content ?? "" } : {}),
|
|
3311
3407
|
},
|
|
3312
3408
|
}),
|
|
3313
3409
|
}],
|
|
@@ -3323,7 +3419,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3323
3419
|
status: z.enum(["done", "failed"]),
|
|
3324
3420
|
file_name: z.string().max(64).optional(),
|
|
3325
3421
|
exists: z.boolean().optional(),
|
|
3326
|
-
|
|
3422
|
+
// Files results (JSON listing / a 256 KiB file, escaped) may exceed the rules
|
|
3423
|
+
// cap; the per-op cap is enforced below once the row's op is known.
|
|
3424
|
+
content: z.string().max(NODE_FILES_RESULT_MAX_CHARS).optional(),
|
|
3327
3425
|
error: z.string().max(2000).optional(),
|
|
3328
3426
|
},
|
|
3329
3427
|
async ({ request_id: requestId, status, file_name: fileName, exists, content, error: ackError }) => {
|
|
@@ -3333,14 +3431,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3333
3431
|
if (!callerAlias) {
|
|
3334
3432
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
|
|
3335
3433
|
}
|
|
3336
|
-
const node =
|
|
3337
|
-
"SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
3338
|
-
callerAlias,
|
|
3339
|
-
enforceNetworkId,
|
|
3340
|
-
);
|
|
3434
|
+
const node = resolveCallerNode();
|
|
3341
3435
|
const queueKey: string = node ? node.node_id : `session:${callerAlias}`;
|
|
3342
3436
|
const req = db.get<any>(
|
|
3343
|
-
"SELECT request_id, node_id, network_id, status FROM node_rules_requests WHERE request_id = ?1",
|
|
3437
|
+
"SELECT request_id, node_id, network_id, status, op FROM node_rules_requests WHERE request_id = ?1",
|
|
3344
3438
|
requestId,
|
|
3345
3439
|
);
|
|
3346
3440
|
// 跨租户闸:只能 ack 自己的请求;别人的当不存在处理。session 键另外核网络。
|
|
@@ -3350,6 +3444,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3350
3444
|
if (req.status === "done" || req.status === "failed" || req.status === "timeout") {
|
|
3351
3445
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "already_terminal", current_status: req.status }) }] };
|
|
3352
3446
|
}
|
|
3447
|
+
if (typeof content === "string" && !isFilesOp(req.op) && content.length > RULES_FILE_MAX_BYTES) {
|
|
3448
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "content_too_large", max: RULES_FILE_MAX_BYTES }) }] };
|
|
3449
|
+
}
|
|
3353
3450
|
db.run(
|
|
3354
3451
|
`UPDATE node_rules_requests SET status = ?1, acked_at = ?2, file_name = ?3, file_exists = ?4, result_content = ?5, error = ?6 WHERE request_id = ?7`,
|
|
3355
3452
|
[
|
|
@@ -3374,11 +3471,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
3374
3471
|
const effectiveNetId = getNetworkId(clientNetId);
|
|
3375
3472
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "read rules file result");
|
|
3376
3473
|
const row = db.get<any>(
|
|
3377
|
-
"SELECT request_id, node_id, network_id, op, status, file_name, file_exists, result_content, error, created_at, pulled_at, acked_at FROM node_rules_requests WHERE request_id = ?1",
|
|
3474
|
+
"SELECT request_id, node_id, network_id, op, status, file_name, file_exists, result_content, error, created_at, pulled_at, acked_at, created_by_token FROM node_rules_requests WHERE request_id = ?1",
|
|
3378
3475
|
requestId,
|
|
3379
3476
|
);
|
|
3380
3477
|
// SEC-1:结果行的网络必须等于调用方作用域,否则当不存在。
|
|
3381
|
-
|
|
3478
|
+
// Project folder results: only the (user) token that asked may read them.
|
|
3479
|
+
const foreignFilesRow = !!row && isFilesOp(row.op) && (callerTokenIsNetwork || row.created_by_token !== (callerTokenId || "unknown"));
|
|
3480
|
+
if (!row || row.network_id !== (effectiveNetId || "default") || foreignFilesRow) {
|
|
3382
3481
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found", request_id: requestId }) }] };
|
|
3383
3482
|
}
|
|
3384
3483
|
let status: string = row.status;
|