@sleep2agi/commhub-server 0.9.0-preview.128 → 0.9.0-preview.129

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.128",
3
+ "version": "0.9.0-preview.129",
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,49 @@
1
+ import { db } from "./db.js";
2
+ import { getUserNetworkRole } from "./auth.js";
3
+ import { resolveNodeCaller } from "./create-node.js";
4
+ import { resolveCanonicalAlias } from "./rename.js";
5
+
6
+ type InboxCaller = {
7
+ tokenId?: string | null;
8
+ userId?: string | null;
9
+ networkId?: string | null;
10
+ nodeToken: boolean;
11
+ identityRejected: boolean;
12
+ };
13
+
14
+ /** Inbox reads and ACKs act as the recipient, not as a dispatch target. */
15
+ export function resolveInboxCaller(alias: string, caller: InboxCaller):
16
+ { ok: true; alias: string } | { ok: false; error: string } {
17
+ const denied = { ok: false as const, error: "inbox_identity_mismatch" };
18
+ if (caller.identityRejected) return denied;
19
+ const requested = alias.trim();
20
+ if (caller.nodeToken) {
21
+ const node = resolveNodeCaller(caller.tokenId);
22
+ if (node.ok) {
23
+ if (node.networkId !== caller.networkId) return denied;
24
+ const canonical = resolveCanonicalAlias(node.networkId, requested).alias;
25
+ return canonical === node.alias ? { ok: true, alias: node.alias } : denied;
26
+ }
27
+ // Registration tokens are network-scoped human tokens, not node tokens.
28
+ // Missing/foreign/ambiguous node identities never fall back to the issuer.
29
+ if (node.reason !== "not_a_node_token" || !caller.tokenId) return denied;
30
+ const token = db.get<{ name: string | null }>(
31
+ "SELECT name FROM api_tokens WHERE token_id = ?1 AND revoked_at IS NULL", caller.tokenId,
32
+ );
33
+ if (!token || token.name?.startsWith("node:")) return denied;
34
+ }
35
+ // Preserve open-development and legacy global-token mode.
36
+ if (!caller.userId) return { ok: true, alias: requested };
37
+ const user = db.get<{ username: string }>("SELECT username FROM users WHERE user_id = ?1", caller.userId);
38
+ if (user?.username === requested) return { ok: true, alias: requested };
39
+ if (!caller.networkId) return { ok: false, error: "network_id_required" };
40
+ const role = getUserNetworkRole(caller.userId, caller.networkId);
41
+ if (!role) return denied;
42
+ const canonical = resolveCanonicalAlias(caller.networkId, requested).alias;
43
+ if (role === "owner" || role === "admin") return { ok: true, alias: canonical };
44
+ const owned = db.get<{ node_id: string }>(
45
+ "SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2 AND owner_user_id = ?3 LIMIT 1",
46
+ canonical, caller.networkId, caller.userId,
47
+ );
48
+ return owned ? { ok: true, alias: canonical } : denied;
49
+ }
@@ -259,6 +259,9 @@ describe("utok multi-network", () => {
259
259
 
260
260
  test("ack_inbox threads explicit network_id", async () => {
261
261
  const { tools } = buildHandlers({ userId: U_MULTI });
262
+ // This tests network selection with an authorized recipient. Acting as
263
+ // another owner's recipient is covered by the HTTP identity suite.
264
+ db.run("UPDATE nodes SET owner_user_id = ?1 WHERE alias = ?2 AND network_id = ?3", [U_MULTI, A1, NET_A]);
262
265
  const ib = makeInbox(A1, "x517 g2 ack_inbox");
263
266
  const r = await call(tools["ack_inbox"], { alias: A1, message_id: ib, network_id: NET_A });
264
267
  expect(r.ok).toBe(true);
@@ -269,6 +269,73 @@ async function mcpSend(token: string, task: string, from: string | undefined) {
269
269
  return tool(token, "send_task", args);
270
270
  }
271
271
 
272
+ test("inbox recipient: bound identity wins over the token name", async () => {
273
+ const id = `inbox-bound-${stamp}`;
274
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'own inbox', ?3)", [id, otherAlias, NET]);
275
+ const read = await tool(otherTok.token, "get_inbox", { alias: otherAlias });
276
+ expect(read.ok).toBe(true);
277
+ expect(read.messages.some((row: any) => row.id === id)).toBe(true);
278
+ expect((await tool(otherTok.token, "ack_inbox", { alias: otherAlias, message_id: id })).ok).toBe(true);
279
+ expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", id)?.acked).toBe(1);
280
+ });
281
+
282
+ test("inbox recipient: another node and invalid node identities cannot read or ACK", async () => {
283
+ const id = `inbox-denied-${stamp}`;
284
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'private inbox', ?3)", [id, victimAlias, NET]);
285
+ for (const caller of [otherTok, squatter, boundMissing]) {
286
+ const read = await tool(caller.token, "get_inbox", { alias: victimAlias });
287
+ expect(read.ok).toBe(false);
288
+ expect(read.error).toBe("inbox_identity_mismatch");
289
+ expect(read.messages).toBeUndefined();
290
+ const ack = await tool(caller.token, "ack_inbox", { alias: victimAlias, message_id: id });
291
+ expect(ack.ok).toBe(false);
292
+ expect(ack.error).toBe("inbox_identity_mismatch");
293
+ expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", id)?.acked).toBe(0);
294
+ }
295
+ expect((await tool(victimTok.token, "ack_inbox", { alias: victimAlias, message_id: id })).ok).toBe(true);
296
+ });
297
+
298
+ test("inbox recipient: same alias stays inside the token network", async () => {
299
+ const home = `inbox-net-home-${stamp}`, foreign = `inbox-net-foreign-${stamp}`;
300
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'home', ?3)", [home, sharedAlias, NET2]);
301
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'foreign', ?3)", [foreign, sharedAlias, NET]);
302
+ const read = await tool(sharedHome.token, "get_inbox", { alias: sharedAlias, network_id: NET });
303
+ expect(read.ok).toBe(true);
304
+ expect(read.messages.some((row: any) => row.id === home)).toBe(true);
305
+ expect(read.messages.some((row: any) => row.id === foreign)).toBe(false);
306
+ expect((await tool(sharedHome.token, "ack_inbox", { alias: sharedAlias, message_id: foreign, network_id: NET })).ok).toBe(false);
307
+ expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", foreign)?.acked).toBe(0);
308
+ });
309
+
310
+ test("inbox recipient: legacy ownerless identity and authorized human access remain", async () => {
311
+ const id = `inbox-legacy-${stamp}`;
312
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'legacy', ?3)", [id, ownerlessAlias, NET]);
313
+ expect((await tool(ownerlessTok.token, "get_inbox", { alias: ownerlessAlias })).ok).toBe(true);
314
+ expect((await tool(ownerToken, "get_inbox", { alias: ownerlessAlias, network_id: NET })).ok).toBe(true);
315
+ expect((await tool(ownerToken, "ack_inbox", { alias: ownerlessAlias, message_id: id, network_id: NET })).ok).toBe(true);
316
+ });
317
+
318
+ test("inbox recipient: a full-access member cannot act as another owner's node", async () => {
319
+ const member = register(`inboxmem${stamp}`, "InboxMember123!", undefined, "seed");
320
+ expect(member.ok).toBe(true);
321
+ expect(addNetworkMember(NET, member.user!.user_id, "member", ownerId).ok).toBe(true);
322
+ db.run("UPDATE network_members SET agent_access = 'all' WHERE user_id = ?1 AND network_id = ?2", [member.user!.user_id, NET]);
323
+ const id = `inbox-member-${stamp}`;
324
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'private', ?3)", [id, victimAlias, NET]);
325
+ for (const name of ["get_inbox", "ack_inbox"]) {
326
+ const args: Record<string, unknown> = { alias: victimAlias, network_id: NET };
327
+ if (name === "ack_inbox") args.message_id = id;
328
+ const result = await tool(member.token!, name, args);
329
+ expect(result.ok).toBe(false);
330
+ expect(result.error).toBe("inbox_identity_mismatch");
331
+ }
332
+ expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", id)?.acked).toBe(0);
333
+ const own = `inbox-member-own-${stamp}`;
334
+ db.run("INSERT INTO inbox (id, session_name, type, content, network_id) VALUES (?1, ?2, 'message', 'own', ?3)", [own, member.user!.username, NET]);
335
+ expect((await tool(member.token!, "get_inbox", { alias: member.user!.username, network_id: NET })).ok).toBe(true);
336
+ expect((await tool(member.token!, "ack_inbox", { alias: member.user!.username, message_id: own, network_id: NET })).ok).toBe(true);
337
+ });
338
+
272
339
  test("caller identity boundary: mcp send omits from_session", async () => {
273
340
  const task = `boundary omit ${stamp}`;
274
341
  const sent = await mcpSend(squatter.token, task, undefined);
package/src/tools.ts CHANGED
@@ -22,6 +22,7 @@ import { getSSEStats, hasSubscribers, hasUserSubscribers, pushEvent, pushNetwork
22
22
  import { deferOfflineIfAnotherCopyConnected } from "./node-identity-conflict.js";
23
23
  import { assertNodeActive } from "./lifecycle-guard.js";
24
24
  import { pendingInboxCount } from "./inbox-count.js";
25
+ import { resolveInboxCaller } from "./inbox-caller.js";
25
26
  import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
26
27
  import { addAgentNetworkScope, addAgentTimelineScope, addNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
27
28
  import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS, type AgentRef } from "./agent-access.js";
@@ -843,6 +844,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
843
844
  // anet daemon. Default-stripping zod would drop this otherwise.
844
845
  role: z.string().max(64).optional().nullable(),
845
846
  scoped_secrets_capable: z.boolean().optional(),
847
+ // Preserve the runtime's independent-key capability through MCP input
848
+ // parsing; unknown z.object fields are otherwise silently stripped.
849
+ node_secrets_capable: z.boolean().optional(),
846
850
  // RFC-026 §9.3 / #338 PR3 — daemon self-declare nested under
847
851
  // `daemon_capabilities` (canonical shape per existing hub reads
848
852
  // at tools.ts:2010/2075 — PR1/PR2 placed these top-level, hub
@@ -1418,13 +1422,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1418
1422
 
1419
1423
  server.tool(
1420
1424
  "get_inbox",
1421
- "Get pending commands for your session.",
1425
+ "Read your inbox.",
1422
1426
  {
1423
- alias: z.string().min(1).max(200).describe("Session alias"),
1427
+ alias: z.string().min(1).max(200).describe("Alias"),
1424
1428
  limit: z.number().min(1).max(100).optional().default(10),
1429
+ network_id: z.string().max(200).optional(),
1425
1430
  },
1426
- async ({ alias, limit }) => {
1427
- const readScope = resolveReadScope(null);
1431
+ async ({ alias, limit, network_id: netId }) => {
1432
+ const effectiveNetId = getNetworkId(netId);
1433
+ const recipient = resolveInboxCaller(alias, { tokenId: callerTokenId, userId: enforceUserId, networkId: effectiveNetId, nodeToken: callerTokenIsNetwork, identityRejected: callerIdentityRejected });
1434
+ if (!recipient.ok) return { content: [{ type: "text" as const, text: JSON.stringify(recipient) }] };
1435
+ alias = recipient.alias;
1436
+ const readScope = resolveReadScope(effectiveNetId);
1428
1437
  if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
1429
1438
  const countParams: any[] = [alias];
1430
1439
  let countSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
@@ -1454,9 +1463,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1454
1463
 
1455
1464
  server.tool(
1456
1465
  "ack_inbox",
1457
- "Acknowledge receipt of a command.",
1466
+ "ACK your inbox.",
1458
1467
  {
1459
- alias: z.string().min(1).max(200).describe("Session alias"),
1468
+ alias: z.string().min(1).max(200).describe("Alias"),
1460
1469
  message_id: z.string().min(1).max(200),
1461
1470
  response: z.string().max(10000).optional(),
1462
1471
  network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
@@ -1464,6 +1473,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1464
1473
  async ({ alias, message_id, response, network_id: netId }) => {
1465
1474
  const effectiveNetId = getNetworkId(netId);
1466
1475
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1476
+ const recipient = resolveInboxCaller(alias, { tokenId: callerTokenId, userId: enforceUserId, networkId: effectiveNetId, nodeToken: callerTokenIsNetwork, identityRejected: callerIdentityRejected });
1477
+ if (!recipient.ok) return { content: [{ type: "text" as const, text: JSON.stringify(recipient) }] };
1478
+ alias = recipient.alias;
1467
1479
  console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
1468
1480
  // New task consumers ACK by the logical tasks.task_id exposed by
1469
1481
  // get_inbox. Keep exact inbox.id support for legacy consumers and for
@@ -4131,7 +4143,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4131
4143
  const providerHuman = (net?: string) => ({ userId: enforceUserId, isNode: callerTokenIsNetwork, networkId: getNetworkId(net) });
4132
4144
  const providerReply = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }] });
4133
4145
  server.tool(
4134
- "daemon_provider_snapshot", "Provider read/write/login (no tokens). hub_*=owner catalog/distribute. secret_*=Daemon env; node_secret_*=node env. provider={node_id?,key?,value?}; next full start.",
4146
+ "daemon_provider_snapshot", "Providers, no tokens; hub_*=owner catalog+bind; secret_*=Daemon env; node_secret_*=node env; provider={node_id?,key?,value?}; next full start.",
4135
4147
  { action: z.enum(["refresh", "read", "put", "collect", "apply", "login", "hub_read", "hub_put", "hub_bind", "secret_list", "secret_put", "secret_delete", "secret_read", "node_secret_list", "node_secret_put", "node_secret_delete"]), id: z.string().min(1).max(200), network_id: z.string().max(200).optional(), revision: z.number().int().min(0).optional(), provider: z.unknown().optional(), include_discovery: z.boolean().optional() },
4136
4148
  async ({ action, id, network_id, revision, provider, include_discovery }) => {
4137
4149
  const human = providerHuman(network_id);