@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 +2 -2
- package/src/dashboard-slash-routing-http.test.ts +4 -2
- package/src/db.ts +3 -1
- package/src/node-caller-identity-http.test.ts +96 -0
- package/src/rename.ts +5 -5
- package/src/rest-identity.test.ts +2 -1
- package/src/rest-identity.ts +24 -8
- package/src/server.ts +6 -4
- package/src/task-auth-origin-http.test.ts +3 -1
- package/src/task-network-resolution-http.test.ts +0 -1
- package/src/task-sender-http.test.ts +143 -0
- package/src/tools.ts +19 -4
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
|
}
|
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
|
|
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(() => {
|
|
@@ -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
|
|
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
|
});
|
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";
|
|
@@ -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
|
-
//
|
|
100
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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,
|
|
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 }) => {
|
|
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
|
|
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
|