@sleep2agi/commhub-server 0.8.8-preview.0 → 0.9.0-preview.0
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 +4 -3
- package/src/auth.ts +30 -2
- package/src/cross-tenant-injection.test.ts +225 -0
- package/src/db-adapter.ts +31 -0
- package/src/db.ts +55 -1
- package/src/push.test.ts +383 -2
- package/src/push.ts +293 -43
- package/src/send_dedup.test.ts +18 -0
- package/src/send_dedup.ts +22 -7
- package/src/tools.ts +43 -6
- package/src/uploads.test.ts +20 -0
- package/src/uploads.ts +29 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CommHub Server
|
|
3
|
+
"version": "0.9.0-preview.0",
|
|
4
|
+
"description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"bin": {
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
15
|
"start": "bun run src/index.ts",
|
|
16
|
-
"dev": "bun --watch run src/index.ts"
|
|
16
|
+
"dev": "bun --watch run src/index.ts",
|
|
17
|
+
"test": "COMMHUB_DB=/tmp/test-commhub-$$.db bun test src/"
|
|
17
18
|
},
|
|
18
19
|
"keywords": [
|
|
19
20
|
"commhub",
|
package/src/auth.ts
CHANGED
|
@@ -19,6 +19,14 @@ export interface AuthResult {
|
|
|
19
19
|
token?: string; // user token (utok_)
|
|
20
20
|
network_token?: string; // network token (ntok_) for default network
|
|
21
21
|
network_id?: string;
|
|
22
|
+
// #261 P0-2 (2026-06-28): true when the logged-in user still has the
|
|
23
|
+
// bootstrap-default password. The CLI client uses this to print a
|
|
24
|
+
// prominent "must change password" warning after login succeeds. NOT
|
|
25
|
+
// a login-blocker — we don't lock out anyone whose deployment ran on
|
|
26
|
+
// the old `admin/anethub` default (back-compat per 通信龙 spec). The
|
|
27
|
+
// field is intentionally absent (rather than `false`) on the normal
|
|
28
|
+
// case so old clients don't even see it.
|
|
29
|
+
must_change_password?: boolean;
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
function validatePasswordStrength(password: string, label = "password"): string | null {
|
|
@@ -93,7 +101,7 @@ export function register(username: string, password: string, email?: string, dis
|
|
|
93
101
|
|
|
94
102
|
export function login(username: string, password: string): AuthResult {
|
|
95
103
|
const user = db.get<any>(
|
|
96
|
-
"SELECT user_id, username, password_hash, display_name, email, role FROM users WHERE username = ?1",
|
|
104
|
+
"SELECT user_id, username, password_hash, display_name, email, role, must_change_password FROM users WHERE username = ?1",
|
|
97
105
|
username);
|
|
98
106
|
|
|
99
107
|
if (!user) return { ok: false, error: "invalid username or password" };
|
|
@@ -123,6 +131,9 @@ export function login(username: string, password: string): AuthResult {
|
|
|
123
131
|
user: { user_id: user.user_id, username: user.username, display_name: user.display_name, email: user.email, role: user.role },
|
|
124
132
|
token,
|
|
125
133
|
network_id: networkId,
|
|
134
|
+
// #261 P0-2 — only include field when truthy (back-compat; old clients
|
|
135
|
+
// don't see this field at all unless their account is flagged).
|
|
136
|
+
...(user.must_change_password === 1 ? { must_change_password: true } : {}),
|
|
126
137
|
};
|
|
127
138
|
}
|
|
128
139
|
|
|
@@ -277,11 +288,28 @@ export function changePassword(userId: string, oldPassword: string, newPassword:
|
|
|
277
288
|
const user = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", userId);
|
|
278
289
|
if (!user) return { ok: false, error: "user not found" };
|
|
279
290
|
if (user.password_hash !== hashPassword(oldPassword)) return { ok: false, error: "incorrect current password" };
|
|
280
|
-
|
|
291
|
+
// #261 P0-2 — clearing must_change_password as a side-effect of a real
|
|
292
|
+
// password change. If the user changes via `anet passwd`, the bootstrap
|
|
293
|
+
// nudge goes away on next login. SET to 0 explicitly (rather than skip)
|
|
294
|
+
// so a future flag flip can't drift the state.
|
|
295
|
+
db.run("UPDATE users SET password_hash = ?1, must_change_password = 0, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
|
|
281
296
|
const revoked = revokeOtherUserTokens(userId, currentTokenId);
|
|
282
297
|
return { ok: true, revoked };
|
|
283
298
|
}
|
|
284
299
|
|
|
300
|
+
/**
|
|
301
|
+
* #261 P0-2 — mark a user as needing to change their password on first
|
|
302
|
+
* login. Called by the hub-bootstrap path (`anet hub start`) AFTER it
|
|
303
|
+
* auto-registers the admin with a random bootstrap password. Idempotent.
|
|
304
|
+
* Returns true if a row was affected (i.e. the user exists), false
|
|
305
|
+
* otherwise. NOT exposed via any HTTP endpoint — internal-only, called
|
|
306
|
+
* via direct module import or a local-process SQLite handle.
|
|
307
|
+
*/
|
|
308
|
+
export function markMustChangePassword(userId: string): boolean {
|
|
309
|
+
const r = db.run("UPDATE users SET must_change_password = 1 WHERE user_id = ?1", [userId]);
|
|
310
|
+
return r.changes > 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
285
313
|
export function resetUserPassword(targetUsername: string, callerIsHubAdmin: boolean): { ok: boolean; error?: string; username?: string; user_id?: string; password?: string; token?: string; token_id?: string; revoked?: number } {
|
|
286
314
|
if (!callerIsHubAdmin) return { ok: false, error: "hub admin required" };
|
|
287
315
|
const user = db.get<any>("SELECT user_id, username FROM users WHERE username = ?1", targetUsername);
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Round-5 F1+F2: cross-tenant write/inject guards.
|
|
2
|
+
//
|
|
3
|
+
// All three guards are exercised against a real in-process SQLite
|
|
4
|
+
// (COMMHUB_DB=/tmp/...) so we're not just unit-testing the SQL string,
|
|
5
|
+
// we're proving the rejection actually happens against persisted rows.
|
|
6
|
+
//
|
|
7
|
+
// Setup: two networks (A, B). Create a task in network A with a known
|
|
8
|
+
// task_id. Then drive the three attack vectors below from a session
|
|
9
|
+
// belonging to network B and assert each is blocked.
|
|
10
|
+
//
|
|
11
|
+
// Run with:
|
|
12
|
+
// COMMHUB_DB=/tmp/xtenant-test.db bun test src/cross-tenant-injection.test.ts
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test, beforeEach } from "bun:test";
|
|
15
|
+
import { db, uuidv4, chainReplyToParent } from "./db.js";
|
|
16
|
+
|
|
17
|
+
function insertTask(opts: {
|
|
18
|
+
task_id?: string;
|
|
19
|
+
from_name: string;
|
|
20
|
+
to_name: string;
|
|
21
|
+
network_id: string | null;
|
|
22
|
+
status?: string;
|
|
23
|
+
parent_task_id?: string | null;
|
|
24
|
+
result?: string | null;
|
|
25
|
+
}): string {
|
|
26
|
+
const id = opts.task_id ?? uuidv4();
|
|
27
|
+
db.run(
|
|
28
|
+
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id, parent_task_id, result)
|
|
29
|
+
VALUES (?1, ?2, ?3, 'normal', ?4, 'test content', 'reply', datetime('now'), ?5, ?6, ?7)`,
|
|
30
|
+
[id, opts.from_name, opts.to_name, opts.status ?? "delivered", opts.network_id, opts.parent_task_id ?? null, opts.result ?? null]
|
|
31
|
+
);
|
|
32
|
+
return id;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getTask(taskId: string) {
|
|
36
|
+
return db.get<{ task_id: string; status: string; result: string | null; network_id: string | null }>(
|
|
37
|
+
"SELECT task_id, status, result, network_id FROM tasks WHERE task_id = ?1",
|
|
38
|
+
taskId
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function countInbox(toSession: string): number {
|
|
43
|
+
const row = db.get<{ cnt: number }>(
|
|
44
|
+
"SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1",
|
|
45
|
+
toSession
|
|
46
|
+
);
|
|
47
|
+
return row?.cnt ?? 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
// Clean slate per test so prior fixtures don't leak across runs.
|
|
52
|
+
db.run("DELETE FROM tasks");
|
|
53
|
+
db.run("DELETE FROM inbox");
|
|
54
|
+
db.run("DELETE FROM task_events");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
58
|
+
// F2 #3 — chainReplyToParent cross-network rejection
|
|
59
|
+
//
|
|
60
|
+
// The unit-level test of the most direct vector: caller in network B
|
|
61
|
+
// holds a child task whose `parent_task_id` points at a parent in
|
|
62
|
+
// network A. Without the fix, chainReplyToParent walks the link and
|
|
63
|
+
// writes `result` + an inbox notification into network A. With the
|
|
64
|
+
// fix, the function refuses to traverse and writes nothing.
|
|
65
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
describe("chainReplyToParent — cross-network rejection (round5 F2 #3)", () => {
|
|
68
|
+
test("blocks chain when parent.network_id != callerNetId", () => {
|
|
69
|
+
// Network A: a task that should NEVER receive a foreign reply.
|
|
70
|
+
const parentA = insertTask({
|
|
71
|
+
from_name: "admin-a",
|
|
72
|
+
to_name: "worker-a",
|
|
73
|
+
network_id: "net-A",
|
|
74
|
+
status: "delivered",
|
|
75
|
+
});
|
|
76
|
+
// Network B: a child task that maliciously names parentA as its
|
|
77
|
+
// parent (the attack — possible because send_task wrote a row,
|
|
78
|
+
// and B controls the parent_task_id field of its own dispatch
|
|
79
|
+
// when the F2 #2 guard isn't in place either).
|
|
80
|
+
const childB = insertTask({
|
|
81
|
+
from_name: "attacker-b",
|
|
82
|
+
to_name: "victim-b",
|
|
83
|
+
network_id: "net-B",
|
|
84
|
+
status: "replied",
|
|
85
|
+
parent_task_id: parentA,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Caller is in network B (effectiveNetId = "net-B"). The chain
|
|
89
|
+
// walks child → parentA, finds parent.network_id="net-A" !=
|
|
90
|
+
// caller="net-B", refuses to write.
|
|
91
|
+
chainReplyToParent(childB, "INJECTED PAYLOAD", "replied", 5, "net-B");
|
|
92
|
+
|
|
93
|
+
const after = getTask(parentA);
|
|
94
|
+
expect(after?.result).toBeNull(); // result NOT polluted
|
|
95
|
+
expect(after?.status).toBe("delivered"); // status NOT bumped
|
|
96
|
+
expect(countInbox("admin-a")).toBe(0); // no rogue inbox row
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("legitimate same-network chain still works", () => {
|
|
100
|
+
const parentB = insertTask({
|
|
101
|
+
from_name: "admin-b",
|
|
102
|
+
to_name: "worker-b",
|
|
103
|
+
network_id: "net-B",
|
|
104
|
+
status: "delivered",
|
|
105
|
+
});
|
|
106
|
+
const childB = insertTask({
|
|
107
|
+
from_name: "child-b",
|
|
108
|
+
to_name: "leaf-b",
|
|
109
|
+
network_id: "net-B",
|
|
110
|
+
status: "replied",
|
|
111
|
+
parent_task_id: parentB,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
chainReplyToParent(childB, "legitimate result", "replied", 5, "net-B");
|
|
115
|
+
|
|
116
|
+
const after = getTask(parentB);
|
|
117
|
+
expect(after?.result).toContain("legitimate result");
|
|
118
|
+
expect(after?.status).toBe("replied");
|
|
119
|
+
expect(countInbox("admin-b")).toBe(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("default-network (null) caller cannot chain into named network", () => {
|
|
123
|
+
const parentNamed = insertTask({
|
|
124
|
+
from_name: "admin-x",
|
|
125
|
+
to_name: "worker-x",
|
|
126
|
+
network_id: "net-X",
|
|
127
|
+
status: "delivered",
|
|
128
|
+
});
|
|
129
|
+
const childDefault = insertTask({
|
|
130
|
+
from_name: "stray",
|
|
131
|
+
to_name: "leaf",
|
|
132
|
+
network_id: null,
|
|
133
|
+
status: "replied",
|
|
134
|
+
parent_task_id: parentNamed,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
chainReplyToParent(childDefault, "stray payload", "replied", 5, null);
|
|
138
|
+
|
|
139
|
+
expect(getTask(parentNamed)?.result).toBeNull();
|
|
140
|
+
expect(countInbox("admin-x")).toBe(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("named-network caller cannot chain into default-network parent", () => {
|
|
144
|
+
const parentDefault = insertTask({
|
|
145
|
+
from_name: "admin-default",
|
|
146
|
+
to_name: "worker-default",
|
|
147
|
+
network_id: null,
|
|
148
|
+
status: "delivered",
|
|
149
|
+
});
|
|
150
|
+
const childNamed = insertTask({
|
|
151
|
+
from_name: "named",
|
|
152
|
+
to_name: "leaf",
|
|
153
|
+
network_id: "net-Y",
|
|
154
|
+
status: "replied",
|
|
155
|
+
parent_task_id: parentDefault,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
chainReplyToParent(childNamed, "named payload", "replied", 5, "net-Y");
|
|
159
|
+
|
|
160
|
+
expect(getTask(parentDefault)?.result).toBeNull();
|
|
161
|
+
expect(countInbox("admin-default")).toBe(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("recursive chain stops at the first cross-network boundary", () => {
|
|
165
|
+
// 3-level chain: grandparent (net-A) ← parent (net-B) ← child (net-B)
|
|
166
|
+
// Caller B replies. F2 #3 should write into parent (same net) but
|
|
167
|
+
// refuse to recurse into grandparent (foreign net).
|
|
168
|
+
const grandparent = insertTask({
|
|
169
|
+
from_name: "gp-admin",
|
|
170
|
+
to_name: "gp-worker",
|
|
171
|
+
network_id: "net-A",
|
|
172
|
+
status: "delivered",
|
|
173
|
+
});
|
|
174
|
+
const parent = insertTask({
|
|
175
|
+
from_name: "p-admin",
|
|
176
|
+
to_name: "p-worker",
|
|
177
|
+
network_id: "net-B",
|
|
178
|
+
status: "delivered",
|
|
179
|
+
parent_task_id: grandparent,
|
|
180
|
+
});
|
|
181
|
+
const child = insertTask({
|
|
182
|
+
from_name: "c",
|
|
183
|
+
to_name: "leaf",
|
|
184
|
+
network_id: "net-B",
|
|
185
|
+
status: "replied",
|
|
186
|
+
parent_task_id: parent,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
chainReplyToParent(child, "result", "replied", 5, "net-B");
|
|
190
|
+
|
|
191
|
+
// Parent (same network) — chain wrote.
|
|
192
|
+
expect(getTask(parent)?.result).toContain("result");
|
|
193
|
+
expect(getTask(parent)?.status).toBe("replied");
|
|
194
|
+
// Grandparent (foreign network) — chain refused.
|
|
195
|
+
expect(getTask(grandparent)?.result).toBeNull();
|
|
196
|
+
expect(getTask(grandparent)?.status).toBe("delivered");
|
|
197
|
+
expect(countInbox("gp-admin")).toBe(0);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("undefined callerNetId preserves legacy network-blind behaviour", () => {
|
|
201
|
+
// Internal/server-only callers that have no tenant context can
|
|
202
|
+
// still call chainReplyToParent without the enforcement (they
|
|
203
|
+
// pass undefined). This is the back-compat escape hatch.
|
|
204
|
+
const parent = insertTask({
|
|
205
|
+
from_name: "internal-admin",
|
|
206
|
+
to_name: "internal-worker",
|
|
207
|
+
network_id: "net-A",
|
|
208
|
+
status: "delivered",
|
|
209
|
+
});
|
|
210
|
+
const child = insertTask({
|
|
211
|
+
from_name: "child",
|
|
212
|
+
to_name: "leaf",
|
|
213
|
+
network_id: "net-B",
|
|
214
|
+
status: "replied",
|
|
215
|
+
parent_task_id: parent,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
chainReplyToParent(child, "legacy result", "replied"); // no callerNetId
|
|
219
|
+
|
|
220
|
+
// Legacy behaviour: writes through regardless of network mismatch.
|
|
221
|
+
// Documents the back-compat surface so a future tightening is a
|
|
222
|
+
// deliberate decision.
|
|
223
|
+
expect(getTask(parent)?.result).toContain("legacy result");
|
|
224
|
+
});
|
|
225
|
+
});
|
package/src/db-adapter.ts
CHANGED
|
@@ -216,6 +216,25 @@ export class PgAdapter implements DbAdapter {
|
|
|
216
216
|
* Create the appropriate adapter based on environment.
|
|
217
217
|
* - DATABASE_URL starts with "postgres://" → PgAdapter
|
|
218
218
|
* - Otherwise → SQLiteAdapter with COMMHUB_DB or default path
|
|
219
|
+
*
|
|
220
|
+
* Test-default-prod GUARD: when `NODE_ENV === "test"` (set automatically by
|
|
221
|
+
* `bun test`) AND `COMMHUB_DB` is unset, we REFUSE to fall through to the
|
|
222
|
+
* default `~/.commhub/commhub.db` path. Several modules (notably
|
|
223
|
+
* `server/src/auth.ts register()`) write into the configured database at
|
|
224
|
+
* import-time-side-effect granularity; running their tests against the
|
|
225
|
+
* production hub DB silently created spurious users / networks / tokens
|
|
226
|
+
* on 2026-06-23 (4u / 4net / 8tok by one `bun -e` probe + an unknown
|
|
227
|
+
* pre-existing history of test pollution in the same DB). The guard makes
|
|
228
|
+
* the failure mode loud + actionable instead of silent + destructive.
|
|
229
|
+
*
|
|
230
|
+
* The guard does NOT fire when:
|
|
231
|
+
* - COMMHUB_DB is set (the test runner's documented contract); or
|
|
232
|
+
* - NODE_ENV is unset (production server) or any non-"test" value.
|
|
233
|
+
*
|
|
234
|
+
* Operators who run `bun -e` snippets that touch the DB are expected to
|
|
235
|
+
* `COMMHUB_DB=/tmp/...` themselves — `bun -e` doesn't set NODE_ENV, so the
|
|
236
|
+
* guard can't catch that case. The rule (never read or write the
|
|
237
|
+
* production hub database) is documented in CLAUDE.md.
|
|
219
238
|
*/
|
|
220
239
|
export function createAdapter(): DbAdapter {
|
|
221
240
|
const dbUrl = process.env.DATABASE_URL;
|
|
@@ -226,6 +245,18 @@ export function createAdapter(): DbAdapter {
|
|
|
226
245
|
// Default: SQLite
|
|
227
246
|
const { mkdirSync } = require("fs");
|
|
228
247
|
const { dirname } = require("path");
|
|
248
|
+
|
|
249
|
+
// Test-default-prod GUARD (see docblock above).
|
|
250
|
+
if (process.env.NODE_ENV === "test" && !process.env.COMMHUB_DB) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
"[commhub] REFUSING to open the default SQLite database under NODE_ENV=test.\n" +
|
|
253
|
+
" Tests must explicitly set COMMHUB_DB to a throwaway path so they don't\n" +
|
|
254
|
+
" pollute the production hub DB. Run tests via:\n" +
|
|
255
|
+
" COMMHUB_DB=/tmp/test-$$.db bun test src/\n" +
|
|
256
|
+
" (or use the `npm run test` script which sets this for you)."
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
229
260
|
const dbPath = process.env.COMMHUB_DB || `${process.env.HOME}/.commhub/commhub.db`;
|
|
230
261
|
mkdirSync(dirname(dbPath), { recursive: true });
|
|
231
262
|
console.log(`[commhub] database: ${dbPath}`);
|
package/src/db.ts
CHANGED
|
@@ -231,6 +231,21 @@ db.exec(`
|
|
|
231
231
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
|
232
232
|
`);
|
|
233
233
|
|
|
234
|
+
// #261 P0-2 (2026-06-28): must_change_password flag. Added via ALTER
|
|
235
|
+
// (not in the CREATE block above) so it's a no-op on fresh DBs that
|
|
236
|
+
// already get the column via the CREATE path's full definition AND a
|
|
237
|
+
// pure additive migration on existing prod DBs — column defaults 0
|
|
238
|
+
// for every existing row, so old `admin/anethub` deployments are NOT
|
|
239
|
+
// locked or nudged on upgrade (back-compat per 通信龙 spec). Wrapped
|
|
240
|
+
// in try/catch because SQLite throws "duplicate column" if the column
|
|
241
|
+
// already exists on subsequent restarts (no IF NOT EXISTS syntax for
|
|
242
|
+
// ALTER ADD COLUMN until SQLite 3.35+, can't assume that everywhere).
|
|
243
|
+
try {
|
|
244
|
+
db.exec(`ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0`);
|
|
245
|
+
} catch (e: any) {
|
|
246
|
+
if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
|
|
247
|
+
}
|
|
248
|
+
|
|
234
249
|
// ── V3: networks table ──
|
|
235
250
|
db.exec(`
|
|
236
251
|
CREATE TABLE IF NOT EXISTS networks (
|
|
@@ -734,10 +749,37 @@ export function logAudit(userId: string | null, username: string | null, action:
|
|
|
734
749
|
// 'replied' if it's still open, and post an inbox notification to the parent's
|
|
735
750
|
// originator (so an upstream agent or the dashboard sees the chained answer).
|
|
736
751
|
// Recurse up the chain (in case of N hops).
|
|
737
|
-
|
|
752
|
+
/**
|
|
753
|
+
* Auto-chain a sub-task reply up to the parent task lineage.
|
|
754
|
+
*
|
|
755
|
+
* `callerNetId` (round5 F2 fix): the network the *caller* (send_reply
|
|
756
|
+
* / report_completion) is operating in. When supplied, the chain
|
|
757
|
+
* refuses to traverse into a parent whose `network_id` doesn't match —
|
|
758
|
+
* blocks cross-tenant write where network B replies to a task they've
|
|
759
|
+
* named as parent but which actually lives in network A.
|
|
760
|
+
*
|
|
761
|
+
* Callers that legitimately need the legacy network-blind behavior
|
|
762
|
+
* (e.g. server-internal bookkeeping that doesn't have a tenant
|
|
763
|
+
* context) can omit `callerNetId`, but every MCP tool path MUST
|
|
764
|
+
* supply it.
|
|
765
|
+
*/
|
|
766
|
+
export function chainReplyToParent(
|
|
767
|
+
childTaskId: string,
|
|
768
|
+
replyText: string,
|
|
769
|
+
replyStatus: "replied" | "failed" | "cancelled" = "replied",
|
|
770
|
+
maxDepth = 5,
|
|
771
|
+
callerNetId?: string | null,
|
|
772
|
+
): void {
|
|
738
773
|
let currentChildId: string | null = childTaskId;
|
|
739
774
|
let currentReply = replyText;
|
|
740
775
|
let depth = 0;
|
|
776
|
+
// Normalize so undefined ("don't enforce") stays distinct from
|
|
777
|
+
// null ("explicit default network"). Caller passes undefined to
|
|
778
|
+
// opt out of the cross-tenant check; null to require the parent
|
|
779
|
+
// also live in the default (null) network.
|
|
780
|
+
const enforce = callerNetId !== undefined;
|
|
781
|
+
const callerNorm = callerNetId ?? null;
|
|
782
|
+
|
|
741
783
|
while (currentChildId && depth < maxDepth) {
|
|
742
784
|
depth++;
|
|
743
785
|
type ChildRow = { parent_task_id: string | null; to_name: string; from_name: string; content: string };
|
|
@@ -753,6 +795,18 @@ export function chainReplyToParent(childTaskId: string, replyText: string, reply
|
|
|
753
795
|
);
|
|
754
796
|
if (!parent) return;
|
|
755
797
|
|
|
798
|
+
// round5 F2 fix: refuse to write into a parent that lives in a
|
|
799
|
+
// different network than the caller. Stops chain-reply being a
|
|
800
|
+
// cross-tenant write primitive. Logs once per attempt so ops can
|
|
801
|
+
// see the rejection in the access log.
|
|
802
|
+
if (enforce) {
|
|
803
|
+
const parentNet = parent.network_id ?? null;
|
|
804
|
+
if (parentNet !== callerNorm) {
|
|
805
|
+
console.log(`[commhub] 🚫 chainReplyToParent cross-network blocked: parent=${parent.task_id.slice(0, 8)} parent-net=${parentNet ?? "null"} caller-net=${callerNorm ?? "null"}`);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
756
810
|
const childAlias = child.to_name;
|
|
757
811
|
const marker = `\n\n[via ${childAlias} 子任务结果]\n${currentReply}`;
|
|
758
812
|
const newResult = parent.result ? parent.result + marker : `[via ${childAlias} 子任务结果]\n${currentReply}`;
|