@sleep2agi/commhub-server 0.9.0-preview.2 → 0.9.0-preview.21
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/ack-create-request.test.ts +331 -0
- package/src/api-host-supervisors-fallback.test.ts +262 -0
- package/src/api-nodes-shape.test.ts +172 -0
- package/src/auth.ts +3 -1
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-validate.test.ts +187 -0
- package/src/create-node-validate.ts +206 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +346 -0
- package/src/db.ts +196 -0
- package/src/index.ts +216 -3
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/tools.ts +1776 -13
- package/src/update-provider.test.ts +234 -0
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +36 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
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.21",
|
|
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",
|
|
7
7
|
"bin": {
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { registerTools } from "./tools.js";
|
|
5
|
+
|
|
6
|
+
// RFC-026 §9.3 D2 / #338 PR3 — ack_create_request HANDLER tests.
|
|
7
|
+
//
|
|
8
|
+
// 通信龙 pre-review caught B1 + B2: my first cut of PR3 added a new daemon
|
|
9
|
+
// ack status `runtime_capability_check_failed` to create-node-daemon.ts but
|
|
10
|
+
// the hub's zod enum only accepted ["started", "failed", "rejected"] — the
|
|
11
|
+
// new status was silently zod-rejected on the hub side and the daemon's
|
|
12
|
+
// `.catch(()=>{})` swallowed the rejection. The PR body also claimed
|
|
13
|
+
// `daemon_capability_lied` audit was written when in fact the action wasn't
|
|
14
|
+
// in the audit enum and no insert ran.
|
|
15
|
+
//
|
|
16
|
+
// This handler-driven suite drives the registered MCP tool through
|
|
17
|
+
// registerTools() so a daemon's exact ack shape (status + runtime field)
|
|
18
|
+
// flows through the real zod schema + handler branch + audit insert. It
|
|
19
|
+
// would have caught the lane-recurring "single component passes but cross-
|
|
20
|
+
// component path stays broken" failure mode immediately.
|
|
21
|
+
|
|
22
|
+
const NET = "net_ack_t";
|
|
23
|
+
const DAEMON_NODE_ID = "node_ack_daemon";
|
|
24
|
+
const DAEMON_ALIAS = "ack-daemon";
|
|
25
|
+
const DAEMON_USER = "u_ack_owner";
|
|
26
|
+
const DAEMON_TOKEN_ID = "tok_ack_daemon";
|
|
27
|
+
const DAEMON_TOKEN_HASH = "hash_ack_daemon";
|
|
28
|
+
|
|
29
|
+
interface ToolHandler { (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>; }
|
|
30
|
+
interface AckReply { ok?: boolean; error?: string; status?: string; }
|
|
31
|
+
|
|
32
|
+
function cleanup() {
|
|
33
|
+
try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [NET]); } catch {}
|
|
34
|
+
try { db.run("DELETE FROM node_create_requests WHERE network_id = ?1", [NET]); } catch {}
|
|
35
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET]); } catch {}
|
|
36
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET]); } catch {}
|
|
37
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
|
|
38
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
|
|
39
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [DAEMON_USER]); } catch {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeEach(cleanup);
|
|
43
|
+
afterAll(cleanup);
|
|
44
|
+
|
|
45
|
+
function seedDaemonWorld() {
|
|
46
|
+
db.run(
|
|
47
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
48
|
+
VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
49
|
+
[DAEMON_USER, DAEMON_USER],
|
|
50
|
+
);
|
|
51
|
+
db.run(
|
|
52
|
+
`INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at)
|
|
53
|
+
VALUES (?1, ?2, ?3, datetime('now'))`,
|
|
54
|
+
[NET, NET, DAEMON_USER],
|
|
55
|
+
);
|
|
56
|
+
db.run(
|
|
57
|
+
`INSERT INTO network_members (user_id, network_id, role, joined_at)
|
|
58
|
+
VALUES (?1, ?2, 'owner', datetime('now'))`,
|
|
59
|
+
[DAEMON_USER, NET],
|
|
60
|
+
);
|
|
61
|
+
// daemon node — must exist for resolveCallerDaemonTokenBound to bind.
|
|
62
|
+
db.run(
|
|
63
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at)
|
|
64
|
+
VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
|
|
65
|
+
[DAEMON_NODE_ID, DAEMON_ALIAS, DAEMON_ALIAS, NET],
|
|
66
|
+
);
|
|
67
|
+
// daemon ntok — bound to the daemon node via name='node:<alias>'.
|
|
68
|
+
db.run(
|
|
69
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
70
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
71
|
+
[DAEMON_TOKEN_ID, DAEMON_USER, NET, `node:${DAEMON_ALIAS}`, DAEMON_TOKEN_HASH],
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function seedCreateRequest(opts: {
|
|
76
|
+
request_id: string,
|
|
77
|
+
child_token_id?: string,
|
|
78
|
+
status?: "pending" | "delivered" | "succeeded" | "failed",
|
|
79
|
+
child_name?: string,
|
|
80
|
+
runtime?: string,
|
|
81
|
+
}) {
|
|
82
|
+
const childName = opts.child_name ?? `child_${opts.request_id}`;
|
|
83
|
+
if (opts.child_token_id) {
|
|
84
|
+
db.run(
|
|
85
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
86
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
87
|
+
[opts.child_token_id, DAEMON_USER, NET, `node:${childName}`, `hash_${opts.child_token_id}`],
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
db.run(
|
|
91
|
+
`INSERT INTO node_create_requests
|
|
92
|
+
(request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
|
|
93
|
+
VALUES (?1, ?2, ?3, ?4, ?5, 'x', '{}', '[]', ?6, ?7, ?8, ?9)`,
|
|
94
|
+
[
|
|
95
|
+
opts.request_id, DAEMON_NODE_ID, childName, NET,
|
|
96
|
+
opts.runtime ?? "claude-agent-sdk",
|
|
97
|
+
opts.status ?? "delivered",
|
|
98
|
+
opts.child_token_id ?? null,
|
|
99
|
+
Date.now(),
|
|
100
|
+
DAEMON_TOKEN_ID,
|
|
101
|
+
],
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Build a server with registerTools wired as if the daemon's ntok bound. */
|
|
106
|
+
function buildAckHandler(): ToolHandler {
|
|
107
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
108
|
+
const tools: Record<string, ToolHandler> = {};
|
|
109
|
+
const origTool = server.tool.bind(server);
|
|
110
|
+
server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
|
|
111
|
+
tools[name] = handler;
|
|
112
|
+
return origTool(name, _desc, _schema, handler);
|
|
113
|
+
};
|
|
114
|
+
const origRegisterTool = server.registerTool?.bind(server);
|
|
115
|
+
if (origRegisterTool) {
|
|
116
|
+
server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
|
|
117
|
+
tools[name] = handler;
|
|
118
|
+
return origRegisterTool(name, _cfg, handler);
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
registerTools(
|
|
122
|
+
server,
|
|
123
|
+
/* clientIP */ undefined,
|
|
124
|
+
/* enforceNetworkId */ NET,
|
|
125
|
+
/* enforceUserId */ DAEMON_USER,
|
|
126
|
+
/* callerAlias */ DAEMON_ALIAS,
|
|
127
|
+
/* callerTokenIsNetwork */ true,
|
|
128
|
+
/* callerTokenId */ DAEMON_TOKEN_ID,
|
|
129
|
+
);
|
|
130
|
+
const h = tools["ack_create_request"];
|
|
131
|
+
if (!h) throw new Error("ack_create_request tool not registered");
|
|
132
|
+
return h;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function callAck(handler: ToolHandler, args: any): Promise<AckReply> {
|
|
136
|
+
const r = await handler(args);
|
|
137
|
+
return JSON.parse(r.content[0].text) as AckReply;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readRequest(request_id: string) {
|
|
141
|
+
return db.get<{ status: string; error: string | null; acked_at: number | null }>(
|
|
142
|
+
`SELECT status, error, acked_at FROM node_create_requests WHERE request_id = ?1`,
|
|
143
|
+
request_id,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readToken(token_id: string) {
|
|
148
|
+
return db.get<{ revoked_at: string | null }>(
|
|
149
|
+
`SELECT revoked_at FROM api_tokens WHERE token_id = ?1`,
|
|
150
|
+
token_id,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readAuditRows(action: string, target_id: string) {
|
|
155
|
+
return db.all<{ action: string; network_id: string; target_id: string; detail: string }>(
|
|
156
|
+
`SELECT action, network_id, target_id, detail FROM audit_log
|
|
157
|
+
WHERE action = ?1 AND target_id = ?2 ORDER BY id DESC`,
|
|
158
|
+
[action, target_id],
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
describe("ack_create_request HANDLER — B1 schema accepts runtime_capability_check_failed", () => {
|
|
163
|
+
test("schema accepts runtime_capability_check_failed + runtime field (no zod reject)", async () => {
|
|
164
|
+
seedDaemonWorld();
|
|
165
|
+
const reqId = "cr_ack_b1_accept";
|
|
166
|
+
seedCreateRequest({ request_id: reqId });
|
|
167
|
+
const handler = buildAckHandler();
|
|
168
|
+
const reply = await callAck(handler, {
|
|
169
|
+
request_id: reqId,
|
|
170
|
+
status: "runtime_capability_check_failed",
|
|
171
|
+
error: "child died within 5000ms post-spawn",
|
|
172
|
+
runtime: "codex-sdk",
|
|
173
|
+
});
|
|
174
|
+
// PR3 v1 BUG: hub zod enum rejected this status → reply.ok=false /
|
|
175
|
+
// tool would even throw before getting here. Now must accept.
|
|
176
|
+
expect(reply.ok).toBe(true);
|
|
177
|
+
expect(reply.status).toBe("runtime_capability_check_failed");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("request flips to terminal runtime_capability_check_failed status", async () => {
|
|
181
|
+
seedDaemonWorld();
|
|
182
|
+
const reqId = "cr_ack_b1_terminal";
|
|
183
|
+
seedCreateRequest({ request_id: reqId, status: "delivered" });
|
|
184
|
+
const handler = buildAckHandler();
|
|
185
|
+
await callAck(handler, {
|
|
186
|
+
request_id: reqId,
|
|
187
|
+
status: "runtime_capability_check_failed",
|
|
188
|
+
error: "boom",
|
|
189
|
+
runtime: "codex-sdk",
|
|
190
|
+
});
|
|
191
|
+
const row = readRequest(reqId);
|
|
192
|
+
expect(row?.status).toBe("runtime_capability_check_failed");
|
|
193
|
+
expect(row?.error).toBe("boom");
|
|
194
|
+
expect(row?.acked_at).toBeGreaterThan(0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("child ntok is revoked on capability-check-failed (same teardown as 'failed')", async () => {
|
|
198
|
+
seedDaemonWorld();
|
|
199
|
+
const reqId = "cr_ack_b1_revoke";
|
|
200
|
+
const childTok = "tok_ack_child_b1";
|
|
201
|
+
seedCreateRequest({ request_id: reqId, child_token_id: childTok });
|
|
202
|
+
expect(readToken(childTok)?.revoked_at).toBeNull();
|
|
203
|
+
const handler = buildAckHandler();
|
|
204
|
+
await callAck(handler, {
|
|
205
|
+
request_id: reqId,
|
|
206
|
+
status: "runtime_capability_check_failed",
|
|
207
|
+
runtime: "codex-sdk",
|
|
208
|
+
});
|
|
209
|
+
expect(readToken(childTok)?.revoked_at).not.toBeNull();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("ack_create_request HANDLER — B2 audit_log daemon_capability_lied", () => {
|
|
214
|
+
test("writes daemon_capability_lied audit row with runtime + daemon + error in detail", async () => {
|
|
215
|
+
seedDaemonWorld();
|
|
216
|
+
const reqId = "cr_ack_b2_audit";
|
|
217
|
+
seedCreateRequest({ request_id: reqId });
|
|
218
|
+
const handler = buildAckHandler();
|
|
219
|
+
await callAck(handler, {
|
|
220
|
+
request_id: reqId,
|
|
221
|
+
status: "runtime_capability_check_failed",
|
|
222
|
+
error: "OPENAI_API_KEY unset",
|
|
223
|
+
runtime: "codex-sdk",
|
|
224
|
+
});
|
|
225
|
+
const rows = readAuditRows("daemon_capability_lied", reqId);
|
|
226
|
+
// PR3 v1 BUG: action wasn't in auditCreateNode enum → 0 rows + no
|
|
227
|
+
// surface for ops to spot lying daemons. Now must write exactly 1.
|
|
228
|
+
expect(rows.length).toBe(1);
|
|
229
|
+
expect(rows[0].network_id).toBe(NET);
|
|
230
|
+
const detail = JSON.parse(rows[0].detail);
|
|
231
|
+
expect(detail.runtime).toBe("codex-sdk");
|
|
232
|
+
expect(detail.daemon_node_id).toBe(DAEMON_NODE_ID);
|
|
233
|
+
expect(detail.error).toBe("OPENAI_API_KEY unset");
|
|
234
|
+
expect(typeof detail.acked_at).toBe("number");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("does NOT write daemon_capability_lied on generic 'failed' ack (status discriminates)", async () => {
|
|
238
|
+
seedDaemonWorld();
|
|
239
|
+
const reqId = "cr_ack_b2_no_audit_on_failed";
|
|
240
|
+
seedCreateRequest({ request_id: reqId });
|
|
241
|
+
const handler = buildAckHandler();
|
|
242
|
+
await callAck(handler, {
|
|
243
|
+
request_id: reqId,
|
|
244
|
+
status: "failed",
|
|
245
|
+
error: "child died immediately after spawn",
|
|
246
|
+
});
|
|
247
|
+
const rows = readAuditRows("daemon_capability_lied", reqId);
|
|
248
|
+
expect(rows.length).toBe(0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("does NOT write daemon_capability_lied on 'started' (happy path silence)", async () => {
|
|
252
|
+
seedDaemonWorld();
|
|
253
|
+
const reqId = "cr_ack_b2_no_audit_on_started";
|
|
254
|
+
seedCreateRequest({ request_id: reqId });
|
|
255
|
+
const handler = buildAckHandler();
|
|
256
|
+
await callAck(handler, { request_id: reqId, status: "started", child_pid: 12345 });
|
|
257
|
+
const rows = readAuditRows("daemon_capability_lied", reqId);
|
|
258
|
+
expect(rows.length).toBe(0);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("runtime field nullable if daemon omitted it (older daemon back-compat)", async () => {
|
|
262
|
+
// Older daemon (preview.10 and earlier) wouldn't send `runtime`.
|
|
263
|
+
// The hub must still accept + audit, just with runtime=null.
|
|
264
|
+
seedDaemonWorld();
|
|
265
|
+
const reqId = "cr_ack_b2_nullable_runtime";
|
|
266
|
+
seedCreateRequest({ request_id: reqId });
|
|
267
|
+
const handler = buildAckHandler();
|
|
268
|
+
const reply = await callAck(handler, {
|
|
269
|
+
request_id: reqId,
|
|
270
|
+
status: "runtime_capability_check_failed",
|
|
271
|
+
error: "child died",
|
|
272
|
+
});
|
|
273
|
+
expect(reply.ok).toBe(true);
|
|
274
|
+
const rows = readAuditRows("daemon_capability_lied", reqId);
|
|
275
|
+
expect(rows.length).toBe(1);
|
|
276
|
+
const detail = JSON.parse(rows[0].detail);
|
|
277
|
+
expect(detail.runtime).toBeNull();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("error string is truncated to 500 chars in audit detail (defense vs giant payload)", async () => {
|
|
281
|
+
seedDaemonWorld();
|
|
282
|
+
const reqId = "cr_ack_b2_trunc";
|
|
283
|
+
seedCreateRequest({ request_id: reqId });
|
|
284
|
+
const handler = buildAckHandler();
|
|
285
|
+
const giant = "x".repeat(900);
|
|
286
|
+
await callAck(handler, {
|
|
287
|
+
request_id: reqId,
|
|
288
|
+
status: "runtime_capability_check_failed",
|
|
289
|
+
error: giant,
|
|
290
|
+
runtime: "codex-sdk",
|
|
291
|
+
});
|
|
292
|
+
const rows = readAuditRows("daemon_capability_lied", reqId);
|
|
293
|
+
expect(rows.length).toBe(1);
|
|
294
|
+
const detail = JSON.parse(rows[0].detail);
|
|
295
|
+
expect(detail.error.length).toBe(500);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
describe("ack_create_request HANDLER — pre-existing happy-path stays green (regression)", () => {
|
|
300
|
+
test("status='started' still flips acked_at without terminal teardown", async () => {
|
|
301
|
+
seedDaemonWorld();
|
|
302
|
+
const reqId = "cr_ack_started_regression";
|
|
303
|
+
const childTok = "tok_ack_child_started";
|
|
304
|
+
seedCreateRequest({ request_id: reqId, child_token_id: childTok });
|
|
305
|
+
const handler = buildAckHandler();
|
|
306
|
+
const reply = await callAck(handler, { request_id: reqId, status: "started", child_pid: 7777 });
|
|
307
|
+
expect(reply.ok).toBe(true);
|
|
308
|
+
expect(reply.status).toBe("awaiting_register");
|
|
309
|
+
const row = readRequest(reqId);
|
|
310
|
+
// 'started' doesn't flip to terminal; status stays 'delivered'.
|
|
311
|
+
expect(row?.status).toBe("delivered");
|
|
312
|
+
expect(row?.acked_at).toBeGreaterThan(0);
|
|
313
|
+
// child ntok is NOT revoked on 'started'.
|
|
314
|
+
expect(readToken(childTok)?.revoked_at).toBeNull();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("status='failed' still revokes child + flips to failed (no audit_log daemon_capability_lied)", async () => {
|
|
318
|
+
seedDaemonWorld();
|
|
319
|
+
const reqId = "cr_ack_failed_regression";
|
|
320
|
+
const childTok = "tok_ack_child_failed";
|
|
321
|
+
seedCreateRequest({ request_id: reqId, child_token_id: childTok });
|
|
322
|
+
const handler = buildAckHandler();
|
|
323
|
+
const reply = await callAck(handler, { request_id: reqId, status: "failed", error: "fork failed" });
|
|
324
|
+
expect(reply.ok).toBe(true);
|
|
325
|
+
const row = readRequest(reqId);
|
|
326
|
+
expect(row?.status).toBe("failed");
|
|
327
|
+
expect(row?.error).toBe("fork failed");
|
|
328
|
+
expect(readToken(childTok)?.revoked_at).not.toBeNull();
|
|
329
|
+
expect(readAuditRows("daemon_capability_lied", reqId).length).toBe(0);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
@@ -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
|
+
});
|