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

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.130",
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).",
3
+ "version": "0.9.0-preview.132",
4
+ "description": "CommHub Server \u2014 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",
7
7
  "bin": {
@@ -14,6 +14,7 @@ const TARGET_RESUME_ID = `resume_dashboard_slash_target_${process.pid}`;
14
14
  let server: any;
15
15
  let base = "";
16
16
  let userToken = "";
17
+ let userName = "";
17
18
  let nodeToken = "";
18
19
  let networkId = "";
19
20
  let requestSequence = 0;
@@ -25,6 +26,7 @@ let requestSequence = 0;
25
26
  beforeAll(async () => {
26
27
  process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
27
28
  const username = `dashboard_slash_${Date.now()}`;
29
+ userName = username;
28
30
  const registered = register(username, "DashboardSlash123!", undefined, "seed");
29
31
  expect(registered.ok).toBe(true);
30
32
  userToken = registered.token!;
@@ -81,7 +83,7 @@ async function postAndRead(token: string, from: string, task: string) {
81
83
  describe("real Hub → authenticated Dashboard slash routing", () => {
82
84
  test("Dashboard /goal and /loop remain exact native payloads for every runtime", async () => {
83
85
  for (const command of ["/goal 更新一下文档", "/loop 5m native loop"] as const) {
84
- const row = await postAndRead(userToken, "dashboard-user", command);
86
+ const row = await postAndRead(userToken, userName, command);
85
87
  expect(row.content).toBe(command);
86
88
  expect(isInteractiveDashboardTask(row)).toBe(true);
87
89
  for (const runtime of ["codex-app-server", "codex", "claude", "grok", "opencode"] as const) {
@@ -92,7 +94,7 @@ describe("real Hub → authenticated Dashboard slash routing", () => {
92
94
 
93
95
  test("Dashboard /agoal and /aloop use the Agent Network scheduler", async () => {
94
96
  for (const command of ["/agoal 5m update docs", "/aloop 5m update docs"] as const) {
95
- const row = await postAndRead(userToken, "dashboard-user", command);
97
+ const row = await postAndRead(userToken, userName, command);
96
98
  expect(isInteractiveDashboardTask(row)).toBe(true);
97
99
  expect(shouldCreateScheduledGoal(row.content, "codex-app-server", true)).toBe(true);
98
100
  }
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
@@ -75,6 +75,92 @@ let p10Owner: Tok;
75
75
  type Sub = { events: any[]; done: boolean; abort: () => void; ready: Promise<void> };
76
76
  const openSubs: Sub[] = [];
77
77
 
78
+ for (const transport of ["mcp", "rest"] as const) {
79
+ for (const shape of ["bound duplicate", "unbound duplicate", "no session", "other network"] as const) {
80
+ test(`node attribution: ${transport} ${shape} retains the credential's node ID`, async () => {
81
+ const key = `${transport}-${shape.replaceAll(" ", "-")}-${stamp}`;
82
+ const alias = `idvsender-${key}`;
83
+ const mine = `n_idv_sender_${key}`;
84
+ const member = register(`idvmem-${key}`, "CallerMember123!", undefined, "seed");
85
+ if (!member.ok || !member.user) throw new Error("member fixture failed");
86
+ const memberId = member.user.user_id;
87
+ const joined = addNetworkMember(NET, memberId, "member", ownerId, { agentAccess: "all" });
88
+ if (!joined.ok) throw new Error(joined.error);
89
+ seedNode(mine, alias, NET, memberId);
90
+ if (shape.includes("duplicate")) {
91
+ const ownerNode = `n_idv_owner_${key}`;
92
+ seedNode(ownerNode, alias, NET, ownerId);
93
+ seedSession(ownerNode, alias, NET); // the active session belongs to a different row
94
+ }
95
+ if (shape === "other network") {
96
+ const foreignNode = `n_idv_foreign_${key}`;
97
+ seedNode(foreignNode, alias, NET2, outsiderId);
98
+ seedSession(foreignNode, alias, NET2);
99
+ }
100
+ const tok = mint(memberId, NET, shape === "bound duplicate" ? victimAlias : alias,
101
+ shape === "unbound duplicate" ? 0 : 2, shape === "unbound duplicate" ? null : mine);
102
+ const content = `sender identity ${key}`;
103
+ const sent = transport === "mcp"
104
+ ? await mcpSend(tok.token, content, undefined)
105
+ : (await api(tok.token, "POST", "/api/task", { alias: sinkAlias, task: content, network_id: NET })).body;
106
+ expect(sent.ok).toBe(true);
107
+ const row = db.get("SELECT from_name, from_node_id, network_id FROM tasks WHERE content = ?1", content);
108
+ expect(row).toEqual({ from_name: alias, from_node_id: mine, network_id: NET });
109
+ });
110
+ }
111
+ }
112
+
113
+ for (const owner of ["own", "another"] as const) {
114
+ test(`rename registry: prepare rejects ${owner} node without a session`, async () => {
115
+ const target = `idvreserved-${owner}-${stamp}`;
116
+ seedNode(`n_idv_reserved_${owner}_${stamp}`, target, NET, owner === "own" ? ownerId : outsiderId);
117
+ const before = db.get("SELECT COUNT(*) AS n FROM rename_txn");
118
+ const result = await api(ownerToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: victimAlias, new_alias: target });
119
+ expect(result.status).toBe(400);
120
+ expect(result.body.ok).toBe(false);
121
+ expect(db.get("SELECT COUNT(*) AS n FROM rename_txn")).toEqual(before);
122
+ expect(sessionSnap(victimAlias, NET)?.node_id).toBe(victimId);
123
+ });
124
+ }
125
+
126
+ test("rename registry: commit rejects a node created after prepare", async () => {
127
+ const target = `idvrace-${stamp}`;
128
+ const prepared = await api(ownerToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: victimAlias, new_alias: target });
129
+ expect(prepared.body.ok).toBe(true);
130
+ const nodeId = `n_idv_race_${stamp}`;
131
+ seedNode(nodeId, target, NET, outsiderId);
132
+ const result = await api(ownerToken, "POST", "/api/node-rename/commit", { txn_id: prepared.body.txn_id });
133
+ expect(result.status).toBe(400);
134
+ expect(result.body.ok).toBe(false);
135
+ expect(sessionSnap(victimAlias, NET)?.node_id).toBe(victimId);
136
+ expect(db.get("SELECT alias FROM nodes WHERE node_id = ?1", nodeId)).toEqual({ alias: target });
137
+ expect(db.get("SELECT status FROM rename_txn WHERE txn_id = ?1", prepared.body.txn_id)).toEqual({ status: "prepared" });
138
+ await api(ownerToken, "POST", "/api/node-rename/abort", { txn_id: prepared.body.txn_id });
139
+ });
140
+
141
+ test("rename registry: another network's node does not reserve the local alias", async () => {
142
+ const target = `idvforeign-target-${stamp}`;
143
+ seedNode(`n_idv_foreign_target_${stamp}`, target, NET2, outsiderId);
144
+ const prepared = await api(ownerToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: victimAlias, new_alias: target });
145
+ expect(prepared.body.ok).toBe(true);
146
+ expect((await api(ownerToken, "POST", "/api/node-rename/abort", { txn_id: prepared.body.txn_id })).body.ok).toBe(true);
147
+ });
148
+
149
+ test("rename registry: a free alias commits and preserves node identity", async () => {
150
+ const alias = `idvrename-old-${stamp}`;
151
+ const target = `idvrename-new-${stamp}`;
152
+ const nodeId = `n_idv_rename_${stamp}`;
153
+ seedNode(nodeId, alias, NET, ownerId);
154
+ seedSession(nodeId, alias, NET);
155
+ const prepared = await api(ownerToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: alias, new_alias: target });
156
+ expect(prepared.body.ok).toBe(true);
157
+ const committed = await api(ownerToken, "POST", "/api/node-rename/commit", { txn_id: prepared.body.txn_id });
158
+ expect(committed.body.ok).toBe(true);
159
+ expect(sessionSnap(target, NET)?.node_id).toBe(nodeId);
160
+ expect(sessionSnap(alias, NET)).toBeNull();
161
+ expect(db.get("SELECT alias FROM nodes WHERE node_id = ?1", nodeId)).toEqual({ alias: target });
162
+ });
163
+
78
164
  function subscribe(path: string, token: string, headers: Record<string, string> = {}): Sub {
79
165
  const ctrl = new AbortController();
80
166
  const sub: Sub = { events: [], done: false, abort: () => ctrl.abort(), ready: Promise.resolve() };
@@ -165,6 +251,16 @@ function minted(userId: string, networkId: string, alias: string, nodeId: string
165
251
  return { token: row.token, tokenId: row.token_id };
166
252
  }
167
253
 
254
+ function seedNode(nodeId: string, alias: string, networkId: string, userId: string) {
255
+ db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id)
256
+ VALUES (?1, ?2, ?2, ?3, ?4)`, [nodeId, alias, networkId, userId]);
257
+ }
258
+
259
+ function seedSession(nodeId: string, alias: string, networkId: string) {
260
+ db.run(`INSERT INTO sessions (resume_id, alias, node_id, network_id, status)
261
+ VALUES (?1, ?2, ?3, ?4, 'idle')`, [`sdk-${nodeId}`, alias, nodeId, networkId]);
262
+ }
263
+
168
264
  function taskCount(content: string): number {
169
265
  return Number(db.get<{ n: number | string }>("SELECT COUNT(*) AS n FROM tasks WHERE content = ?1", content)?.n ?? 0);
170
266
  }
package/src/rename.ts CHANGED
@@ -109,7 +109,7 @@ export function cleanupCommittedRenameSessions(networkIds: string[] | null = nul
109
109
 
110
110
  // PHASE 1 P3 — create a prepared rename_txn row reserving new_alias.
111
111
  // CAS guard (RFC §4 risk #3 TOCTOU): new_alias must be free both in the
112
- // sessions registry AND among in-flight prepared rename_txn rows.
112
+ // sessions/nodes registries AND among in-flight prepared rename_txn rows.
113
113
  export function prepareRename(
114
114
  userId: string, networkId: string, oldAlias: string, newAlias: string,
115
115
  ): RenameResult {
@@ -140,9 +140,9 @@ export function prepareRename(
140
140
  };
141
141
  }
142
142
 
143
- // new-alias must not be taken — in sessions OR reserved by another prepared txn
143
+ // A node can reserve its alias before it has ever reported a session.
144
144
  const newInSessions = db.get<any>(
145
- "SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2", networkId, newAlias);
145
+ "SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2 UNION ALL SELECT 1 FROM nodes WHERE network_id = ?1 AND alias = ?2", networkId, newAlias);
146
146
  if (newInSessions) return { ok: false, error: `alias "${newAlias}" already in use` };
147
147
  const newPrepared = db.get<any>(
148
148
  "SELECT txn_id FROM rename_txn WHERE network_id = ?1 AND new_alias = ?2 AND status = 'prepared'",
@@ -168,10 +168,10 @@ export function commitRename(userId: string, txnId: string): RenameResult {
168
168
  if (txn.status === "aborted") return { ok: false, error: "rename transaction was aborted" };
169
169
  if (!hasWriteAccess(userId, txn.network_id)) return { ok: false, error: "no write access to this network" };
170
170
 
171
- // Defense-in-depth: new-alias still free in sessions (prepare already CAS'd,
171
+ // Defense-in-depth: new-alias still free in both registries (prepare already CAS'd,
172
172
  // but a parallel direct registration could have raced in).
173
173
  const conflict = db.get<any>(
174
- "SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2", txn.network_id, txn.new_alias);
174
+ "SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2 UNION ALL SELECT 1 FROM nodes WHERE network_id = ?1 AND alias = ?2", txn.network_id, txn.new_alias);
175
175
  if (conflict) return { ok: false, error: `alias "${txn.new_alias}" was taken since prepare` };
176
176
 
177
177
  const renamedNode = db.transaction(() => {
@@ -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
  });
@@ -33,7 +33,7 @@ export interface RestIdentityInput {
33
33
  }
34
34
 
35
35
  export type RestIdentityResult =
36
- | { ok: true; fromSession: string }
36
+ | { ok: true; fromSession: string; nodeId?: string | null }
37
37
  | {
38
38
  ok: false;
39
39
  error: "from_session_identity_mismatch";
@@ -50,13 +50,31 @@ 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;
72
+ let nodeId: string | null = null;
56
73
  if (input.token.startsWith("ntok_") && input.tokenId) {
57
74
  const resolved = resolveNodeCaller(input.tokenId);
58
75
  if (resolved.ok) {
59
76
  alias = resolved.alias;
77
+ nodeId = resolved.kind === "node" ? resolved.nodeId : null;
60
78
  } else if (resolved.reason === "not_a_node_token") { // board679-plain-ntok-rest
61
79
  // Name is not `node:` yet. Main signs that as api, and refuses a claim.
62
80
  // not_owner / bound_node_missing / ambiguous stay the hard reject.
@@ -96,16 +114,14 @@ export function resolveRestFromSession(input: RestIdentityInput): RestIdentityRe
96
114
  return { ok: true, fromSession: "api" };
97
115
  }
98
116
 
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.
117
+ // Authenticated human callers are always represented by their username.
118
+ // Keep the unscoped development / legacy transport path separate.
104
119
  if (!alias) {
105
120
  const authenticatedUsername = typeof input.authenticatedUsername === "string"
106
121
  ? input.authenticatedUsername.trim()
107
122
  : "";
108
- return { ok: true, fromSession: requested || authenticatedUsername || "api" };
123
+ if (authenticatedUsername) return resolveUserTaskFromSession(authenticatedUsername, input.requestedFrom);
124
+ return { ok: true, fromSession: requested || "api" };
109
125
  }
110
126
 
111
127
  // Node token claiming somebody else — refuse rather than silently rewrite,
@@ -121,5 +137,5 @@ export function resolveRestFromSession(input: RestIdentityInput): RestIdentityRe
121
137
  }
122
138
 
123
139
  // Node token, no claim or a matching one: bind to the token's own alias.
124
- return { ok: true, fromSession: alias };
140
+ return { ok: true, fromSession: alias, ...(input.tokenId ? { nodeId } : {}) };
125
141
  }
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,11 @@ 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
+ : requestToken(req).startsWith("ntok_")
3699
+ ? (identity.nodeId ?? null)
3700
+ : resolveRestNodeIdForAlias(fromSession, taskNetId);
3699
3701
  const targetNodeId = target.session?.node_id ?? null;
3700
3702
  const actualTo = restActualTo(target, taskNetId);
3701
3703
  // #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";
@@ -57,6 +58,7 @@ import {
57
58
  auditCreateNode,
58
59
  auditCreateNodeStrict,
59
60
  resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
61
+ resolveNodeCaller,
60
62
  } from "./create-node.js";
61
63
  import { decideClone, factsFromRows, parseWorkdirPolicy } from "./clone-node.js";
62
64
  import {
@@ -1829,7 +1831,17 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1829
1831
  meta: z.any().optional().describe("Optional structured task metadata, e.g. { attachments: [{ type, path, url, mime, name, size }] }."),
1830
1832
  force: z.boolean().optional().describe("dispatch even if the target is degraded (user tokens only)"),
1831
1833
  },
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);
1834
+ async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId, parent_task_id: parentIn, meta, force }) => {
1835
+ const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch;
1836
+ const humanSender = !callerTokenIsNetwork && !!enforceUserId;
1837
+ const userIdentity = humanSender ? resolveUserTaskFromSession(callerAlias, _fromIn) : null;
1838
+ if (userIdentity && !userIdentity.ok) {
1839
+ return { content: [{ type: "text" as const, text: JSON.stringify({
1840
+ ok: false, error: userIdentity.error, message: userIdentity.message,
1841
+ token_alias: userIdentity.tokenAlias, requested_from_session: userIdentity.requestedFromSession,
1842
+ }) }] };
1843
+ }
1844
+ const from_session = userIdentity?.ok ? userIdentity.fromSession : defaultFrom(_fromIn);
1833
1845
  const effectiveNetId = getNetworkId(netId);
1834
1846
  const authOrigin: TaskAuthOrigin = callerTokenIsNetwork
1835
1847
  ? "node"
@@ -1865,7 +1877,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1865
1877
  // network A dispatch and chain-reply into the wrong tenant. The
1866
1878
  // explicit-parent path is verified below in F2.
1867
1879
  let parentTaskId: string | null = parentIn ?? null;
1868
- if (!parentTaskId && from_session && from_session !== "hub" && from_session !== "api") {
1880
+ if (!humanSender && !parentTaskId && from_session && from_session !== "hub" && from_session !== "api") {
1869
1881
  try {
1870
1882
  const recentParams: any[] = [from_session];
1871
1883
  let recentSql = "SELECT task_id FROM tasks WHERE to_name = ?1 AND status IN ('delivered')"; // board668-parent-delivered-only
@@ -1996,7 +2008,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1996
2008
  }
1997
2009
 
1998
2010
  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);
2011
+ const senderIdentity = callerTokenIsNetwork ? resolveNodeCaller(callerTokenId) : null;
2012
+ const fromNodeId = humanSender ? null : callerTokenIsNetwork
2013
+ ? (senderIdentity?.ok && senderIdentity.kind === "node" && senderIdentity.networkId === effectiveNetId ? senderIdentity.nodeId : null)
2014
+ : resolveNodeIdForAlias(from_session, effectiveNetId);
2000
2015
  const targetNodeId = target.session?.node_id ?? null;
2001
2016
  const resolvedActualTo = actualTo(target, effectiveNetId);
2002
2017
  // #500 step 2 — what is already queued on the target, read BEFORE this task is written (advice only, never blocks).
@@ -2021,7 +2036,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2021
2036
  touchSql = addScope(touchSql, touchParams, effectiveNetId);
2022
2037
  db.run(touchSql, touchParams);
2023
2038
  // #1548 —— 能派活的发送方不可能真的 blocked;此处出 blocked → idle。
2024
- releaseBlockedSession(from_session, effectiveNetId);
2039
+ if (!humanSender) releaseBlockedSession(from_session, effectiveNetId);
2025
2040
  });
2026
2041
  logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${targetAlias} (parent=${parentTaskId.slice(0,8)})` : `→ ${targetAlias}`);
2027
2042
  // Only stamp the dedup index after the inbox/tasks transaction