@sleep2agi/commhub-server 0.9.0-preview.91 → 0.9.0-preview.93
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/auth.ts +2 -2
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +358 -0
- package/src/departments.ts +30 -9
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/human-dm.ts +2 -2
- package/src/mcp-context-cost-http.test.ts +4 -2
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +242 -0
- package/src/requirements-status-participants-http.test.ts +5 -1
- package/src/requirements.ts +76 -4
- package/src/server.ts +263 -15
- package/src/task-access.ts +41 -3
- package/src/tool-audience-http.test.ts +195 -0
- package/src/tool-audience.ts +109 -0
- package/src/tools-list-json-schema.test.ts +2 -1
- package/src/tools.ts +68 -2
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// #478 —— tools/list 按调用者给(tool-audience.ts)。钉住:
|
|
2
|
+
// 1. 每个注册的工具都在 TOOL_AUDIENCE 里归了类(新工具忘了归类 → 红),表里也没有已经不存在的工具;
|
|
3
|
+
// 2. 兼容回放:对每种调用者(节点:正常 / 只读 / 受限 / enforce 开关;人:owner / member / viewer),把每个工具都真调一次 ——
|
|
4
|
+
// 今天调得通的(不是那几种「不看参数就拒」的错误),一定还在它的列表里(协议工具在 opt-in 的列表里);
|
|
5
|
+
// 列表里藏起来的,调用一定回那几种拒绝之一。tools/call 不受影响(只滤列表)。
|
|
6
|
+
// 3. 每种角色的 tools/list 字节数(打印 before / after),各有上限(#476 的总上限仍管「全部工具」)。
|
|
7
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/tool-audience-http.test.ts(PG 在 tests/test2123-hub-postgres-ladder 里注册)
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
9
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { tmpdir } from "os";
|
|
12
|
+
import { register, createNetworkTokenForNode } from "./auth.js";
|
|
13
|
+
import { db } from "./db.js";
|
|
14
|
+
import { TOOL_AUDIENCE } from "./tool-audience.js";
|
|
15
|
+
|
|
16
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-tool-audience-"));
|
|
17
|
+
let BASE = "";
|
|
18
|
+
let hub: any = null;
|
|
19
|
+
const PW = "ToolAudiencePassw0rd!x";
|
|
20
|
+
let NET = "";
|
|
21
|
+
const T: Record<string, string> = {};
|
|
22
|
+
|
|
23
|
+
/** 处理函数里「不看参数就拒」的错误码(用户令牌调节点工具 / 节点令牌调人的工具 / #487 的拒绝)。 */
|
|
24
|
+
const REFUSALS = new Set(["network_token_required", "node_token_required", "caller_not_a_daemon", "user_token_required", "node_token_cannot_browse_files", "node_token_cannot_read_logs", "node_permission_denied"]);
|
|
25
|
+
|
|
26
|
+
async function rpc(token: string, method: string, params: unknown, headers: Record<string, string> = {}) {
|
|
27
|
+
const res = await fetch(`${BASE}/mcp`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26", ...headers }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }) });
|
|
28
|
+
const raw = await res.text();
|
|
29
|
+
const line = raw.split("\n").filter(x => x.startsWith("data:")).at(-1);
|
|
30
|
+
return { json: line ? JSON.parse(line.slice(5)) : JSON.parse(raw), bytes: 0 };
|
|
31
|
+
}
|
|
32
|
+
async function listTools(token: string, all = false): Promise<{ names: string[]; bytes: number; tools: any[] }> {
|
|
33
|
+
const { json } = await rpc(token, "tools/list", {}, all ? { "X-Anet-Tools": "all" } : {});
|
|
34
|
+
const tools = json.result.tools as any[];
|
|
35
|
+
return { names: tools.map(t => t.name), bytes: Buffer.byteLength(JSON.stringify({ tools }), "utf8"), tools };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 参数:按 inputSchema 生成;名字认得的用真值;schema 有格式要求的给合法值(不给 → -32602,测试会让你补)。
|
|
39
|
+
const OVERRIDES: Record<string, Record<string, unknown>> = {
|
|
40
|
+
get_skill: { skill_id: "skill_probe" },
|
|
41
|
+
review_skill: { skill_id: "skill_probe" },
|
|
42
|
+
mark_tasks_runtime_submitted: { task_ids: ["task_probe"] },
|
|
43
|
+
mark_tasks_consumed: { task_ids: ["task_probe"] },
|
|
44
|
+
upsert_network_secret: { key: "PROBE_KEY" },
|
|
45
|
+
update_provider: { provider_id: "prov_probe" },
|
|
46
|
+
};
|
|
47
|
+
function fill(schema: any, key: string, ctx: Record<string, unknown>): unknown {
|
|
48
|
+
if (!schema) return "probe";
|
|
49
|
+
if (ctx[key] !== undefined) return ctx[key];
|
|
50
|
+
if (schema.enum) return schema.enum[0];
|
|
51
|
+
if (schema.anyOf) return fill(schema.anyOf.find((s: any) => s.type !== "null") ?? schema.anyOf[0], key, ctx);
|
|
52
|
+
const t = Array.isArray(schema.type) ? schema.type.find((x: string) => x !== "null") : schema.type;
|
|
53
|
+
if (t === "string") return schema.minLength && schema.minLength > 5 ? "p".repeat(schema.minLength) : "probe";
|
|
54
|
+
if (t === "integer" || t === "number") return schema.minimum ?? 1;
|
|
55
|
+
if (t === "boolean") return false;
|
|
56
|
+
if (t === "array") return [];
|
|
57
|
+
if (t === "object") { const o: Record<string, unknown> = {}; for (const k of schema.required ?? []) o[k] = fill(schema.properties?.[k], k, ctx); return o; }
|
|
58
|
+
return "probe";
|
|
59
|
+
}
|
|
60
|
+
let ALL_TOOLS: any[] = [];
|
|
61
|
+
const argsFor = (tool: any) => ({ ...(fill(tool.inputSchema, "", { network_id: NET, alias: "probe-victim", new_alias: "probe-victim", node_id: "node_ta_victim" }) as object), ...(OVERRIDES[tool.name] ?? {}) });
|
|
62
|
+
async function callOutcome(token: string, tool: any): Promise<{ refused: boolean; schema: boolean; code: string }> {
|
|
63
|
+
const { json } = await rpc(token, "tools/call", { name: tool.name, arguments: argsFor(tool) });
|
|
64
|
+
if (json.error) return { refused: false, schema: json.error.code === -32602, code: `rpc:${json.error.code}` };
|
|
65
|
+
const text = String(json.result?.content?.[0]?.text ?? "");
|
|
66
|
+
if (/^MCP error -32602/.test(text)) return { refused: false, schema: true, code: "schema" };
|
|
67
|
+
let body: any = null;
|
|
68
|
+
try { body = JSON.parse(text); } catch {}
|
|
69
|
+
const code = body && body.ok === false ? String(body.error) : "ok";
|
|
70
|
+
// 受限成员的闸(agent_access_restricted)跑在处理函数前面,看传的 network_id;对节点 / 协议工具,处理函数后面还有一道
|
|
71
|
+
// 不看参数的 network_token_required(用户令牌换哪个网络都过不去),所以这时它也算拒。对「两边都能用」的工具不算。
|
|
72
|
+
const masked = code === "agent_access_restricted" && (TOOL_AUDIENCE[tool.name] === "node" || TOOL_AUDIENCE[tool.name] === "protocol");
|
|
73
|
+
return { refused: REFUSALS.has(code) || masked, schema: false, code };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
beforeAll(async () => {
|
|
77
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
78
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
79
|
+
process.env.HOST = "127.0.0.1";
|
|
80
|
+
delete process.env.COMMHUB_NODE_PERMISSIONS;
|
|
81
|
+
const owner = register(`ta_owner_${Date.now()}`, PW, undefined, "Owner");
|
|
82
|
+
NET = owner.network_id!;
|
|
83
|
+
T.owner = owner.token!;
|
|
84
|
+
const mod: any = await import("./server.js");
|
|
85
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
86
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
87
|
+
const mk = async (k: string, role: string) => {
|
|
88
|
+
const username = `ta_${k}_${Date.now()}`;
|
|
89
|
+
await fetch(`${BASE}/api/admin/users`, { method: "POST", headers: { Authorization: `Bearer ${T.owner}`, "Content-Type": "application/json" }, body: JSON.stringify({ username, password: PW, network_id: NET, role }) });
|
|
90
|
+
T[k] = (await (await fetch(`${BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password: PW }) })).json()).token;
|
|
91
|
+
};
|
|
92
|
+
await mk("member", "member");
|
|
93
|
+
await mk("viewer", "viewer");
|
|
94
|
+
for (const mode of ["normal", "readonly", "restricted"]) {
|
|
95
|
+
T[`node_${mode}`] = createNetworkTokenForNode(owner.user!.user_id, NET, `ta-${mode}`, `node_ta_${mode}`).token!;
|
|
96
|
+
db.run("UPDATE nodes SET permission_mode = ?1 WHERE node_id = ?2", [mode, `node_ta_${mode}`]);
|
|
97
|
+
}
|
|
98
|
+
createNetworkTokenForNode(owner.user!.user_id, NET, "probe-victim", "node_ta_victim");
|
|
99
|
+
// 没有身份的调用者 = 不过滤:用 owner 的「全部」作为全集(owner 是用户令牌,协议工具对它不列;全集取并集)。
|
|
100
|
+
const a = await listTools(T.node_normal, true);
|
|
101
|
+
const b = await listTools(T.owner);
|
|
102
|
+
const byName = new Map([...a.tools, ...b.tools].map(t => [t.name, t]));
|
|
103
|
+
ALL_TOOLS = [...byName.values()];
|
|
104
|
+
}, 60_000);
|
|
105
|
+
|
|
106
|
+
afterAll(() => {
|
|
107
|
+
delete process.env.COMMHUB_NODE_PERMISSIONS;
|
|
108
|
+
try { hub?.stop?.(true); } catch {}
|
|
109
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("every tool is classified", () => {
|
|
113
|
+
test("registry == TOOL_AUDIENCE (no unclassified tool, no stale entry)", async () => {
|
|
114
|
+
const registered = ALL_TOOLS.map(t => t.name).sort();
|
|
115
|
+
expect(registered.length).toBeGreaterThan(70);
|
|
116
|
+
expect(registered.filter(n => !TOOL_AUDIENCE[n])).toEqual([]);
|
|
117
|
+
expect(Object.keys(TOOL_AUDIENCE).filter(n => !registered.includes(n))).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("compat replay: nothing callable today disappears from the list", () => {
|
|
122
|
+
const cases: Array<[string, string, string | null]> = [
|
|
123
|
+
["node (normal, log)", "node_normal", null], ["node (readonly)", "node_readonly", null], ["node (restricted)", "node_restricted", null],
|
|
124
|
+
["node (normal, enforce)", "node_normal", "enforce"], ["owner", "owner", null], ["member", "member", null], ["viewer", "viewer", null],
|
|
125
|
+
];
|
|
126
|
+
for (const [label, key, flag] of cases) {
|
|
127
|
+
test(label, async () => {
|
|
128
|
+
if (flag) process.env.COMMHUB_NODE_PERMISSIONS = flag; else delete process.env.COMMHUB_NODE_PERMISSIONS;
|
|
129
|
+
try {
|
|
130
|
+
const isNode = key.startsWith("node_");
|
|
131
|
+
const listed = new Set((await listTools(T[key], isNode)).names);
|
|
132
|
+
// 「调得通 ⇒ 列着」,等价于「藏起来的 ⇒ 一定是那几种拒绝」。
|
|
133
|
+
const lost: string[] = [], schema: string[] = [];
|
|
134
|
+
for (const tool of ALL_TOOLS) {
|
|
135
|
+
const out = await callOutcome(T[key], tool);
|
|
136
|
+
if (out.schema) schema.push(tool.name);
|
|
137
|
+
if (!out.refused && !listed.has(tool.name)) lost.push(`${tool.name}(${out.code})`);
|
|
138
|
+
}
|
|
139
|
+
expect(schema).toEqual([]); // 每个工具都真跑到了处理函数(否则判不出能不能用)
|
|
140
|
+
expect(lost).toEqual([]);
|
|
141
|
+
} finally { delete process.env.COMMHUB_NODE_PERMISSIONS; }
|
|
142
|
+
}, 120_000);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("what each caller sees", () => {
|
|
147
|
+
test("node: no human-only tools; protocol tools only with X-Anet-Tools: all; user: no node / protocol tools", async () => {
|
|
148
|
+
const node = new Set((await listTools(T.node_normal)).names);
|
|
149
|
+
const nodeAll = new Set((await listTools(T.node_normal, true)).names);
|
|
150
|
+
const user = new Set((await listTools(T.owner)).names);
|
|
151
|
+
for (const [name, a] of Object.entries(TOOL_AUDIENCE)) {
|
|
152
|
+
expect([name, node.has(name)]).toEqual([name, a === "both" || a === "node"]);
|
|
153
|
+
expect([name, nodeAll.has(name)]).toEqual([name, a !== "user"]);
|
|
154
|
+
expect([name, user.has(name)]).toEqual([name, a === "both" || a === "user"]);
|
|
155
|
+
}
|
|
156
|
+
// ?tools=all is the same switch for clients that cannot set headers
|
|
157
|
+
const viaQuery = await fetch(`${BASE}/mcp?tools=all`, { method: "POST", headers: { Authorization: `Bearer ${T.node_normal}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }) });
|
|
158
|
+
const raw = await viaQuery.text();
|
|
159
|
+
const parsed = JSON.parse(raw.split("\n").filter(x => x.startsWith("data:")).at(-1)!.slice(5));
|
|
160
|
+
expect(parsed.result.tools.length).toBe(nodeAll.size);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("node modes: readonly / restricted drop broadcast + node writes + human-only; enforce drops human-only for normal nodes", async () => {
|
|
164
|
+
const ro = new Set((await listTools(T.node_readonly)).names);
|
|
165
|
+
for (const n of ["broadcast", "update_node_config", "create_node", "upsert_provider"]) expect([n, ro.has(n)]).toEqual([n, false]);
|
|
166
|
+
for (const n of ["send_task", "requirements_update", "report_status", "get_all_status"]) expect([n, ro.has(n)]).toEqual([n, true]); // 看参数才拒的照列
|
|
167
|
+
expect(new Set((await listTools(T.node_normal)).names).has("upsert_provider")).toBe(true); // log 模式:调得通,照列
|
|
168
|
+
process.env.COMMHUB_NODE_PERMISSIONS = "enforce";
|
|
169
|
+
try { expect(new Set((await listTools(T.node_normal)).names).has("upsert_provider")).toBe(false); } finally { delete process.env.COMMHUB_NODE_PERMISSIONS; }
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("a hidden tool still answers tools/call exactly as before (only the list changes)", async () => {
|
|
173
|
+
const p = await rpc(T.node_normal, "tools/call", { name: "projects_create", arguments: { network_id: NET, name: "示例项目" } });
|
|
174
|
+
expect(JSON.parse(p.json.result.content[0].text).error).toBe("user_token_required");
|
|
175
|
+
const r = await rpc(T.owner, "tools/call", { name: "report_status", arguments: argsFor(ALL_TOOLS.find(t => t.name === "report_status")) });
|
|
176
|
+
expect(JSON.parse(r.json.result.content[0].text).error).toBe("network_token_required");
|
|
177
|
+
const g = await rpc(T.node_normal, "tools/call", { name: "get_config_update", arguments: {} });
|
|
178
|
+
expect(JSON.parse(g.json.result.content[0].text).ok).toBe(true); // 协议工具默认不列,但照常能调
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("bytes per role (#476 ceilings stay meaningful per role)", () => {
|
|
183
|
+
// 全部工具(= 改动前每个调用者拿到的)71–72 KB;改动后按角色。改数字时在 PR 里写为什么。
|
|
184
|
+
// 量出来(#478 合入时):全部 74 个工具 71,559 B;节点 52 个 57,632 B(−19%);人 56 个 54,780 B(−23%)。留 ~1.5 KB 余量。
|
|
185
|
+
const CEILINGS = { node: 59_000, user: 56_500, all: 72_500 } as const;
|
|
186
|
+
test("node / user / all", async () => {
|
|
187
|
+
const node = await listTools(T.node_normal);
|
|
188
|
+
const user = await listTools(T.owner);
|
|
189
|
+
const all = { bytes: Buffer.byteLength(JSON.stringify({ tools: ALL_TOOLS }), "utf8"), names: ALL_TOOLS.map(t => t.name) };
|
|
190
|
+
console.log(`tools/list bytes — all ${all.names.length} tools ${all.bytes} B; node ${node.names.length} tools ${node.bytes} B (−${Math.round(100 - node.bytes / all.bytes * 100)}%); user ${user.names.length} tools ${user.bytes} B (−${Math.round(100 - user.bytes / all.bytes * 100)}%)`);
|
|
191
|
+
expect(node.bytes).toBeLessThanOrEqual(CEILINGS.node);
|
|
192
|
+
expect(user.bytes).toBeLessThanOrEqual(CEILINGS.user);
|
|
193
|
+
expect(all.bytes).toBeLessThanOrEqual(CEILINGS.all);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// #478 —— tools/list 按调用者给:只列这个令牌真能用的工具。
|
|
2
|
+
//
|
|
3
|
+
// 以前每个调用者拿到同一份 ~71 KB 的 tools/list:Agent 节点看见只有人能用的工具(项目管理、浏览别的节点的文件 / 日志),
|
|
4
|
+
// 人看见只有节点能用的心跳和 agent-node / daemon 之间的协议工具。这些调用本来就会被拒,只是白白占模型的上下文。
|
|
5
|
+
//
|
|
6
|
+
// 🔴 只改「列出来的」,不改「能不能调」:工具照旧全部注册,tools/call 的结果逐字节不变(隐藏的工具照样回它今天回的那个错误)。
|
|
7
|
+
// 🔴 只藏「一定会被拒」的:判据是处理函数里**不看参数**的那道闸 —— 用户令牌调节点工具一律 network_token_required /
|
|
8
|
+
// node_token_required / caller_not_a_daemon;节点令牌调人的工具一律 user_token_required / node_token_cannot_*;
|
|
9
|
+
// #487 的节点权限只在「不看参数就必拒」时才藏(见 nodeHidden)。看参数才决定的(派活给谁、哪个网络)一律照列。
|
|
10
|
+
// 受限成员(只看授权 Agent)的拒绝取决于传的 network_id,所以不按它藏。
|
|
11
|
+
// 🔴 protocol 类(agent-node / anet daemon 和 Hub 之间的拉取 / 确认)节点令牌能调,但没有模型该调它们:默认不列,
|
|
12
|
+
// 客户端带 `X-Anet-Tools: all`(或 /mcp?tools=all)时照列 —— 调用不受影响,agent-node 本来就按名字直接调、从不 list。
|
|
13
|
+
// 新注册的工具必须在 TOOL_AUDIENCE 里归类(tool-audience-http.test.ts 钉住,漏了就红)。
|
|
14
|
+
|
|
15
|
+
import { NODE_TOOL_CLASS, nodeIdentity, nodePermissionsFlag, broadcastVerdict, humanOnlyVerdict, writeVerdict, type Verdict } from "./node-permissions.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* both —— 人和节点都能调(按参数 / 网络再判的,也算这一类)。
|
|
19
|
+
* node —— 只有节点令牌(用户令牌一律 network_token_required):节点自己的心跳,模型会调,照列给节点。
|
|
20
|
+
* protocol —— 只有节点令牌,而且是 agent-node / daemon 和 Hub 之间的协议(拉请求、确认、运行证据):默认不列,opt-in 才列。
|
|
21
|
+
* user —— 只有人(节点令牌一律被拒)。
|
|
22
|
+
*/
|
|
23
|
+
export type ToolAudience = "both" | "node" | "protocol" | "user";
|
|
24
|
+
|
|
25
|
+
export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
|
|
26
|
+
// ── 节点:用户令牌一律 network_token_required ──
|
|
27
|
+
report_status: "node",
|
|
28
|
+
// ── 协议:用户令牌一律 network_token_required / node_token_required / caller_not_a_daemon ──
|
|
29
|
+
mark_tasks_runtime_submitted: "protocol", mark_tasks_consumed: "protocol",
|
|
30
|
+
get_config_update: "protocol", ack_config_update: "protocol",
|
|
31
|
+
get_rules_file_request: "protocol", ack_rules_file_request: "protocol",
|
|
32
|
+
list_my_pending_create_requests: "protocol", list_my_pending_lifecycle_requests: "protocol", list_my_children: "protocol",
|
|
33
|
+
get_create_request: "protocol", ack_create_request: "protocol",
|
|
34
|
+
get_stop_request: "protocol", ack_stop_request: "protocol",
|
|
35
|
+
get_start_request: "protocol", ack_start_request: "protocol",
|
|
36
|
+
get_probe_request: "protocol", ack_probe_request: "protocol",
|
|
37
|
+
// ── 人:节点令牌一律 user_token_required(项目管理)/ node_token_cannot_browse_files / node_token_cannot_read_logs ──
|
|
38
|
+
projects_create: "user", projects_update: "user",
|
|
39
|
+
list_node_files: "user", read_node_file: "user", tail_node_logs: "user",
|
|
40
|
+
// ── 两边都能调 ──
|
|
41
|
+
submit_skill: "both", list_skills: "both", get_skill: "both", review_skill: "both",
|
|
42
|
+
report_completion: "both", get_inbox: "both", ack_inbox: "both",
|
|
43
|
+
get_all_status: "both", get_session_status: "both",
|
|
44
|
+
send_task: "both", send_message: "both", send_reply: "both", send_peer_reply: "both", send_ack: "both",
|
|
45
|
+
retry_task: "both", get_task: "both", list_tasks: "both", cancel_task: "both", reassign_task: "both",
|
|
46
|
+
send_desktop_message: "both", broadcast: "both", get_completions: "both",
|
|
47
|
+
update_node_config: "both", read_node_rules_file: "both", write_node_rules_file: "both",
|
|
48
|
+
list_node_skills: "both", read_node_skill: "both", get_rules_file_result: "both",
|
|
49
|
+
restart_node: "both", list_host_supervisors: "both", create_node: "both", stop_node: "both", delete_node: "both", start_node: "both",
|
|
50
|
+
upsert_network_secret: "both", list_network_secrets: "both", upsert_provider: "both", update_provider: "both",
|
|
51
|
+
list_providers: "both", probe_provider_model: "both", get_probe_results: "both",
|
|
52
|
+
requirements_list: "both", requirements_people: "both", requirements_get: "both", requirements_create: "both",
|
|
53
|
+
requirements_update: "both", requirements_checklist_toggle: "both", requirements_upsert_by_external_ref: "both",
|
|
54
|
+
requirements_events: "both", requirements_comment: "both", projects_list: "both",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** 调用者:节点令牌(绑定网络)/ 用户令牌 / 没有身份(旧的全局令牌、未开鉴权)—— 最后一种什么都不藏。 */
|
|
58
|
+
export type ToolCaller =
|
|
59
|
+
| { kind: "node"; tokenId: string | null; networkId: string | null; includeProtocol: boolean }
|
|
60
|
+
| { kind: "user"; includeProtocol: boolean }
|
|
61
|
+
| { kind: "unknown" };
|
|
62
|
+
|
|
63
|
+
/** #487 的判定里「不看参数就必拒」的那几类;和 nodeDecide 同一规则(显式模式恒拒,其余看开关),只是不记日志。 */
|
|
64
|
+
function nodeHidden(name: string, verdictFor: (cls: string) => Verdict): boolean {
|
|
65
|
+
const cls = NODE_TOOL_CLASS[name];
|
|
66
|
+
if (cls !== "broadcast" && cls !== "node_write" && cls !== "human_only") return false;
|
|
67
|
+
const v = verdictFor(cls);
|
|
68
|
+
if (!v) return false;
|
|
69
|
+
if (v.reason === "mode_readonly" || v.reason === "mode_restricted_not_assigned") return true;
|
|
70
|
+
return nodePermissionsFlag() === "enforce";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 这个调用者的 tools/list 里留哪些。没归类的工具一律留下(宁可多列;测试保证不会有没归类的)。 */
|
|
74
|
+
export function listedToolFilter(caller: ToolCaller): (name: string) => boolean {
|
|
75
|
+
if (caller.kind === "unknown") return () => true;
|
|
76
|
+
if (caller.kind === "user") return name => { const a = TOOL_AUDIENCE[name]; return a !== "node" && a !== "protocol"; };
|
|
77
|
+
const id = caller.tokenId && caller.networkId ? nodeIdentity(caller.tokenId, caller.networkId) : null;
|
|
78
|
+
const verdictFor = (cls: string): Verdict => {
|
|
79
|
+
if (!id) return null;
|
|
80
|
+
if (cls === "broadcast") return broadcastVerdict(id);
|
|
81
|
+
if (cls === "node_write") return writeVerdict(id);
|
|
82
|
+
return humanOnlyVerdict(id);
|
|
83
|
+
};
|
|
84
|
+
return name => {
|
|
85
|
+
const a = TOOL_AUDIENCE[name];
|
|
86
|
+
if (a === "user") return false;
|
|
87
|
+
if (a === "protocol" && !caller.includeProtocol) return false;
|
|
88
|
+
return !nodeHidden(name, verdictFor);
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 给一个已经注册完工具的 McpServer 换上按调用者过滤的 tools/list。只包 tools/list 的处理函数(先跑 SDK 原来的、
|
|
94
|
+
* 再滤掉不列的),tools/call 不碰。SDK 的处理函数表是内部字段:取不到就不滤(宁可多列),测试会红。
|
|
95
|
+
*/
|
|
96
|
+
export function scopeToolsList(server: unknown, keep: (name: string) => boolean): void {
|
|
97
|
+
const handlers = (server as { server?: { _requestHandlers?: Map<string, (req: unknown, extra: unknown) => unknown> } }).server?._requestHandlers;
|
|
98
|
+
const inner = handlers?.get("tools/list");
|
|
99
|
+
if (!handlers || !inner) return;
|
|
100
|
+
handlers.set("tools/list", async (req: unknown, extra: unknown) => {
|
|
101
|
+
const result = await inner(req, extra) as { tools?: Array<{ name: string }> };
|
|
102
|
+
return Array.isArray(result?.tools) ? { ...result, tools: result.tools.filter(t => keep(t.name)) } : result;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 客户端要不要看协议工具:`X-Anet-Tools: all` 或 /mcp?tools=all。 */
|
|
107
|
+
export function wantsAllTools(req: Request, url: URL): boolean {
|
|
108
|
+
return (req.headers.get("x-anet-tools") ?? "").trim().toLowerCase() === "all" || url.searchParams.get("tools") === "all";
|
|
109
|
+
}
|
|
@@ -19,7 +19,8 @@ import { registerTools } from "./tools.js";
|
|
|
19
19
|
|
|
20
20
|
async function connect() {
|
|
21
21
|
const server = new McpServer({ name: "list-tools-test", version: "1" });
|
|
22
|
-
|
|
22
|
+
// 没有用户 / 节点身份的调用者 = tools/list 不按角色过滤(#478),这里要的正是「每个登记的工具」。
|
|
23
|
+
registerTools(server, undefined, "net_list_tools", null, "u_list_tools", false, "tok_list_tools");
|
|
23
24
|
const client = new Client({ name: "list-tools-client", version: "1" });
|
|
24
25
|
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
25
26
|
await server.connect(st);
|
package/src/tools.ts
CHANGED
|
@@ -12,7 +12,9 @@ import { assertNodeActive } from "./lifecycle-guard.js";
|
|
|
12
12
|
import { pendingInboxCount } from "./inbox-count.js";
|
|
13
13
|
import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
|
|
14
14
|
import { addAgentNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
15
|
-
import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS } from "./agent-access.js";
|
|
15
|
+
import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS, type AgentRef } from "./agent-access.js";
|
|
16
|
+
import { listedToolFilter, scopeToolsList, type ToolCaller } from "./tool-audience.js";
|
|
17
|
+
import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_TOOL_CLASS, nodeDecide, nodeIdentity, nodePermissionDeniedBody, writeVerdict, type NodeIdentity, type Verdict } from "./node-permissions.js";
|
|
16
18
|
import { restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
17
19
|
import { errorBody } from "./requirements-errors.js";
|
|
18
20
|
import { handleRequirementsRequest } from "./requirements.js";
|
|
@@ -200,11 +202,69 @@ function guardRestrictedMemberTools(server: McpServer, restrictedNets: string[],
|
|
|
200
202
|
}
|
|
201
203
|
}
|
|
202
204
|
|
|
203
|
-
|
|
205
|
+
// RFC-041 第一阶段(#487):节点令牌的每个 MCP 工具先按 NODE_TOOL_CLASS 判一次(node-permissions.ts)。
|
|
206
|
+
// log 模式下正常节点照常放行、只记录;显式的只读 / 受限模式立刻生效。requirements_* 在 requirements.ts 里按卡判。
|
|
207
|
+
function guardNodePermissionTools(server: McpServer, tokenId: string, networkId: string): void {
|
|
208
|
+
const denied = (reason: Parameters<typeof nodePermissionDeniedBody>[0], route: string) => ({
|
|
209
|
+
content: [{ type: "text" as const, text: JSON.stringify(nodePermissionDeniedBody(reason, route)) }],
|
|
210
|
+
});
|
|
211
|
+
const taskTarget = (taskId: unknown): { to: AgentRef; from: string | null } | null => {
|
|
212
|
+
if (typeof taskId !== "string" || !taskId) return null;
|
|
213
|
+
const t = db.get<{ to_name: string | null; to_node_id: string | null; from_name: string | null }>(
|
|
214
|
+
"SELECT to_name, to_node_id, from_name FROM tasks WHERE task_id = ?1 AND network_id = ?2", taskId, networkId);
|
|
215
|
+
return t ? { to: { alias: t.to_name, nodeId: t.to_node_id }, from: t.from_name } : null;
|
|
216
|
+
};
|
|
217
|
+
const aliasTarget = (alias: unknown): AgentRef | null => {
|
|
218
|
+
if (typeof alias !== "string" || !alias.trim()) return null;
|
|
219
|
+
const canonical = resolveCanonicalAlias(networkId, alias.trim()).alias;
|
|
220
|
+
const session = db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", canonical, networkId);
|
|
221
|
+
return { alias: canonical, nodeId: session?.node_id ?? null };
|
|
222
|
+
};
|
|
223
|
+
const verdictFor = (id: NodeIdentity, name: string, args: Record<string, unknown>): Verdict => {
|
|
224
|
+
switch (NODE_TOOL_CLASS[name]) {
|
|
225
|
+
case "dispatch": {
|
|
226
|
+
if (name === "retry_task" || name === "cancel_task") {
|
|
227
|
+
const t = taskTarget(args.task_id);
|
|
228
|
+
if (!t) return null; // 没这个任务:交给工具自己回 not found
|
|
229
|
+
if (name === "cancel_task" && t.from && id.aliases.includes(t.from)) return null; // 撤回自己派的
|
|
230
|
+
return dispatchVerdict(id, t.to);
|
|
231
|
+
}
|
|
232
|
+
const target = aliasTarget(name === "reassign_task" ? args.new_alias : args.alias);
|
|
233
|
+
return target ? dispatchVerdict(id, target) : null;
|
|
234
|
+
}
|
|
235
|
+
case "broadcast": return broadcastVerdict(id);
|
|
236
|
+
case "node_write": return writeVerdict(id, name);
|
|
237
|
+
case "human_only": return humanOnlyVerdict(id, name);
|
|
238
|
+
default: return null; // always / read / requirements(按卡判)/ 没归类的(test 保证没有)
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
const wrap = (name: string, handler: (...callArgs: any[]) => any) => async (...callArgs: any[]) => {
|
|
242
|
+
const cls = NODE_TOOL_CLASS[name];
|
|
243
|
+
if (cls && cls !== "always" && cls !== "read" && cls !== "requirements") {
|
|
244
|
+
const id = nodeIdentity(tokenId, networkId);
|
|
245
|
+
const args = callArgs[0] && typeof callArgs[0] === "object" ? callArgs[0] as Record<string, unknown> : {};
|
|
246
|
+
const verdict = id ? verdictFor(id, name, args) : null;
|
|
247
|
+
if (id && nodeDecide(id, `mcp:${name}`, verdict)) return denied(verdict!.reason, `mcp:${name}`);
|
|
248
|
+
}
|
|
249
|
+
return handler(...callArgs);
|
|
250
|
+
};
|
|
251
|
+
const anyServer = server as any;
|
|
252
|
+
for (const method of ["tool", "registerTool"] as const) {
|
|
253
|
+
const original = anyServer[method].bind(server);
|
|
254
|
+
anyServer[method] = (...regArgs: any[]) => {
|
|
255
|
+
const last = regArgs.length - 1;
|
|
256
|
+
if (typeof regArgs[0] === "string" && typeof regArgs[last] === "function") regArgs[last] = wrap(regArgs[0], regArgs[last]);
|
|
257
|
+
return original(...regArgs);
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null, listing: { includeProtocol?: boolean } = {}) {
|
|
204
263
|
// 多用户 Agent 权限:用户令牌调用者在哪些网络里是受限成员(只看授权 Agent)。
|
|
205
264
|
// 网络令牌不会走到这里 —— 受限成员的网络令牌在 resolveToken 就被拒了。
|
|
206
265
|
const restrictedNets = enforceUserId && !callerTokenIsNetwork && !enforceNetworkId ? restrictedNetworkIds(enforceUserId) : [];
|
|
207
266
|
if (restrictedNets.length) guardRestrictedMemberTools(server, restrictedNets, enforceUserId!);
|
|
267
|
+
if (callerTokenIsNetwork && callerTokenId && enforceNetworkId) guardNodePermissionTools(server, callerTokenId, enforceNetworkId);
|
|
208
268
|
// Default from_session for outbound tools — extracted from the calling
|
|
209
269
|
// token's binding (ntok_ → node alias, utok_ → username). Without this,
|
|
210
270
|
// an agent's send_task call always claimed from='hub' and peer agents
|
|
@@ -6074,6 +6134,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
6074
6134
|
},
|
|
6075
6135
|
async ({ id, text, network_id }) => requirementsCall("POST", `/api/requirements/${encodeURIComponent(id)}/comments`, network_id, { text }),
|
|
6076
6136
|
);
|
|
6137
|
+
|
|
6138
|
+
// #478:tools/list 只列这个调用者真能用的工具(tool-audience.ts)。只滤列表,tools/call 不变。
|
|
6139
|
+
const caller: ToolCaller = callerTokenIsNetwork
|
|
6140
|
+
? { kind: "node", tokenId: callerTokenId ?? null, networkId: enforceNetworkId ?? null, includeProtocol: listing.includeProtocol === true }
|
|
6141
|
+
: enforceUserId ? { kind: "user", includeProtocol: listing.includeProtocol === true } : { kind: "unknown" };
|
|
6142
|
+
scopeToolsList(server, listedToolFilter(caller));
|
|
6077
6143
|
}
|
|
6078
6144
|
|
|
6079
6145
|
// ────────────────────────────────────────────────────────────────────
|