@sleep2agi/commhub-server 0.9.0-preview.116 → 0.9.0-preview.117

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.116",
3
+ "version": "0.9.0-preview.117",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,98 @@
1
+ import { beforeAll, afterAll, expect, test } from "bun:test";
2
+ import { mkdtempSync } from "node:fs";
3
+
4
+ process.env.COMMHUB_DB ||= `${mkdtempSync('/tmp/team-whoami-')}/hub.db`;
5
+ let db: any, hub: any, base: string, owner: any, outside: any;
6
+ let selfToken: string, otherToken: string, outsideToken: string, unboundToken: string;
7
+ const SELF = 'node_team_whoami_self', OTHER = 'node_team_whoami_other', FOREIGN = 'node_team_whoami_foreign';
8
+ const TEAM = 'team_whoami_child', ROOT = 'team_whoami_root';
9
+
10
+ async function rpc(token: string, method = 'tools/call', params: unknown = { name: 'org_whoami', arguments: {} }) {
11
+ const response = await fetch(`${base}/mcp`, { method: 'POST', headers: {
12
+ Authorization: `Bearer ${token}`, 'Content-Type': 'application/json',
13
+ Accept: 'application/json, text/event-stream', 'MCP-Protocol-Version': '2025-03-26',
14
+ }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
15
+ expect(response.status).toBe(200);
16
+ const text = await response.text();
17
+ const line = text.split('\n').filter(x => x.startsWith('data:')).at(-1);
18
+ const body = JSON.parse(line ? line.slice(5) : text);
19
+ expect(body.error).toBeUndefined();
20
+ return body.result;
21
+ }
22
+ async function who(token = selfToken, args: Record<string, unknown> = {}) {
23
+ const result = await rpc(token, 'tools/call', { name: 'org_whoami', arguments: args });
24
+ return JSON.parse(result.content[0].text);
25
+ }
26
+
27
+ beforeAll(async () => {
28
+ ({ db } = await import('./db.js'));
29
+ const { register, createNetworkTokenForNode } = await import('./auth.js');
30
+ owner = register('team_whoami_owner', 'Fixture-Passw0rd!xyz');
31
+ outside = register('team_whoami_outside', 'Fixture-Passw0rd!xyz');
32
+ db.run("UPDATE users SET display_name = 'Team owner' WHERE user_id = ?1", [owner.user.user_id]);
33
+ const mint = (u: any, alias: string, node?: string) => {
34
+ const r = createNetworkTokenForNode(u.user.user_id, u.network_id, alias, node);
35
+ expect(r.error ?? null).toBe(null); return r.token!;
36
+ };
37
+ selfToken = mint(owner, 'team-self', SELF);
38
+ otherToken = mint(owner, 'team-other', OTHER);
39
+ outsideToken = mint(outside, 'team-self', FOREIGN); // Same alias in a different network.
40
+ unboundToken = mint(owner, 'team-unregistered');
41
+ const { bootServer } = await import('./server.js');
42
+ hub = bootServer({ port: 0, hostname: '127.0.0.1' });
43
+ base = `http://127.0.0.1:${hub.port}`;
44
+ for (const [net, id, name, parent] of [
45
+ [owner.network_id, ROOT, 'Parent', null], [owner.network_id, TEAM, 'My team', ROOT],
46
+ [outside.network_id, TEAM, 'Foreign team', null],
47
+ ]) db.run('INSERT INTO network_agent_teams(network_id,team_id,name,parent_id) VALUES(?1,?2,?3,?4)', [net,id,name,parent]);
48
+ db.run('UPDATE network_agent_teams SET lead_node_id=?1,owner_user_id=?2 WHERE network_id=?3 AND team_id=?4', [SELF,owner.user.user_id,owner.network_id,TEAM]);
49
+ for (const [net, node] of [[owner.network_id, SELF], [outside.network_id, FOREIGN]]) {
50
+ db.run('INSERT INTO network_agent_team_members(network_id,node_id,team_id) VALUES(?1,?2,?3)', [net,node,TEAM]);
51
+ }
52
+ // Even a stale/corrupt membership must not project a node from another network.
53
+ db.run('INSERT INTO network_agent_team_members(network_id,node_id,team_id) VALUES(?1,?2,?3)', [owner.network_id,FOREIGN,TEAM]);
54
+ }, 60_000);
55
+ afterAll(() => hub?.stop(true));
56
+
57
+ test('exact node team, nearest-first ancestors, Agent lead and private human display name', async () => {
58
+ const r = await who();
59
+ expect(r.ok).toBe(true); expect(r.source).toBe('node'); expect(r.node_id).toBe(SELF);
60
+ expect(r.team).toEqual({ id: TEAM, name: 'My team' });
61
+ expect(r.ancestors).toEqual([{ id: ROOT, name: 'Parent' }]);
62
+ expect(r.lead.node_id).toBe(SELF);
63
+ expect(r.owner).toEqual({ user_id: owner.user.user_id, display_name: 'Team owner' });
64
+ expect(r.agents.map((n: any) => n.node_id)).toEqual([SELF]);
65
+ expect(r.truncated).toBe(false);
66
+ expect(JSON.stringify(r)).not.toContain('team_whoami_owner');
67
+ });
68
+ test('same-id foreign team and foreign node cannot cross network; supplied identity is ignored', async () => {
69
+ const r = await who(selfToken, { network_id: outside.network_id, node_id: FOREIGN, alias: 'team-other' });
70
+ expect(r.node_id).toBe(SELF); expect(r.team.name).toBe('My team');
71
+ expect(JSON.stringify(r)).not.toContain(FOREIGN);
72
+ const b = await who(outsideToken);
73
+ expect(b.team.name).toBe('Foreign team'); expect(b.node_id).toBe(FOREIGN);
74
+ expect(b.ancestors).toEqual([]); expect(JSON.stringify(b)).not.toContain(SELF);
75
+ });
76
+ test('unassigned node does not inherit its owner or another node team', async () => {
77
+ expect(await who(otherToken)).toEqual({ ok: true, source: 'none', node_id: OTHER, team: null,
78
+ ancestors: [], lead: null, owner: null, agents: [], truncated: false });
79
+ expect((await who(unboundToken)).error).toBe('node_identity_unbound');
80
+ });
81
+ test('user credentials cannot list or call; node credentials can list', async () => {
82
+ expect((await rpc(owner.token, 'tools/list', {})).tools.some((t: any) => t.name === 'org_whoami')).toBe(false);
83
+ expect((await who(owner.token)).error).toBe('network_token_required');
84
+ expect((await rpc(selfToken, 'tools/list', {})).tools.some((t: any) => t.name === 'org_whoami')).toBe(true);
85
+ });
86
+ test('foreign lead and human owner references are not returned', async () => {
87
+ db.run('UPDATE network_agent_teams SET lead_node_id=?1,owner_user_id=?2 WHERE network_id=?3 AND team_id=?4', [FOREIGN,outside.user.user_id,owner.network_id,TEAM]);
88
+ try { const r = await who(); expect(r.lead).toBe(null); expect(r.owner).toBe(null); }
89
+ finally { db.run('UPDATE network_agent_teams SET lead_node_id=?1,owner_user_id=?2 WHERE network_id=?3 AND team_id=?4', [SELF,owner.user.user_id,owner.network_id,TEAM]); }
90
+ });
91
+ test('large teams are bounded to 50 and explicitly truncated', async () => {
92
+ for (let i = 0; i < 51; i++) {
93
+ const id = `node_team_whoami_cap_${i}`;
94
+ db.run('INSERT INTO nodes(node_id,node_name,alias,network_id) VALUES(?1,?1,?1,?2)', [id,owner.network_id]);
95
+ db.run('INSERT INTO network_agent_team_members(network_id,node_id,team_id) VALUES(?1,?2,?3)', [owner.network_id,id,TEAM]);
96
+ }
97
+ const r = await who(); expect(r.agents.length).toBe(50); expect(r.truncated).toBe(true);
98
+ });
@@ -125,6 +125,28 @@ export function listAgentTeams(networkId: string, hidden: Hidden) {
125
125
  }));
