@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.
|
|
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
|
-
"
|
|
1425
|
+
"Read your inbox.",
|
|
1422
1426
|
{
|
|
1423
|
-
alias: z.string().min(1).max(200).describe("
|
|
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
|
|
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
|
-
"
|
|
1466
|
+
"ACK your inbox.",
|
|
1458
1467
|
{
|
|
1459
|
-
alias: z.string().min(1).max(200).describe("
|
|
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", "
|
|
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);
|