@sleep2agi/commhub-server 0.9.0-preview.14 → 0.9.0-preview.20

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.14",
3
+ "version": "0.9.0-preview.20",
4
4
  "description": "CommHub Server — 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",
@@ -0,0 +1,262 @@
1
+ // #380 fix — REST /api/host-supervisors utok→default-network fallback.
2
+ //
3
+ // Before this patch the endpoint hard-4xxed when the client omitted the
4
+ // `network_id` query param. The dashboard's create-node wizard was
5
+ // reported as "hub 400" against a prod hub that had zero daemons AND was
6
+ // being polled without the query — the frontend read that as "no
7
+ // available servers" and blocked the wizard.
8
+ //
9
+ // Post-fix contract:
10
+ // - ntok caller → uses bound network (existing)
11
+ // - utok caller + `?network_id=...` verified → uses that (existing)
12
+ // - utok caller + NO query + 1 accessible net → fallback to that net (200)
13
+ // - utok caller + NO query + 2+ accessible net → 400, error=network_id_required_multi
14
+ // - utok caller + NO query + 0 accessible net → 400, error=missing_network_id
15
+ //
16
+ // The multi-network case is the authz boundary — we refuse to guess
17
+ // which network to list from (per 通信龙 spec "别 fallback 到错 network").
18
+ //
19
+ // Test pattern mirrors uploads-http.test.ts: bind the real Bun.serve
20
+ // server on an ephemeral port with a temp DB, hit it with fetch().
21
+
22
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
23
+ import { mkdtempSync, rmSync } from "fs";
24
+ import { join } from "path";
25
+ import { tmpdir } from "os";
26
+ import { register, login, createNetwork } from "./auth.js";
27
+ import { db } from "./db.js";
28
+
29
+ const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-hs-fallback-db-")) + "/commhub.db";
30
+ const PORT = 18000 + Math.floor(Math.random() * 1000);
31
+ const BASE = `http://127.0.0.1:${PORT}`;
32
+
33
+ // Three users:
34
+ // soloUser — single accessible network (fallback should work)
35
+ // multiUser — two accessible networks (fallback should refuse)
36
+ // orphanUser — zero networks (fallback has nothing to derive)
37
+ let soloToken = "", multiToken = "", orphanToken = "", adminToken = "";
38
+ let soloNetworkId = "";
39
+ let multiNetworkA = "", multiNetworkB = "";
40
+ let soloDaemonAlias = "";
41
+
42
+ beforeAll(async () => {
43
+ process.env.COMMHUB_DB = SERVER_DB;
44
+ process.env.PORT = String(PORT);
45
+ process.env.HOST = "127.0.0.1";
46
+
47
+ const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
48
+ const password = "BootstrapPw123Aa!";
49
+
50
+ // Pre-register a first user so subsequent test users are NOT auto-admin.
51
+ // resolveRestNetworkScope's admin branch bypasses fallback (admin can
52
+ // legitimately query any network), which would make solo's no-query
53
+ // case a false-negative for our fix contract.
54
+ const seed = register(`seed_admin_${suffix}`, password, undefined, "seed");
55
+ if (!seed.ok || !seed.token) throw new Error("seed admin failed: " + JSON.stringify(seed));
56
+ // Save the admin token so path 7 can hit /api/host-supervisors as an
57
+ // admin directly (通信龙 review point 1: verify admin+no-query goes 400
58
+ // explicitly, not only implicitly via seed_admin's shadow presence).
59
+ adminToken = seed.token;
60
+
61
+ // Solo user — register() auto-creates a default network; nothing to add.
62
+ const solo = register(`solo_${suffix}`, password, undefined, "seed");
63
+ if (!solo.ok || !solo.token) throw new Error("solo register failed: " + JSON.stringify(solo));
64
+ soloToken = solo.token;
65
+ soloNetworkId = solo.network_id ?? "";
66
+ if (!soloNetworkId) throw new Error("solo default network missing");
67
+
68
+ // Multi user — register + create a second owned network.
69
+ const multi = register(`multi_${suffix}`, password, undefined, "seed");
70
+ if (!multi.ok || !multi.token) throw new Error("multi register failed: " + JSON.stringify(multi));
71
+ multiToken = multi.token;
72
+ multiNetworkA = multi.network_id ?? "";
73
+ if (!multiNetworkA) throw new Error("multi default network missing");
74
+ const authCtx = login(`multi_${suffix}`, password);
75
+ const secondNet: any = createNetwork(authCtx.user!.user_id, `multi_second_${suffix}`);
76
+ if (!secondNet.ok || !secondNet.network_id) throw new Error("multi second-net create failed: " + JSON.stringify(secondNet));
77
+ multiNetworkB = secondNet.network_id;
78
+
79
+ // Orphan user — register then delete membership in the default net so
80
+ // the user has zero networks. Direct DB manipulation is fine here;
81
+ // we're modeling an edge case the production API would never let a
82
+ // normal user hit (they'd always keep at least their default), but
83
+ // the endpoint contract still needs to be locked.
84
+ const orphan = register(`orphan_${suffix}`, password, undefined, "seed");
85
+ if (!orphan.ok || !orphan.token) throw new Error("orphan register failed: " + JSON.stringify(orphan));
86
+ orphanToken = orphan.token;
87
+ const orphanUserId = login(`orphan_${suffix}`, password).user!.user_id;
88
+ db.run("DELETE FROM network_members WHERE user_id = ?1", [orphanUserId]);
89
+
90
+ // 通信龙 review point 2: path-2's fallback response must land the
91
+ // daemon list *from soloNetworkId specifically*, not from the whole
92
+ // sessions/nodes table. The original test-2 asserted daemons was an
93
+ // array but a clean fixture (0 daemons in the DB) would trivially
94
+ // satisfy that. Insert:
95
+ // (a) a host_supervisor daemon in soloNetworkId — should show up in
96
+ // fallback response
97
+ // (b) a host_supervisor daemon in multiNetworkA — should NOT show
98
+ // up in solo's fallback (cross-tenant isolation lock)
99
+ soloDaemonAlias = `solo_daemon_${suffix}`;
100
+ seedHostSupervisorDaemon({
101
+ alias: soloDaemonAlias,
102
+ networkId: soloNetworkId,
103
+ userIdForToken: login(`solo_${suffix}`, password).user!.user_id,
104
+ });
105
+ seedHostSupervisorDaemon({
106
+ alias: `other_daemon_${suffix}`,
107
+ networkId: multiNetworkA,
108
+ userIdForToken: login(`multi_${suffix}`, password).user!.user_id,
109
+ });
110
+
111
+ // Import triggers Bun.serve at module load — this IS the server start.
112
+ await import("./index.js");
113
+ await new Promise((r) => setTimeout(r, 100));
114
+ });
115
+
116
+ // Insert a minimal `nodes` row + `api_tokens` row so the daemon shows up
117
+ // in /api/host-supervisors' JOIN-then-role-filter query (index.ts
118
+ // GET /api/host-supervisors handler, at the SELECT ... FROM nodes n ...
119
+ // EXISTS (SELECT 1 FROM api_tokens ...) branch). The api_tokens row
120
+ // with name='node:<alias>' + revoked_at IS NULL is the "still active"
121
+ // witness — without it EXISTS drops the row.
122
+ function seedHostSupervisorDaemon(opts: {
123
+ alias: string;
124
+ networkId: string;
125
+ userIdForToken: string;
126
+ }) {
127
+ const nodeId = `n_test_${opts.alias}`;
128
+ db.run(
129
+ `INSERT OR REPLACE INTO nodes (
130
+ node_id, node_name, alias, runtime, model, config_path,
131
+ channels, server, hostname, network_id,
132
+ config_revision, config_snapshot
133
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`,
134
+ [nodeId, opts.alias, opts.alias, "claude-agent-sdk", "claude-sonnet-4-5",
135
+ "/tmp/cfg.json", "[]", "test-host", "test-host", opts.networkId,
136
+ 0, JSON.stringify({ role: "host_supervisor", daemon_capabilities: {} })],
137
+ );
138
+ db.run(
139
+ `INSERT OR REPLACE INTO api_tokens (
140
+ token_id, token_hash, user_id, network_id, name, scope
141
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
142
+ [`tok_test_${opts.alias}`, `hash_test_${opts.alias}`,
143
+ opts.userIdForToken, opts.networkId, `node:${opts.alias}`, "network"],
144
+ );
145
+ }
146
+
147
+ afterAll(() => {
148
+ try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
149
+ });
150
+
151
+ async function get(path: string, token: string): Promise<{ status: number; body: any }> {
152
+ const res = await fetch(`${BASE}${path}`, {
153
+ headers: { "Authorization": `Bearer ${token}` },
154
+ });
155
+ const body = await res.json().catch(() => null);
156
+ return { status: res.status, body };
157
+ }
158
+
159
+ describe("#380 — /api/host-supervisors utok→default-network fallback", () => {
160
+ test("path 1 — utok with 1 network, WITH ?network_id → 200 (existing behavior)", async () => {
161
+ const r = await get(`/api/host-supervisors?network_id=${soloNetworkId}`, soloToken);
162
+ expect(r.status).toBe(200);
163
+ expect(r.body?.ok).toBe(true);
164
+ expect(Array.isArray(r.body?.daemons)).toBe(true);
165
+ expect(typeof r.body?.count).toBe("number");
166
+ });
167
+
168
+ test("path 2 — utok with 1 network, NO ?network_id → 200 (fallback lands AND scopes correctly)", async () => {
169
+ // This is the core #380 fix: dashboard forgot the query but the user
170
+ // only has one network anyway, so we can safely fall back to it.
171
+ //
172
+ // 通信龙 review point 2: harden the assertion beyond "daemons is an
173
+ // array". We seeded one daemon in soloNetworkId and one in
174
+ // multiNetworkA (multi user); the fallback response MUST include the
175
+ // solo one and NOT the multi one. Otherwise a future regression that
176
+ // wired the fallback to "all networks the user knows about" would
177
+ // slip through — cross-tenant leak.
178
+ const r = await get(`/api/host-supervisors`, soloToken);
179
+ expect(r.status).toBe(200);
180
+ expect(r.body?.ok).toBe(true);
181
+ expect(Array.isArray(r.body?.daemons)).toBe(true);
182
+
183
+ const aliases: string[] = r.body.daemons.map((d: any) => d.alias);
184
+ expect(aliases).toContain(soloDaemonAlias);
185
+ // Belt: nothing from the multi user's network snuck through.
186
+ expect(aliases.every((a: string) => !a.startsWith("other_daemon_"))).toBe(true);
187
+ // Every returned daemon's daemon_node_id should be one this user can
188
+ // legitimately see — locking the shape end-to-end.
189
+ for (const d of r.body.daemons) {
190
+ expect(typeof d.daemon_node_id).toBe("string");
191
+ expect(typeof d.alias).toBe("string");
192
+ }
193
+ });
194
+
195
+ test("path 3 — utok with 2 networks, NO ?network_id → 400 network_id_required_multi", async () => {
196
+ // Authz boundary: refuse to guess which network to list. Distinguishing
197
+ // error field lets the client tell the user "pick a network" vs
198
+ // "you're not in any networks".
199
+ const r = await get(`/api/host-supervisors`, multiToken);
200
+ expect(r.status).toBe(400);
201
+ expect(r.body?.ok).toBe(false);
202
+ expect(r.body?.error).toBe("network_id_required_multi");
203
+ expect(r.body?.memberships).toBe(2);
204
+ });
205
+
206
+ test("path 4 — utok with 2 networks, WITH ?network_id → 200 (explicit choice honored)", async () => {
207
+ const r = await get(`/api/host-supervisors?network_id=${multiNetworkA}`, multiToken);
208
+ expect(r.status).toBe(200);
209
+ expect(r.body?.ok).toBe(true);
210
+ const r2 = await get(`/api/host-supervisors?network_id=${multiNetworkB}`, multiToken);
211
+ expect(r2.status).toBe(200);
212
+ expect(r2.body?.ok).toBe(true);
213
+ });
214
+
215
+ test("path 5 — utok with 2 networks, WITH ?network_id (not a member) → denied by scope layer", async () => {
216
+ // Requesting a network the user isn't in must never leak — this is
217
+ // enforced upstream at resolveRestNetworkScope, but re-lock here.
218
+ const foreignNet = "net_absolutely_not_a_member";
219
+ const r = await get(`/api/host-supervisors?network_id=${foreignNet}`, multiToken);
220
+ expect(r.status).toBe(403);
221
+ });
222
+
223
+ test("path 7 — admin utok, NO ?network_id → 400 (admin can span every network; no cross-network guessing)", async () => {
224
+ // 通信龙 review point 1: the previous suite only asserted admin
225
+ // indirectly (seed_admin was created first so other test users
226
+ // weren't auto-admin). This test hits /api/host-supervisors with
227
+ // seed_admin's own utok — resolveRestNetworkScope's isAdmin branch
228
+ // returns networkIds:null, singleNetworkId returns null, endpoint
229
+ // 400s. The response distinguishes it from the multi-network case
230
+ // via `memberships` being absent-or-0 (admin doesn't populate
231
+ // scope.networkIds), so the client sees an explicit "you're admin,
232
+ // pass network_id" affordance.
233
+ const r = await get(`/api/host-supervisors`, adminToken);
234
+ expect(r.status).toBe(400);
235
+ expect(r.body?.ok).toBe(false);
236
+ // Not the multi-network error — admin path goes through the "no
237
+ // scope.networkIds" branch → memberships defaults to 0 (the ??
238
+ // coalesce in the handler). This asymmetry lets the client
239
+ // distinguish "you own too many" from "you can see all of them".
240
+ expect(r.body?.error).toBe("missing_network_id");
241
+ });
242
+
243
+ test("path 8 — admin utok, WITH explicit ?network_id → 200 (admin can inspect any network)", async () => {
244
+ // Complement to path 7: even without membership in a target network,
245
+ // admin can query it. Locks resolveRestNetworkScope's isAdmin =>
246
+ // {networkId: requested, networkIds: null} branch.
247
+ const r = await get(`/api/host-supervisors?network_id=${multiNetworkA}`, adminToken);
248
+ expect(r.status).toBe(200);
249
+ expect(r.body?.ok).toBe(true);
250
+ // The daemon we seeded in multiNetworkA must be reachable.
251
+ const aliases: string[] = r.body.daemons.map((d: any) => d.alias);
252
+ expect(aliases.some(a => a.startsWith("other_daemon_"))).toBe(true);
253
+ });
254
+
255
+ test("path 6 — utok with 0 networks, NO ?network_id → 400 missing_network_id (memberships=0)", async () => {
256
+ const r = await get(`/api/host-supervisors`, orphanToken);
257
+ expect(r.status).toBe(400);
258
+ expect(r.body?.ok).toBe(false);
259
+ expect(r.body?.error).toBe("missing_network_id");
260
+ expect(r.body?.memberships).toBe(0);
261
+ });
262
+ });
package/src/index.ts CHANGED
@@ -78,7 +78,7 @@ function normalizeMetaJson(meta: unknown): string | null {
78
78
  // #222 cross-host safety — same sanitization as tools.ts so REST
79
79
  // /api/task and MCP send_task produce identical meta_json shapes.
80
80
  // Conditional: only strips `path` when `file_id` is also present
81
- // (preserves single-host feishu fallback per [[feedback_assume_unit_before_threshold]]
81
+ // (preserves single-host feishu fallback per team rule — assume-unit-before-threshold: cap is in bytes
82
82
  // discipline of not breaking other call sites).
83
83
  try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
84
84
  }
@@ -1992,10 +1992,28 @@ Bun.serve({
1992
1992
  // Same SQL + role-extract + member-脱敏 logic; SEC-1 scoped via REST
1993
1993
  // auth pipeline (restScope). Returns {ok, daemons:[...], count}.
1994
1994
  if (url.pathname === "/api/host-supervisors") {
1995
- const netParam = url.searchParams.get("network_id");
1996
- const effectiveNetId = restScope.networkId ?? netParam ?? null;
1995
+ // #380 — utok callers with exactly one accessible network get a
1996
+ // safe fallback so the create-node wizard doesn't have to bake
1997
+ // network_id into every request (it was hard-4xxing when the
1998
+ // dashboard omitted the query param — that's what "hub 400" was).
1999
+ // `singleNetworkId` returns:
2000
+ // - scope.networkId when it's set (ntok binding, or an
2001
+ // explicitly-requested network the user has verified access to)
2002
+ // - the sole member of scope.networkIds when the utok user
2003
+ // belongs to exactly one network (safe unambiguous fallback)
2004
+ // - null when the user belongs to 0 or 2+ networks (authz
2005
+ // boundary — refuse to guess which one, mirrors 通信龙 spec
2006
+ // "别 fallback 到错 network")
2007
+ // The 400 branch is retained for the ambiguous / no-access cases
2008
+ // and distinguishes them in the error message so the client can
2009
+ // recover (send network_id explicitly).
2010
+ const effectiveNetId = singleNetworkId(restScope);
1997
2011
  if (!effectiveNetId) {
1998
- return withCors(req, Response.json({ ok: false, error: "missing_network_id" }, { status: 400 }));
2012
+ const memberships = restScope.networkIds?.length ?? 0;
2013
+ const error = memberships > 1
2014
+ ? "network_id_required_multi"
2015
+ : "missing_network_id";
2016
+ return withCors(req, Response.json({ ok: false, error, memberships }, { status: 400 }));
1999
2017
  }
2000
2018
  // Role for member-脱敏 (admin/owner = full host_telemetry, others = masked)
2001
2019
  let isPrivileged = false;
@@ -12,7 +12,7 @@
12
12
  //
13
13
  // PR1.2a (#346 follow-up) extracts the helper to this module so both
14
14
  // the MCP tools and the REST handlers can import the same code path,
15
- // per [[feedback_grep_all_sites_before_apply_guard]]: any SQL-level
15
+ // per team rule (grep every write site before adding a guard): any SQL-level
16
16
  // guard's helper lives at module scope so every write site
17
17
  // (MCP + REST + internal db.ts helpers) can use it.
18
18
  //
@@ -7,7 +7,7 @@ import { registerTools } from "./tools.js";
7
7
  // get_stop_request, ack_stop_request. Drives the MCP handlers via
8
8
  // registerTools() so the wired auth context is real (not the
9
9
  // mirror-SQL pattern that PR2 v1 used and which let SEC-1 leak slip
10
- // through, per [[feedback_parallel_fork_review_catches_wired_wrong]]).
10
+ // through — per team rule: independent fork-review catches wired-wrong even when unit tests pass).
11
11
  //
12
12
  // Covers RFC-027 P1 e2e plan §5.2 scenarios A/B/C/D/E/F at the
13
13
  // handler layer:
package/src/tools.ts CHANGED
@@ -155,7 +155,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
155
155
  // (#346 review catch) extracted because the closure scope made it
156
156
  // unreachable from REST and left the §2.3 race open on dashboard
157
157
  // Dispatch (POST /api/task + /api/broadcast). Per
158
- // [[feedback_grep_all_sites_before_apply_guard]].
158
+ // per team rule: grep every write site (MCP tools.ts AND REST index.ts) before adding a guard, and extract the helper into a shared module so both transports import it.
159
159
 
160
160
  const addReadScope = (sql: string, params: any[], scope: ReadScope, column = "network_id"): string => {
161
161
  if (scope.networkId) {
@@ -3464,7 +3464,7 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
3464
3464
  // The snapshot stays the source of truth for non-list reads; the
3465
3465
  // columns exist so `list_host_supervisors` doesn't JSON.parse on
3466
3466
  // every call. typeof-narrow per
3467
- // [[feedback_typeof_narrow_extracted_fields]] — zod narrowed but
3467
+ // per team rule (typeof-narrow extracted JSON fields at the boundary) — zod narrowed but
3468
3468
  // input.config_snapshot is typed `unknown` here.
3469
3469
  //
3470
3470
  // PR3 nit ①: read from nested `daemon_capabilities.*` (canonical