126
126
  }
127
127
 
128
+ /** #765: node-token identity only; no fallback to a human's department.
129
+ * Reuse the REST projection's network joins and display-name privacy rules.
130
+ */
131
+ export function agentTeamWhoami(networkId: string, nodeId: string | null) {
132
+ if (!nodeId) return { ok: false, error: "node_identity_unbound" };
133
+ const teams = listAgentTeams(networkId, () => false);
134
+ const self = teams.find(t => t.members.some(n => n.node_id === nodeId));
135
+ if (!self) return { ok: true, source: "none", node_id: nodeId, team: null, ancestors: [], lead: null, owner: null, agents: [], truncated: false };
136
+ const byId = new Map(teams.map(t => [t.id, t]));
137
+ const seen = new Set([self.id]);
138
+ const ancestors: { id: string; name: string }[] = [];
139
+ for (let id = self.parent_id; id && !seen.has(id);) {
140
+ const parent = byId.get(id);
141
+ if (!parent) break;
142
+ seen.add(id);
143
+ ancestors.push({ id, name: parent.name });
144
+ id = parent.parent_id;
145
+ }
146
+ return { ok: true, source: "node", node_id: nodeId, team: { id: self.id, name: self.name }, ancestors,
147
+ lead: self.lead, owner: self.owner, agents: self.members.slice(0, 50), truncated: self.members.length > 50 };
148
+ }
149
+
128
150
  /** scope = null 表示全管;否则是调用者能管的团队集合。 */
