@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.8.8-preview.0",
4
- "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
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
- db.run("UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
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
- export function chainReplyToParent(childTaskId: string, replyText: string, replyStatus: "replied" | "failed" | "cancelled" = "replied", maxDepth = 5): void {
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}`;