@sleep2agi/commhub-server 0.9.0-preview.131 → 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.131",
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
  }
@@ -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(() => {
@@ -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";
@@ -69,10 +69,12 @@ export function resolveUserTaskFromSession(username: string | null | undefined,
69
69
  export function resolveRestFromSession(input: RestIdentityInput): RestIdentityResult {
70
70
  const requested = typeof input.requestedFrom === "string" ? input.requestedFrom.trim() : "";
71
71
  let alias: string | null;
72
+ let nodeId: string | null = null;
72
73
  if (input.token.startsWith("ntok_") && input.tokenId) {
73
74
  const resolved = resolveNodeCaller(input.tokenId);
74
75
  if (resolved.ok) {
75
76
  alias = resolved.alias;
77
+ nodeId = resolved.kind === "node" ? resolved.nodeId : null;
76
78
  } else if (resolved.reason === "not_a_node_token") { // board679-plain-ntok-rest
77
79
  // Name is not `node:` yet. Main signs that as api, and refuses a claim.
78
80
  // not_owner / bound_node_missing / ambiguous stay the hard reject.
@@ -135,5 +137,5 @@ export function resolveRestFromSession(input: RestIdentityInput): RestIdentityRe
135
137
  }
136
138
 
137
139
  // Node token, no claim or a matching one: bind to the token's own alias.
138
- return { ok: true, fromSession: alias };
140
+ return { ok: true, fromSession: alias, ...(input.tokenId ? { nodeId } : {}) };
139
141
  }
package/src/server.ts CHANGED
@@ -3695,7 +3695,9 @@ return Bun.serve({
3695
3695
  const ttlSeconds = (body as any).ttl_seconds || 3600;
3696
3696
  const fromNodeId = restAuth && !requestToken(req).startsWith("ntok_")
3697
3697
  ? null
3698
- : resolveRestNodeIdForAlias(fromSession, taskNetId);
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
package/src/tools.ts CHANGED
@@ -58,6 +58,7 @@ import {
58
58
  auditCreateNode,
59
59
  auditCreateNodeStrict,
60
60
  resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
61
+ resolveNodeCaller,
61
62
  } from "./create-node.js";
62
63
  import { decideClone, factsFromRows, parseWorkdirPolicy } from "./clone-node.js";
63
64
  import {
@@ -2007,7 +2008,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2007
2008
  }
2008
2009
 
2009
2010
  console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
2010
- const fromNodeId = humanSender ? null : 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);
2011
2015
  const targetNodeId = target.session?.node_id ?? null;
2012
2016
  const resolvedActualTo = actualTo(target, effectiveNetId);
2013
2017
  // #500 step 2 — what is already queued on the target, read BEFORE this task is written (advice only, never blocks).