129
151
  export function createAgentTeam(networkId: string, actor: string, scope: Set<string> | null, hidden: Hidden, body: Record<string, unknown>) {
130
152
  const name = cleanName(body.name);
@@ -257,7 +257,7 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
257
257
  get_adopt_request: "always", ack_adopt_request: "always",
258
258
  send_desktop_message: "always", // 发给人的私信,不是派活
259
259
  // 读
260
- get_all_status: "read", get_session_status: "read", get_task: "read", list_tasks: "read", get_completions: "read",
260
+ get_all_status: "read", get_session_status: "read", get_task: "read", list_tasks: "read", get_completions: "read", org_whoami: "read",
261
261
  list_skills: "read", get_skill: "read", list_host_supervisors: "read", list_network_secrets: "read", list_providers: "read",
262
262
  get_probe_results: "read", read_node_rules_file: "read", list_node_skills: "read", read_node_skill: "read", list_node_files: "read",
263
263
  read_node_file: "read", tail_node_logs: "read",
@@ -26,7 +26,7 @@ export const TOOL_AUDIENCE: Readonly<Record<string, ToolAudience>> = {
26
26
  request_adopt_node: "user", unadopt_node: "user",
27
27
  get_adopt_request: "protocol", ack_adopt_request: "protocol",
28
28
  // ── 节点:用户令牌一律 network_token_required ──
29
- report_status: "node",
29
+ report_status: "node", org_whoami: "node",
30
30
  // #733 定时任务:节点工具(用户令牌一律 network_token_required;人走 REST /api/scheduled-tasks)
31
31
  schedule_list: "node", schedule_get: "node", schedule_create: "node", schedule_update: "node",
32
32
  schedule_cancel: "node", schedule_run_now: "node", schedule_runs: "node",
package/src/tools.ts CHANGED
@@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { requestAdopt, getAdopt, ackAdopt, unadopt, resolveManagedDaemon, activeBinding, createdDaemon } from "./node-daemon-bindings.js";
3
3
  import { runtimeReadinessSchema } from "./runtime-readiness.js";
4
4
  import { lockNodeAlias } from "./node-token-ownership.js";
5
- import { clearNodeFromAgentTeams } from "./agent-teams.js";
5
+ import { clearNodeFromAgentTeams, agentTeamWhoami } from "./agent-teams.js";
6
6
  import { parseDbTimestampMs } from "./db-timestamp.js";
7
7
  import { z } from "zod/v4";
8
8
  import { nodeHealthSchema, normalizeNodeHealth, recordNodeHealth } from "./node-health-store.js";
@@ -6403,6 +6403,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6403
6403
  server.tool("schedule_runs", "A schedule's recent runs.", { schedule_id: schedId },
6404
6404
  async ({ schedule_id }) => scheduleCall(id => agentScheduleRuns(id, schedule_id)));
6405
6405
 
6406
+ // #765: exact authenticated node; team tree is independent of human departments.
6407
+ server.tool("org_whoami", "Your Agent team, ancestors, lead, owner and up to 50 teammates. Read-only.", {},
6408
+ async () => scheduleCall(id => agentTeamWhoami(id.networkId, id.nodeId)));
6409
+
6406
6410
  // #478:tools/list 只列这个调用者真能用的工具(tool-audience.ts)。只滤列表,tools/call 不变。
6407
6411
  const caller: ToolCaller = callerTokenIsNetwork
6408
6412
  ? { kind: "node", tokenId: callerTokenId ?? null, networkId: enforceNetworkId ?? null, includeProtocol: listing.includeProtocol === true }