@sleep2agi/commhub-server 0.9.0-preview.92 → 0.9.0-preview.94
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 +3 -3
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/departments.ts +31 -10
- package/src/group-membership-events-http.test.ts +264 -0
- 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-identity-mismatch.test.ts +74 -0
- package/src/node-permissions-http.test.ts +18 -0
- package/src/requirement-due-reminders-http.test.ts +390 -0
- package/src/requirement-due-reminders.ts +310 -0
- package/src/requirements-errors.ts +2 -0
- package/src/requirements-status-participants-http.test.ts +5 -1
- package/src/requirements.ts +16 -1
- package/src/server.ts +171 -10
- 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 +36 -6
package/src/server.ts
CHANGED
|
@@ -3,8 +3,10 @@ import { markGzipReusable, maybeGzipResponse, trimLightTask } from "./http-gzip"
|
|
|
3
3
|
import { ifNoneMatchHits, memoStatusBody } from "./status-read-cache";
|
|
4
4
|
import { statusAliasResolverRead } from "./status-alias-resolver.js";
|
|
5
5
|
import { readNodeHealth } from "./node-health-store.js";
|
|
6
|
+
import { wantsAllTools } from "./tool-audience.js";
|
|
6
7
|
import { assertNodeHealthy, degradedLayers } from "./node-health-guard.js";
|
|
7
8
|
import { auditDepartment, createDepartment, deleteDepartment, listDepartments, setMemberDepartment, updateDepartment } from "./departments.js";
|
|
9
|
+
import { addManualMember, canManageGroup, createDepartmentGroup, getDepartmentGroup, groupById, groupTx, groupViewerCan, isGroupMember, listGroupMembers, listVisibleGroups, readGroup, removeManualMember, renameGroup, syncDepartmentGroups } from "./department-groups.js";
|
|
8
10
|
import { DEPARTMENT_SCOPE_DENIED, departmentLeaders, departmentSubtree, headScope, listDepartmentProjectGrants, managedDepartmentIds, membersIn, replaceDepartmentProjectGrants } from "./department-heads.js";
|
|
9
11
|
import { redactMessageRow } from "./redact-tokens.js";
|
|
10
12
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -22,6 +24,7 @@ import { pendingInboxCount } from "./inbox-count.js";
|
|
|
22
24
|
import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
23
25
|
import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
24
26
|
import { dmParticipantSeesFile, listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
|
|
27
|
+
import { groupMemberSeesFile, groupUnreadFor, listGroupMessages, listGroupThreads, markGroupRead, memberGroup, sendGroupMessage } from "./group-messages.js";
|
|
25
28
|
import { getTaskAccessMode, isTaskScoped, listProjectGrants, replaceTaskGrants, NEW_MEMBER_TASK_ACCESS } from "./task-access.js";
|
|
26
29
|
import { canMessageAgent, isAgentRestricted, listAgentGrants, listGroupGrants, getAgentAccessMode, replaceAgentGrants, restrictedNetworkIds, usernameIsAgentAlias, listAgentGroups, createAgentGroup, updateAgentGroup, replaceAgentGroupMembers, deleteAgentGroup, usersGrantedGroup } from "./agent-access.js";
|
|
27
30
|
import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_PERMISSION_MODES, nodeDecide, nodeIdentity, nodePermissionDeniedBody, nodePermissionsFlag, streamVerdict, writeVerdict, type NodeIdentity, type NodePermissionMode, type Verdict } from "./node-permissions.js";
|
|
@@ -67,6 +70,7 @@ import { resolveRestFromSession } from "./rest-identity.js";
|
|
|
67
70
|
import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
|
|
68
71
|
import { diagnoseTask } from "./task-diagnostic.js";
|
|
69
72
|
import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
|
|
73
|
+
import { startDueReminderTimer } from "./requirement-due-reminders.js";
|
|
70
74
|
import { handleRequirementsRequest } from "./requirements.js";
|
|
71
75
|
import { labelRoute, mcpRouteLabel, recordRouteTiming, routeStats, setRouteCallerMasterToken, setRouteCallerTokenReader } from "./route-timing.js";
|
|
72
76
|
import { handleExternalScheduleEditRequest } from "./external-schedule-edits.js";
|
|
@@ -124,7 +128,11 @@ const SERVER_VERSION = (() => {
|
|
|
124
128
|
})();
|
|
125
129
|
|
|
126
130
|
// Feature flags advertised on /health (see the /health handler). Exported for tests.
|
|
127
|
-
|
|
131
|
+
// node_permission_mode(#489):节点有 permission_mode、GET /api/nodes 每行带 viewer_can.permission_mode,
|
|
132
|
+
// PUT /api/nodes/:id/permission-mode 与 GET /api/networks/:id/node-permission-report 可用(RFC-041 第一阶段)。
|
|
133
|
+
// chat_groups(RFC-042,#457):部门群可用 —— …/departments/:dept/group、…/chat-groups(资料 / 成员 / 消息 / 已读)、
|
|
134
|
+
// GET /api/dm/threads 的 group_threads;群对象带 viewer_can {manage, post},会话行带 last_message 预览,成员带 username / display_name。
|
|
135
|
+
export const HUB_HEALTH_CAPABILITIES = ["status_node_id", "node_permission_mode", "chat_groups"] as const;
|
|
128
136
|
|
|
129
137
|
// In-memory log ring buffer — last N lines streamed via /api/server-logs.
|
|
130
138
|
// Wraps console.log/info/warn/error so EVERY existing log call lands here
|
|
@@ -180,12 +188,12 @@ function sweepStaleRateLimits(): void {
|
|
|
180
188
|
}
|
|
181
189
|
|
|
182
190
|
// ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
|
|
183
|
-
function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null): McpServer {
|
|
191
|
+
function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null, includeProtocolTools = false): McpServer {
|
|
184
192
|
const server = new McpServer({
|
|
185
193
|
name: "commhub",
|
|
186
194
|
version: "0.5.0",
|
|
187
195
|
});
|
|
188
|
-
registerTools(server, clientIP, enforceNetworkId, enforceUserId, callerAlias, callerTokenIsNetwork, callerTokenId);
|
|
196
|
+
registerTools(server, clientIP, enforceNetworkId, enforceUserId, callerAlias, callerTokenIsNetwork, callerTokenId, { includeProtocol: includeProtocolTools });
|
|
189
197
|
return server;
|
|
190
198
|
}
|
|
191
199
|
|
|
@@ -453,6 +461,10 @@ export function normalizeEntry(
|
|
|
453
461
|
// legacy master token still pass above, as for every other file.
|
|
454
462
|
// Files uploaded any other way never become DM-scoped, including when
|
|
455
463
|
// they are later forwarded into a DM.
|
|
464
|
+
// RFC-042 (#457, group messages): a DM-scoped file is also readable by
|
|
465
|
+
// the CURRENT members of a chat group with a message carrying it
|
|
466
|
+
// (group-messages.ts groupMemberSeesFile); the same widening applies to
|
|
467
|
+
// restricted members below. Removed members lose it immediately.
|
|
456
468
|
// - Everything else → deny (404). New uploads in production always
|
|
457
469
|
// carry a truthy owner_id (requireAuth blocks the null-owner-
|
|
458
470
|
// producing path when DEV_OPEN=off and no legacy master token is
|
|
@@ -480,12 +492,12 @@ export function authorizeFileDownload(
|
|
|
480
492
|
// 私信文件:只看是不是这段私信里的人,与受限 / 不受限无关。
|
|
481
493
|
if (entry.dmScoped) {
|
|
482
494
|
if (entry.ownerId !== null && entry.ownerId === principal.userId) return true;
|
|
483
|
-
return !!fileId && dmParticipantSeesFile(principal.userId, entry.networkId, fileId);
|
|
495
|
+
return !!fileId && (dmParticipantSeesFile(principal.userId, entry.networkId, fileId) || groupMemberSeesFile(principal.userId, entry.networkId, fileId));
|
|
484
496
|
}
|
|
485
497
|
// 多用户 Agent 权限:受限成员只能下载自己传的,或者出现在自己与授权 Agent 往来 / 自己收件箱里的文件。
|
|
486
498
|
if (!isAgentRestricted(principal.userId, entry.networkId)) return true;
|
|
487
499
|
if (entry.ownerId !== null && entry.ownerId === principal.userId) return true;
|
|
488
|
-
return !!fileId && restrictedMemberSeesFile(principal.userId, principal.username, entry.networkId, fileId);
|
|
500
|
+
return !!fileId && (restrictedMemberSeesFile(principal.userId, principal.username, entry.networkId, fileId) || groupMemberSeesFile(principal.userId, entry.networkId, fileId));
|
|
489
501
|
}
|
|
490
502
|
|
|
491
503
|
if (principal.kind === "ntok" || principal.kind === "utok") {
|
|
@@ -992,7 +1004,8 @@ return Bun.serve({
|
|
|
992
1004
|
}
|
|
993
1005
|
labelRoute(req, mcpParsedBody === undefined ? "?" : mcpRouteLabel(mcpParsedBody));
|
|
994
1006
|
}
|
|
995
|
-
|
|
1007
|
+
// #478:tools/list 按调用者过滤;X-Anet-Tools: all / ?tools=all 时节点令牌也列出协议工具。
|
|
1008
|
+
const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias, !!token?.startsWith("ntok_"), authCtx?.tokenId || null, wantsAllTools(req, url));
|
|
996
1009
|
await mcpServer.connect(transport);
|
|
997
1010
|
const response = await transport.handleRequest(req, mcpParsedBody === undefined ? undefined : { parsedBody: mcpParsedBody });
|
|
998
1011
|
// Disconnect after response to prevent McpServer leak
|
|
@@ -1749,7 +1762,7 @@ return Bun.serve({
|
|
|
1749
1762
|
// 在这棵子树里也能写;越出子树 → 403 department_scope_denied(固定一个错误)。不是负责人的成员与以前逐字节相同。 ──
|
|
1750
1763
|
const deptMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/departments(?:\/([^/]+))?$/);
|
|
1751
1764
|
const memberDeptMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members\/([^/]+)\/department$/);
|
|
1752
|
-
const deptSubMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/departments\/([^/]+)\/(project-grants|nodes)$/);
|
|
1765
|
+
const deptSubMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/departments\/([^/]+)\/(project-grants|nodes|group)$/);
|
|
1753
1766
|
if (deptMatch || memberDeptMatch || deptSubMatch) {
|
|
1754
1767
|
const token = requestToken(req, { allowQueryToken: false });
|
|
1755
1768
|
const resolved = token ? resolveToken(token) : null;
|
|
@@ -1797,6 +1810,35 @@ return Bun.serve({
|
|
|
1797
1810
|
});
|
|
1798
1811
|
return withCors(req, Response.json({ ok: true, network_id: netId, department_id: deptId, nodes }));
|
|
1799
1812
|
}
|
|
1813
|
+
if (deptSubMatch[3] === "group") {
|
|
1814
|
+
// RFC-042(#457)部门群:GET 看部门挂着的群,POST 建群。Agent(节点令牌)不进群,一律 403。
|
|
1815
|
+
// 建:owner / admin / Hub 管理员,或这个部门(含上级)的负责人。读:上面这些人,和群成员;别人 → 404(不暴露有没有群)。
|
|
1816
|
+
if (resolved.networkId) return withCors(req, Response.json({ ok: false, error: "humans_only" }, { status: 403 }));
|
|
1817
|
+
const mayCreate = canManage || head.managed.has(deptId);
|
|
1818
|
+
if (req.method === "GET") {
|
|
1819
|
+
const linked = getDepartmentGroup(netId, deptId);
|
|
1820
|
+
// 兜底对账(RFC-042 §4):读之前先把成员对齐,再判「是不是成员」。
|
|
1821
|
+
if (linked) groupTx(() => { syncDepartmentGroups(netId, linked.id); });
|
|
1822
|
+
const group = linked ? getDepartmentGroup(netId, deptId) : null;
|
|
1823
|
+
if (!group || (!mayCreate && !isGroupMember(group.id, resolved.user.user_id))) {
|
|
1824
|
+
return withCors(req, Response.json({ ok: false, error: "department_group_not_found" }, { status: 404 }));
|
|
1825
|
+
}
|
|
1826
|
+
const viewerCan = groupViewerCan(group, isGroupMember(group.id, resolved.user.user_id), canManage, head.managed);
|
|
1827
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, department_id: deptId, group: { ...group, viewer_can: viewerCan }, members: listGroupMembers(group.id) }));
|
|
1828
|
+
}
|
|
1829
|
+
if (req.method !== "POST") return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
1830
|
+
if (!mayCreate) return isHead ? scopeDenied() : withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1831
|
+
let groupBody: Record<string, unknown> = {};
|
|
1832
|
+
try { const t = await req.text(); const b = t ? JSON.parse(t) : {}; groupBody = b && typeof b === "object" && !Array.isArray(b) ? b as Record<string, unknown> : {}; } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
|
|
1833
|
+
const r = createDepartmentGroup(netId, deptId, resolved.user.user_id, groupBody);
|
|
1834
|
+
if (!r.ok) {
|
|
1835
|
+
const { ok: _ok, status, ...rest } = r;
|
|
1836
|
+
return withCors(req, Response.json({ ok: false, ...rest }, { status }));
|
|
1837
|
+
}
|
|
1838
|
+
auditDepartment(resolved.user, "department_group_created", netId, JSON.stringify({ id: deptId, group_id: r.group.id, members: r.members.length, ...(canManage ? {} : { via: "leader" }) }));
|
|
1839
|
+
const createdCan = groupViewerCan(r.group, r.members.some((m) => m.user_id === resolved.user.user_id), canManage, head.managed);
|
|
1840
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, department_id: deptId, group: { ...r.group, viewer_can: createdCan }, members: r.members }, { status: 201 }));
|
|
1841
|
+
}
|
|
1800
1842
|
// 部门项目授权:只有 owner / admin(和按人授权一样)。
|
|
1801
1843
|
if (!canManage) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1802
1844
|
if (req.method === "GET") {
|
|
@@ -1887,6 +1929,108 @@ return Bun.serve({
|
|
|
1887
1929
|
return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
1888
1930
|
}
|
|
1889
1931
|
|
|
1932
|
+
// ── RFC-042(#457):群(目前只有部门群)。
|
|
1933
|
+
// GET …/chat-groups = 我在里面的群(owner / admin / Hub 管理员看到全部);GET …/chat-groups/:gid = 群资料 + 成员
|
|
1934
|
+
// (群成员、owner / admin、该部门(含上级)负责人;别人 404)。建群走 POST …/departments/:dept/group。
|
|
1935
|
+
// 第二个 PR:PATCH …/chat-groups/:gid {name} 改群名;POST …/chat-groups/:gid/members {user_id} 手动拉人;
|
|
1936
|
+
// DELETE …/chat-groups/:gid/members/:uid 手动移人(source='department' 的人 → 409 department_member)。
|
|
1937
|
+
// 管群 = owner / admin / Hub 管理员,或挂部门的群的该部门(含上级)负责人。看得见但管不了 → 403 group_manage_denied;
|
|
1938
|
+
// 看不见 → 404 group_not_found(不暴露有没有群)。Agent(节点令牌)不进群,一律 403 humans_only。 ──
|
|
1939
|
+
const chatGroupsMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/chat-groups(?:\/([^/]+)(?:\/(members|messages|read)(?:\/([^/]+))?)?)?$/);
|
|
1940
|
+
if (chatGroupsMatch) {
|
|
1941
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1942
|
+
const resolved = token ? resolveToken(token) : null;
|
|
1943
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1944
|
+
if (resolved.networkId) return withCors(req, Response.json({ ok: false, error: "humans_only" }, { status: 403 }));
|
|
1945
|
+
const netId = decodeURIComponent(chatGroupsMatch[1]);
|
|
1946
|
+
const hubAdmin = isHubAdminCredential(resolved);
|
|
1947
|
+
const role = getUserNetworkRole(resolved.user.user_id, netId);
|
|
1948
|
+
if (!hubAdmin && !role) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1949
|
+
const canManage = hubAdmin || role === "owner" || role === "admin";
|
|
1950
|
+
const gid = chatGroupsMatch[2] ? decodeURIComponent(chatGroupsMatch[2]) : null;
|
|
1951
|
+
const sub = chatGroupsMatch[3];
|
|
1952
|
+
const memberUid = chatGroupsMatch[4] ? decodeURIComponent(chatGroupsMatch[4]) : null;
|
|
1953
|
+
// viewer 当负责人不获得任何东西(headScope 已保证);owner / admin 本来全管,不必算。
|
|
1954
|
+
const managed = canManage ? new Set<string>() : headScope(netId, resolved.user.user_id).managed;
|
|
1955
|
+
if (!gid) {
|
|
1956
|
+
if (req.method !== "GET") return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
1957
|
+
// 第三个 PR:每个群附上我的 unread / last_message_at(只对我在里面的群;管理者看到的非成员群是 0 / null —— 不让管理身份窥探消息时间)。
|
|
1958
|
+
// App 补口(只增):last_message 预览(同一口径,非成员群 null)和 viewer_can {manage, post}(与写接口同一判据)。
|
|
1959
|
+
// listGroupThreads 已经批量带上 last_message,整张列表固定几次查询,不逐群查。
|
|
1960
|
+
const threads = new Map(listGroupThreads(netId, resolved.user.user_id).map((t) => [t.group_id, t]));
|
|
1961
|
+
const groups = listVisibleGroups(netId, resolved.user.user_id, canManage).map((g) => {
|
|
1962
|
+
const t = g.is_member ? threads.get(g.id) : undefined;
|
|
1963
|
+
return { ...g, unread: t?.unread ?? 0, last_message_at: t?.last_at ?? null, last_message: t?.last_message ?? null, viewer_can: groupViewerCan(g, g.is_member, canManage, managed) };
|
|
1964
|
+
});
|
|
1965
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, groups }));
|
|
1966
|
+
}
|
|
1967
|
+
const notFound = () => withCors(req, Response.json({ ok: false, error: "group_not_found" }, { status: 404 }));
|
|
1968
|
+
// 第三个 PR(群消息):GET / POST …/:gid/messages、POST …/:gid/read。只认**当前群成员** —— owner / admin / 负责人
|
|
1969
|
+
// 不是成员也读不到消息(他们看得到群资料,但消息只给群里的人);别人(含被移出的人)一律 404 group_not_found。
|
|
1970
|
+
if (sub === "messages" || sub === "read") {
|
|
1971
|
+
if (memberUid) return notFound();
|
|
1972
|
+
const ok = (sub === "messages" && (req.method === "GET" || req.method === "POST")) || (sub === "read" && req.method === "POST");
|
|
1973
|
+
if (!ok) return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
1974
|
+
if (!memberGroup(netId, gid, resolved.user.user_id)) return notFound();
|
|
1975
|
+
const failedMsg = (r: { status: number; error: string }) => withCors(req, Response.json({ ok: false, error: r.error }, { status: r.status }));
|
|
1976
|
+
if (sub === "messages" && req.method === "GET") {
|
|
1977
|
+
const page = listGroupMessages(gid, resolved.user.user_id, Number(url.searchParams.get("limit")) || 50, url.searchParams.get("before"));
|
|
1978
|
+
if ("ok" in page) return failedMsg(page);
|
|
1979
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, group_id: gid, ...page, unread: groupUnreadFor(gid, resolved.user.user_id) }));
|
|
1980
|
+
}
|
|
1981
|
+
let mbody: Record<string, unknown> = {};
|
|
1982
|
+
try { const t = await req.text(); const b = t ? JSON.parse(t) : {}; if (!b || typeof b !== "object" || Array.isArray(b)) throw new Error("not an object"); mbody = b as Record<string, unknown>; } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
|
|
1983
|
+
if (sub === "read") {
|
|
1984
|
+
const r = markGroupRead(netId, gid, resolved.user.user_id, mbody.seq);
|
|
1985
|
+
if (!r.ok) return failedMsg(r);
|
|
1986
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, group_id: gid, last_read_seq: r.last_read_seq, unread: r.unread }));
|
|
1987
|
+
}
|
|
1988
|
+
const sent = sendGroupMessage({
|
|
1989
|
+
networkId: netId, groupId: gid,
|
|
1990
|
+
sender: { userId: resolved.user.user_id, username: resolved.user.username },
|
|
1991
|
+
message: mbody.message, attachments: mbody.attachments, clientRequestId: mbody.client_request_id,
|
|
1992
|
+
});
|
|
1993
|
+
if (!sent.ok) return failedMsg(sent);
|
|
1994
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, group_id: gid, message: sent.message, duplicate: sent.duplicate, delivered_to: sent.delivered_to }));
|
|
1995
|
+
}
|
|
1996
|
+
if (!sub && req.method === "GET") {
|
|
1997
|
+
const got = readGroup(netId, gid, resolved.user.user_id, canManage, managed);
|
|
1998
|
+
if (!got) return notFound();
|
|
1999
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, ...got }));
|
|
2000
|
+
}
|
|
2001
|
+
const allowed = (!sub && req.method === "PATCH") || (sub === "members" && !memberUid && req.method === "POST") || (sub === "members" && memberUid && req.method === "DELETE");
|
|
2002
|
+
if (!allowed) return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
2003
|
+
const group = groupById(netId, gid);
|
|
2004
|
+
if (!group) return notFound();
|
|
2005
|
+
if (!canManageGroup(group, canManage, managed)) {
|
|
2006
|
+
return isGroupMember(gid, resolved.user.user_id)
|
|
2007
|
+
? withCors(req, Response.json({ ok: false, error: "group_manage_denied" }, { status: 403 }))
|
|
2008
|
+
: notFound();
|
|
2009
|
+
}
|
|
2010
|
+
let body: Record<string, unknown> = {};
|
|
2011
|
+
if (req.method !== "DELETE") {
|
|
2012
|
+
try { const t = await req.text(); const b = t ? JSON.parse(t) : {}; body = b && typeof b === "object" && !Array.isArray(b) ? b as Record<string, unknown> : {}; } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
|
|
2013
|
+
}
|
|
2014
|
+
const via = canManage ? {} : { via: "leader" };
|
|
2015
|
+
const failed = (r: { status: number; error: string }) => withCors(req, Response.json({ ok: false, error: r.error }, { status: r.status }));
|
|
2016
|
+
if (!sub) {
|
|
2017
|
+
const r = renameGroup(netId, gid, body);
|
|
2018
|
+
if (!r.ok) return failed(r);
|
|
2019
|
+
auditDepartment(resolved.user, "chat_group_renamed", netId, JSON.stringify({ group_id: gid, name: r.group.name, ...via }));
|
|
2020
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, group: { ...r.group, viewer_can: groupViewerCan(r.group, isGroupMember(gid, resolved.user.user_id), canManage, managed) } }));
|
|
2021
|
+
}
|
|
2022
|
+
if (!memberUid) {
|
|
2023
|
+
const r = addManualMember(netId, gid, body.user_id);
|
|
2024
|
+
if (!r.ok) return failed(r);
|
|
2025
|
+
auditDepartment(resolved.user, "chat_group_member_added", netId, JSON.stringify({ group_id: gid, user_id: r.member.user_id, ...via }));
|
|
2026
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, group_id: gid, member: r.member }, { status: 201 }));
|
|
2027
|
+
}
|
|
2028
|
+
const r = removeManualMember(netId, gid, memberUid);
|
|
2029
|
+
if (!r.ok) return failed(r);
|
|
2030
|
+
auditDepartment(resolved.user, "chat_group_member_removed", netId, JSON.stringify({ group_id: gid, user_id: memberUid, ...via }));
|
|
2031
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, group_id: gid, removed: memberUid }));
|
|
2032
|
+
}
|
|
2033
|
+
|
|
1890
2034
|
// ── V3.13: Network members + invites ──
|
|
1891
2035
|
const membersMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members(?:\/([^/]+))?$/);
|
|
1892
2036
|
if (membersMatch) {
|
|
@@ -2376,7 +2520,8 @@ return Bun.serve({
|
|
|
2376
2520
|
return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
2377
2521
|
}
|
|
2378
2522
|
if (url.pathname === "/api/dm/threads") {
|
|
2379
|
-
|
|
2523
|
+
// group_threads(RFC-042 第三个 PR,只增字段):我在里面的群,每个带 unread / last_at / last_message 预览 —— App 私信未读角标可以一起算。
|
|
2524
|
+
return withCors(req, Response.json({ ok: true, network_id: dmNet, threads: listDmThreads(dmNet, restAuth.userId), group_threads: listGroupThreads(dmNet, restAuth.userId) }));
|
|
2380
2525
|
}
|
|
2381
2526
|
const other = url.searchParams.get("with") ?? "";
|
|
2382
2527
|
if (!other) return withCors(req, Response.json({ ok: false, error: "with_required" }, { status: 400 }));
|
|
@@ -4363,7 +4508,7 @@ return Bun.serve({
|
|
|
4363
4508
|
config_path, channels, server, hostname,
|
|
4364
4509
|
network_id, created_at, updated_at,
|
|
4365
4510
|
config_snapshot, lifecycle_state, avatar_url,
|
|
4366
|
-
display_name, team, tags, attrs_revision, permission_mode
|
|
4511
|
+
display_name, team, tags, attrs_revision, permission_mode, owner_user_id
|
|
4367
4512
|
FROM nodes WHERE 1=1`;
|
|
4368
4513
|
const params: any[] = [];
|
|
4369
4514
|
sql = addAgentNetworkScope(sql, params, restScope, { alias: "alias", nodeId: "node_id" });
|
|
@@ -4404,6 +4549,17 @@ return Bun.serve({
|
|
|
4404
4549
|
}
|
|
4405
4550
|
})();
|
|
4406
4551
|
|
|
4552
|
+
// RFC-041(#489):调用者能不能改这个节点的权限模式 —— 与 PUT /api/nodes/:id/permission-mode 同一判据
|
|
4553
|
+
// (节点主人、网络 owner / admin、Hub 管理员;只给用户令牌)。客户端据此决定显示「权限」一行,不自己推导规则。
|
|
4554
|
+
const netRoles = new Map<string, string | null>();
|
|
4555
|
+
const canSetPermissionMode = (r: Record<string, any>): boolean => {
|
|
4556
|
+
if (!restAuth || restAuth.networkId) return false;
|
|
4557
|
+
if (isAdmin || (r.owner_user_id && r.owner_user_id === restAuth.userId)) return true;
|
|
4558
|
+
if (!r.network_id) return false;
|
|
4559
|
+
if (!netRoles.has(r.network_id)) netRoles.set(r.network_id, getUserNetworkRole(restAuth.userId, r.network_id));
|
|
4560
|
+
const role = netRoles.get(r.network_id);
|
|
4561
|
+
return role === "owner" || role === "admin";
|
|
4562
|
+
};
|
|
4407
4563
|
const rows = rawRows.map(r => {
|
|
4408
4564
|
let role: string | null = null;
|
|
4409
4565
|
const snap = r.config_snapshot;
|
|
@@ -4411,7 +4567,7 @@ return Bun.serve({
|
|
|
4411
4567
|
try { role = (typeof snap === "string" ? JSON.parse(snap) : snap)?.role ?? null; }
|
|
4412
4568
|
catch { /* malformed snapshot — leave role null */ }
|
|
4413
4569
|
}
|
|
4414
|
-
const { config_snapshot, ...rest } = r;
|
|
4570
|
+
const { config_snapshot, owner_user_id, ...rest } = r;
|
|
4415
4571
|
// `tags` is stored as JSON text; hand the dashboard a real array so
|
|
4416
4572
|
// it can map() without a guard, and normalise legacy/absent values
|
|
4417
4573
|
// to [] rather than null. attrs_revision is normalised to a number
|
|
@@ -4427,6 +4583,7 @@ return Bun.serve({
|
|
|
4427
4583
|
role,
|
|
4428
4584
|
lifecycle_controllable,
|
|
4429
4585
|
lifecycle_daemon_node_id: controllable.get(r.node_id) ?? null,
|
|
4586
|
+
viewer_can: { permission_mode: canSetPermissionMode(r) },
|
|
4430
4587
|
};
|
|
4431
4588
|
});
|
|
4432
4589
|
return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
|
|
@@ -4754,6 +4911,8 @@ export function startHub(opts?: { port?: number; hostname?: string }): ReturnTyp
|
|
|
4754
4911
|
patrolDeliveredStaleTasks();
|
|
4755
4912
|
const deliveredStalePatrolTimer = setInterval(patrolDeliveredStaleTasks, deliveredStalePatrolMs);
|
|
4756
4913
|
const scheduledTaskTimer = startScheduledTaskScheduler();
|
|
4914
|
+
// #492 —— 任务到期提醒(默认每 10 分钟;COMMHUB_DUE_REMINDERS=0 关掉)。bootServer 不起它,测试按需直接调 runDueReminders。
|
|
4915
|
+
const dueReminderTimer = startDueReminderTimer();
|
|
4757
4916
|
|
|
4758
4917
|
// The Bun.serve socket is what keeps the process alive; the periodic
|
|
4759
4918
|
// jobs must not — otherwise a test (or a future caller) that stops the
|
|
@@ -4764,6 +4923,7 @@ export function startHub(opts?: { port?: number; hostname?: string }): ReturnTyp
|
|
|
4764
4923
|
(taskPatrolTimer as any)?.unref?.();
|
|
4765
4924
|
(deliveredStalePatrolTimer as any)?.unref?.();
|
|
4766
4925
|
(scheduledTaskTimer as any)?.unref?.();
|
|
4926
|
+
(dueReminderTimer as any)?.unref?.();
|
|
4767
4927
|
|
|
4768
4928
|
// ── Graceful shutdown ───────────────────────────────
|
|
4769
4929
|
function shutdown() {
|
|
@@ -4774,6 +4934,7 @@ export function startHub(opts?: { port?: number; hostname?: string }): ReturnTyp
|
|
|
4774
4934
|
clearInterval(taskPatrolTimer);
|
|
4775
4935
|
clearInterval(deliveredStalePatrolTimer);
|
|
4776
4936
|
clearInterval(scheduledTaskTimer);
|
|
4937
|
+
if (dueReminderTimer) clearInterval(dueReminderTimer);
|
|
4777
4938
|
db.close();
|
|
4778
4939
|
process.exit(0);
|
|
4779
4940
|
}
|
|
@@ -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
|
+
}
|