@sleep2agi/commhub-server 0.9.0-preview.130 → 0.9.0-preview.131

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.130",
3
+ "version": "0.9.0-preview.131",
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",
package/src/db.ts CHANGED
@@ -1271,7 +1271,7 @@ try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_from_node ON tasks(from_node
1271
1271
  try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_to_node ON tasks(to_node_id)"); } catch {}
1272
1272
  try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_node ON inbox(node_id)"); } catch {}
1273
1273
 
1274
- function backfillMessageNodeIds() {
1274
+ export function backfillMessageNodeIds() {
1275
1275
  let inboxDirect = 0;
1276
1276
  let inboxRenamed = 0;
1277
1277
  let tasksToDirect = 0;
@@ -1391,6 +1391,7 @@ function backfillMessageNodeIds() {
1391
1391
  LIMIT 1
1392
1392
  )
1393
1393
  WHERE from_node_id IS NULL
1394
+ AND NOT EXISTS (SELECT 1 FROM users u WHERE u.username = tasks.from_name)
1394
1395
  AND EXISTS (
1395
1396
  SELECT 1 FROM sessions s
1396
1397
  WHERE s.node_id IS NOT NULL
@@ -1416,6 +1417,7 @@ function backfillMessageNodeIds() {
1416
1417
  LIMIT 1
1417
1418
  )
1418
1419
  WHERE from_node_id IS NULL
1420
+ AND NOT EXISTS (SELECT 1 FROM users u WHERE u.username = tasks.from_name)
1419
1421
  AND EXISTS (
1420
1422
  SELECT 1 FROM rename_txn r
1421
1423
  JOIN sessions s
@@ -52,10 +52,11 @@ describe("REST from_session identity binding", () => {
52
52
  });
53
53
 
54
54
  // The Dashboard posts as its logged-in user; this path must not change.
55
- test("user token keeps the previous behaviour", () => {
55
+ test("user token explicitly naming the authenticated username is accepted", () => {
56
56
  expect(resolveRestFromSession({
57
57
  token: UTOK,
58
58
  tokenName: "dashboard",
59
+ authenticatedUsername: "admin",
59
60
  requestedFrom: "admin",
60
61
  })).toEqual({ ok: true, fromSession: "admin" });
61
62
  });
@@ -50,6 +50,22 @@ export function nodeAliasForToken(token: string, tokenName: string | null | unde
50
50
  return alias || null;
51
51
  }
52
52
 
53
+ /** Human dispatches use the authenticated username; node credentials represent nodes. */
54
+ export function resolveUserTaskFromSession(username: string | null | undefined, requestedFrom: unknown): RestIdentityResult {
55
+ const fromSession = username?.trim() || "api";
56
+ const requested = typeof requestedFrom === "string" ? requestedFrom.trim() : "";
57
+ if (requested && requested !== fromSession) {
58
+ return {
59
+ ok: false,
60
+ error: "from_session_identity_mismatch",
61
+ message: "user token task sender must match the authenticated username",
62
+ tokenAlias: fromSession,
63
+ requestedFromSession: requested,
64
+ };
65
+ }
66
+ return { ok: true, fromSession };
67
+ }
68
+
53
69
  export function resolveRestFromSession(input: RestIdentityInput): RestIdentityResult {
54
70
  const requested = typeof input.requestedFrom === "string" ? input.requestedFrom.trim() : "";
55
71
  let alias: string | null;
@@ -96,16 +112,14 @@ export function resolveRestFromSession(input: RestIdentityInput): RestIdentityRe
96
112
  return { ok: true, fromSession: "api" };
97
113
  }
98
114
 
99
- // A user-token client normally does not need to repeat its identity in every
100
- // request. Prefer an explicit, backwards-compatible claim when present;
101
- // otherwise attribute the task to the authenticated username. Falling back
102
- // to the transport label `api` is reserved for legacy auth contexts where
103
- // the server genuinely has no user identity.
115
+ // Authenticated human callers are always represented by their username.
116
+ // Keep the unscoped development / legacy transport path separate.
104
117
  if (!alias) {
105
118
  const authenticatedUsername = typeof input.authenticatedUsername === "string"
106
119
  ? input.authenticatedUsername.trim()
107
120
  : "";
108
- return { ok: true, fromSession: requested || authenticatedUsername || "api" };
121
+ if (authenticatedUsername) return resolveUserTaskFromSession(authenticatedUsername, input.requestedFrom);
122
+ return { ok: true, fromSession: requested || "api" };
109
123
  }
110
124
 
111
125
  // Node token claiming somebody else — refuse rather than silently rewrite,
package/src/server.ts CHANGED
@@ -3672,9 +3672,7 @@ return Bun.serve({
3672
3672
  // TUI*) as that node. Same table, same token, one transport hardened and
3673
3673
  // its sibling not.
3674
3674
  //
3675
- // User tokens keep the previous behaviour: the Dashboard legitimately
3676
- // posts as its logged-in user, and tightening that is a separate change
3677
- // with its own compatibility surface.
3675
+ // Human dispatches use the authenticated username on both transports.
3678
3676
  // The decision itself lives in rest-identity.ts as a pure function so the
3679
3677
  // shipped path and the tested path are the same code.
3680
3678
  const identity = resolveRestFromSession({
@@ -3695,7 +3693,9 @@ return Bun.serve({
3695
3693
  }
3696
3694
  const fromSession = identity.fromSession;
3697
3695
  const ttlSeconds = (body as any).ttl_seconds || 3600;
3698
- const fromNodeId = resolveRestNodeIdForAlias(fromSession, taskNetId);
3696
+ const fromNodeId = restAuth && !requestToken(req).startsWith("ntok_")
3697
+ ? null
3698
+ : resolveRestNodeIdForAlias(fromSession, taskNetId);
3699
3699
  const targetNodeId = target.session?.node_id ?? null;
3700
3700
  const actualTo = restActualTo(target, taskNetId);
3701
3701
  // #221 — fold top-level `attachments` into `meta.attachments` so
@@ -9,6 +9,7 @@ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-auth-origin-http-"));
9
9
  let server: any;
10
10
  let base = "";
11
11
  let userToken = "";
12
+ let userName = "";
12
13
  let nodeToken = "";
13
14
  let networkId = "";
14
15
  const TARGET = `auth-origin-http-target-${process.pid}`;
@@ -22,6 +23,7 @@ const TARGET_RESUME_ID = `resume_auth_origin_target_${process.pid}`;
22
23
  beforeAll(async () => {
23
24
  process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
24
25
  const username = `auth_origin_${Date.now()}`;
26
+ userName = username;
25
27
  const registered = register(username, "AuthOriginTest123!", undefined, "seed");
26
28
  expect(registered.ok).toBe(true);
27
29
  userToken = registered.token!;
@@ -80,7 +82,7 @@ async function postTask(token: string, from: string, marker: string) {
80
82
 
81
83
  describe("POST /api/task server-authenticated origin", () => {
82
84
  test("user token stamps user even when the client supplied another fact", async () => {
83
- const meta = await postTask(userToken, "dashboard-user", "origin-user");
85
+ const meta = await postTask(userToken, userName, "origin-user");
84
86
  expect(meta.auth_origin).toBe("user");
85
87
  });
86
88
 
@@ -87,7 +87,6 @@ async function post(principal: Principal, marker: string, networkId?: string) {
87
87
  headers: { Authorization: `Bearer ${principal.token}`, "Content-Type": "application/json" },
88
88
  body: JSON.stringify({
89
89
  alias: principal.target,
90
- from: "api",
91
90
  task: marker,
92
91
  priority: "high",
93
92
  ...(networkId ? { network_id: networkId } : {}),
@@ -0,0 +1,143 @@
1
+ import { afterAll, beforeAll, expect, test } from "bun:test";
2
+ import { addNetworkMember, createNetworkTokenForNode, createToken, register } from "./auth.js";
3
+ import { backfillMessageNodeIds, db } from "./db.js";
4
+
5
+ if (!process.env.COMMHUB_DB && !process.env.COMMHUB_TEST_PG_URL) throw new Error("isolated database required");
6
+ const stamp = `${process.pid}_${Date.now()}`;
7
+ type Caller = { label: string; token: string; username: string; userId: string };
8
+ type Transport = "rest" | "mcp";
9
+ let server: any, base: string, net: string, foreignNet: string;
10
+ let owner: Caller, member: Caller, admin: Caller, viewer: Caller;
11
+ let callers: Caller[] = [];
12
+ let nodeToken: string;
13
+ const target = `sender-target-${stamp}`;
14
+ const memberNode = `sender-owned-${stamp}`;
15
+ const targetId = `n_sender_target_${stamp}`;
16
+ const memberNodeId = `n_sender_owned_${stamp}`;
17
+ const collisionId = `n_sender_collision_${stamp}`;
18
+ let markerSeq = 0;
19
+
20
+ function user(label: string): Caller & { networkId: string } {
21
+ const username = `sender_${label}_${stamp}`;
22
+ const r = register(username, "SenderFixturePass123!", undefined, "seed");
23
+ if (!r.ok || !r.user || !r.token || !r.network_id) throw new Error("fixture registration failed");
24
+ return { label, token: r.token, username, userId: r.user.user_id, networkId: r.network_id };
25
+ }
26
+
27
+ function node(alias: string, id: string, ownerId: string, status = "idle") {
28
+ const r = createNetworkTokenForNode(ownerId, net, alias, id);
29
+ if (!r.ok || !r.token) throw new Error("fixture node registration failed");
30
+ db.run(`INSERT INTO sessions (resume_id, alias, node_id, network_id, status, last_seen_at)
31
+ VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'))`, [`resume_${id}`, alias, id, net, status]);
32
+ return r.token;
33
+ }
34
+
35
+ beforeAll(async () => {
36
+ const first = user("owner"); owner = first; net = first.networkId;
37
+ const second = user("member"); member = second; foreignNet = second.networkId;
38
+ admin = user("admin"); viewer = user("viewer");
39
+ db.run("UPDATE users SET role = 'user' WHERE user_id IN (?1, ?2, ?3)", [owner.userId, member.userId, viewer.userId]);
40
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.userId]);
41
+ for (const [c, role] of [[member, "member"], [admin, "admin"], [viewer, "viewer"]] as const) {
42
+ expect(addNetworkMember(net, c.userId, role, owner.userId, { agentAccess: "all" }).ok).toBe(true);
43
+ }
44
+ node(target, targetId, owner.userId);
45
+ nodeToken = node(memberNode, memberNodeId, member.userId);
46
+ // Legacy rows may predate the current username reservation on node creation.
47
+ db.run(`INSERT INTO nodes (node_id, alias, node_name, network_id, owner_user_id)
48
+ VALUES (?1, ?2, ?2, ?3, ?4)`, [collisionId, member.username, net, owner.userId]);
49
+ db.run(`INSERT INTO sessions (resume_id, alias, node_id, network_id, status, last_seen_at)
50
+ VALUES (?1, ?2, ?3, ?4, 'blocked', datetime('now'))`, [`resume_${collisionId}`, member.username, collisionId, net]);
51
+ db.run(`INSERT INTO tasks (task_id, from_name, to_name, to_node_id, network_id, status, content)
52
+ VALUES (?1, 'hub', ?2, ?3, ?4, 'delivered', 'fixture pending')`, [`parent_${stamp}`, member.username, collisionId, net]);
53
+ const plain = createToken(member.userId, "sender-cli");
54
+ const scoped = createToken(member.userId, "sender-scoped", net);
55
+ const named = createToken(member.userId, `node:${memberNode}`, net);
56
+ expect([plain.ok, scoped.ok, named.ok]).toEqual([true, true, true]);
57
+ callers = [owner, member, admin,
58
+ { ...member, label: "atok-cli", token: plain.token! },
59
+ { ...member, label: "atok-network", token: scoped.token! },
60
+ { ...member, label: "atok-named", token: named.token! }];
61
+ const mod = await import("./server.js");
62
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
63
+ base = `http://127.0.0.1:${server.port}`;
64
+ }, 30_000);
65
+
66
+ afterAll(() => { server?.stop(true); });
67
+
68
+ async function dispatch(transport: Transport, token: string, requested?: string, networkId = net) {
69
+ const marker = `sender-case-${stamp}-${++markerSeq}`;
70
+ const args: Record<string, unknown> = { alias: target, task: marker, network_id: networkId, meta: { source: "sender-fixture" } };
71
+ if (requested !== undefined) args[transport === "rest" ? "from" : "from_session"] = requested;
72
+ const res = await fetch(`${base}${transport === "rest" ? "/api/task" : "/mcp"}`, {
73
+ method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json",
74
+ Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
75
+ body: JSON.stringify(transport === "rest" ? args : {
76
+ jsonrpc: "2.0", id: markerSeq, method: "tools/call", params: { name: "send_task", arguments: args },
77
+ }),
78
+ });
79
+ const text = await res.text();
80
+ const data = text.split("\n").filter(s => s.startsWith("data:"));
81
+ const payload = JSON.parse(data.length ? data.at(-1)!.slice(5).trim() : text);
82
+ const body = transport === "rest" ? payload : JSON.parse(payload.result.content[0].text);
83
+ const task = db.get<{ from_name: string; from_node_id: string | null; parent_task_id: string | null; meta_json: string }>(
84
+ "SELECT from_name, from_node_id, parent_task_id, meta_json FROM tasks WHERE content = ?1", marker);
85
+ const inbox = db.get<{ n: number | string }>("SELECT COUNT(*) AS n FROM inbox WHERE content = ?1", marker);
86
+ return { status: res.status, body, task, inboxCount: Number(inbox?.n ?? 0) };
87
+ }
88
+
89
+ for (const transport of ["rest", "mcp"] as const) {
90
+ for (const label of ["owner", "member", "admin", "atok-cli", "atok-network", "atok-named"]) {
91
+ test(`authenticated sender: ${transport} ${label} uses its username`, async () => {
92
+ const c = callers.find(c => c.label === label)!;
93
+ for (const from of [undefined, ` ${c.username} `, ""]) {
94
+ const r = await dispatch(transport, c.token, from);
95
+ expect(r.body.task_id ?? r.body.message_id).toBeTruthy();
96
+ expect(r.task).toMatchObject({ from_name: c.username, from_node_id: null, parent_task_id: null });
97
+ expect(JSON.parse(r.task!.meta_json).auth_origin).toBe("user");
98
+ expect(r.inboxCount).toBe(1);
99
+ }
100
+ });
101
+ test(`authenticated sender: ${transport} ${label} rejects another identity without writes`, async () => {
102
+ const c = callers.find(c => c.label === label)!;
103
+ for (const from of [memberNode, "hub", "api", `other-${stamp}`]) {
104
+ const r = await dispatch(transport, c.token, from);
105
+ if (transport === "rest") expect(r.status).toBe(403);
106
+ expect(r.body).toMatchObject({ ok: false, error: "from_session_identity_mismatch" });
107
+ expect(r.task).toBeNull();
108
+ expect(r.inboxCount).toBe(0);
109
+ }
110
+ });
111
+ }
112
+ test(`authenticated sender: ${transport} node token retains node attribution`, async () => {
113
+ const r = await dispatch(transport, nodeToken, memberNode);
114
+ expect(r.body.task_id ?? r.body.message_id).toBeTruthy();
115
+ expect(r.task).toMatchObject({ from_name: memberNode, from_node_id: memberNodeId });
116
+ expect(JSON.parse(r.task!.meta_json).auth_origin).toBe("node");
117
+ });
118
+ test(`authenticated sender: ${transport} keeps viewer and network permissions`, async () => {
119
+ for (const [token, network] of [[viewer.token, net], [owner.token, foreignNet]]) {
120
+ const r = await dispatch(transport, token, undefined, network);
121
+ expect(r.body.ok).not.toBe(true);
122
+ expect(r.task).toBeNull();
123
+ expect(r.inboxCount).toBe(0);
124
+ }
125
+ });
126
+ test(`authenticated sender: ${transport} does not change a same-named node's state`, async () => {
127
+ const r = await dispatch(transport, member.token);
128
+ expect(r.task?.from_node_id).toBeNull();
129
+ expect(r.task?.parent_task_id).toBeNull();
130
+ expect(db.get<{ status: string }>("SELECT status FROM sessions WHERE node_id = ?1", collisionId)?.status).toBe("blocked");
131
+ });
132
+ test(`authenticated sender: ${transport} preserves human attribution during backfill`, async () => {
133
+ await dispatch(transport, member.token);
134
+ backfillMessageNodeIds();
135
+ expect(db.all<{ from_node_id: string | null }>(
136
+ "SELECT from_node_id FROM tasks WHERE from_name = ?1", member.username).every(t => t.from_node_id === null)).toBe(true);
137
+ const legacyId = `legacy_sender_${transport}_${stamp}`;
138
+ db.run(`INSERT INTO tasks (task_id, from_name, to_name, network_id, content)
139
+ VALUES (?1, ?2, ?3, ?4, 'legacy fixture')`, [legacyId, memberNode, target, net]);
140
+ backfillMessageNodeIds();
141
+ expect(db.get<{ from_node_id: string | null }>("SELECT from_node_id FROM tasks WHERE task_id = ?1", legacyId)?.from_node_id).toBe(memberNodeId);
142
+ });
143
+ }
package/src/tools.ts CHANGED
@@ -23,6 +23,7 @@ import { deferOfflineIfAnotherCopyConnected } from "./node-identity-conflict.js"
23
23
  import { assertNodeActive } from "./lifecycle-guard.js";
24
24
  import { pendingInboxCount } from "./inbox-count.js";
25
25
  import { resolveInboxCaller } from "./inbox-caller.js";
26
+ import { resolveUserTaskFromSession } from "./rest-identity.js";
26
27
  import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
27
28
  import { addAgentNetworkScope, addAgentTimelineScope, addNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
28
29
  import { canMessageAgent, restrictedNetworkIds, RESTRICTED_MEMBER_TOOLS, type AgentRef } from "./agent-access.js";
@@ -1829,7 +1830,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1829
1830
  meta: z.any().optional().describe("Optional structured task metadata, e.g. { attachments: [{ type, path, url, mime, name, size }] }."),
1830
1831
  force: z.boolean().optional().describe("dispatch even if the target is degraded (user tokens only)"),
1831
1832
  },
1832
- async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId, parent_task_id: parentIn, meta, force }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
1833
+ async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId, parent_task_id: parentIn, meta, force }) => {
1834
+ const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch;
1835
+ const humanSender = !callerTokenIsNetwork && !!enforceUserId;
1836
+ const userIdentity = humanSender ? resolveUserTaskFromSession(callerAlias, _fromIn) : null;
1837
+ if (userIdentity && !userIdentity.ok) {
1838
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1839
+ ok: false, error: userIdentity.error, message: userIdentity.message,
1840
+ token_alias: userIdentity.tokenAlias, requested_from_session: userIdentity.requestedFromSession,
1841
+ }) }] };
1842
+ }
1843
+ const from_session = userIdentity?.ok ? userIdentity.fromSession : defaultFrom(_fromIn);
1833
1844
  const effectiveNetId = getNetworkId(netId);
1834
1845
  const authOrigin: TaskAuthOrigin = callerTokenIsNetwork
1835
1846
  ? "node"
@@ -1865,7 +1876,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1865
1876
  // network A dispatch and chain-reply into the wrong tenant. The
1866
1877
  // explicit-parent path is verified below in F2.
1867
1878
  let parentTaskId: string | null = parentIn ?? null;
1868
- if (!parentTaskId && from_session && from_session !== "hub" && from_session !== "api") {
1879
+ if (!humanSender && !parentTaskId && from_session && from_session !== "hub" && from_session !== "api") {
1869
1880
  try {
1870
1881
  const recentParams: any[] = [from_session];
1871
1882
  let recentSql = "SELECT task_id FROM tasks WHERE to_name = ?1 AND status IN ('delivered')"; // board668-parent-delivered-only
@@ -1996,7 +2007,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1996
2007
  }
1997
2008
 
1998
2009
  console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
1999
- const fromNodeId = resolveNodeIdForAlias(from_session, effectiveNetId);
2010
+ const fromNodeId = humanSender ? null : resolveNodeIdForAlias(from_session, effectiveNetId);
2000
2011
  const targetNodeId = target.session?.node_id ?? null;
2001
2012
  const resolvedActualTo = actualTo(target, effectiveNetId);
2002
2013
  // #500 step 2 — what is already queued on the target, read BEFORE this task is written (advice only, never blocks).
@@ -2021,7 +2032,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2021
2032
  touchSql = addScope(touchSql, touchParams, effectiveNetId);
2022
2033
  db.run(touchSql, touchParams);
2023
2034
  // #1548 —— 能派活的发送方不可能真的 blocked;此处出 blocked → idle。
2024
- releaseBlockedSession(from_session, effectiveNetId);
2035
+ if (!humanSender) releaseBlockedSession(from_session, effectiveNetId);
2025
2036
  });
2026
2037
  logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${targetAlias} (parent=${parentTaskId.slice(0,8)})` : `→ ${targetAlias}`);
2027
2038
  // Only stamp the dedup index after the inbox/tasks transaction