@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.
|
|
4
|
-
"description": "CommHub Server
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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(() => {
|
package/src/rest-identity.ts
CHANGED
|
@@ -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
|
-
:
|
|
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
|
|
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).
|