@sleep2agi/commhub-server 0.9.0-preview.1 → 0.9.0-preview.13
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/ack-create-request.test.ts +331 -0
- package/src/api-nodes-shape.test.ts +172 -0
- package/src/auth.ts +3 -1
- package/src/config-apply-sec1.test.ts +163 -1
- 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 +193 -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 +1741 -10
- 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.13",
|
|
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": {
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
50
50
|
"bun-types": "^1.3.13",
|
|
51
|
-
"hono": "^4.12.25"
|
|
51
|
+
"hono": "^4.12.25",
|
|
52
|
+
"zod": "^4.4.3"
|
|
52
53
|
}
|
|
53
54
|
}
|
|
@@ -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,172 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
|
|
4
|
+
// Regression test for SELECT * fragility on GET /api/nodes.
|
|
5
|
+
//
|
|
6
|
+
// Prior behavior: `SELECT * FROM nodes` returned whatever columns the
|
|
7
|
+
// table happened to have. When RFC-024 added config_revision +
|
|
8
|
+
// config_snapshot columns (read by the per-node GET /api/nodes/:id/config
|
|
9
|
+
// endpoint, NOT meant for the list endpoint), they silently leaked into
|
|
10
|
+
// the list response and broke V3 Networks tests that asserted on the
|
|
11
|
+
// nodes-list row shape.
|
|
12
|
+
//
|
|
13
|
+
// Current behavior: explicit column list — the response contract is:
|
|
14
|
+
// node_id, node_name, alias, runtime, model, config_path, channels,
|
|
15
|
+
// server, hostname, network_id, created_at, updated_at
|
|
16
|
+
// This test runs the same SQL the handler uses, on a row that DOES have
|
|
17
|
+
// config_revision + config_snapshot populated, and asserts those fields
|
|
18
|
+
// do NOT appear in the returned row. If anyone reverts the handler to
|
|
19
|
+
// SELECT *, or adds an internal column to the SELECT, this test breaks.
|
|
20
|
+
|
|
21
|
+
const TEST_NETWORK = "net_test_shape";
|
|
22
|
+
const TEST_NODE = "node_test_shape_abc";
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
// Best-effort cleanup (test runs in shared test DB; rows are isolated by id)
|
|
26
|
+
try { db.run("DELETE FROM nodes WHERE node_id = ?1", [TEST_NODE]); } catch {}
|
|
27
|
+
});
|
|
28
|
+
afterAll(() => {
|
|
29
|
+
try { db.run("DELETE FROM nodes WHERE node_id = ?1", [TEST_NODE]); } catch {}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Helper: mirror handler's role-extraction pass to mimic the production
|
|
33
|
+
// response shape exactly. Keeps the test the source of truth for the
|
|
34
|
+
// contract; if the handler changes, the assertions still lock what
|
|
35
|
+
// the dashboard / external clients see.
|
|
36
|
+
function mapRow(r: Record<string, unknown>): Record<string, unknown> {
|
|
37
|
+
let role: string | null = null;
|
|
38
|
+
const snap = r.config_snapshot;
|
|
39
|
+
if (snap) {
|
|
40
|
+
try { role = (typeof snap === "string" ? JSON.parse(snap) : snap)?.role ?? null; }
|
|
41
|
+
catch { /* malformed */ }
|
|
42
|
+
}
|
|
43
|
+
const { config_snapshot, ...rest } = r;
|
|
44
|
+
return { ...rest, role };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", () => {
|
|
48
|
+
test("explicit columns + role extracted: config_snapshot NOT in response, role IS", () => {
|
|
49
|
+
// Insert a row with the leak-prone columns populated. Use INSERT OR
|
|
50
|
+
// REPLACE so concurrent test runs don't trip on PRIMARY KEY.
|
|
51
|
+
db.run(
|
|
52
|
+
`INSERT OR REPLACE INTO nodes (
|
|
53
|
+
node_id, node_name, alias, runtime, model, config_path,
|
|
54
|
+
channels, server, hostname, network_id,
|
|
55
|
+
config_revision, config_snapshot
|
|
56
|
+
) VALUES (
|
|
57
|
+
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12
|
|
58
|
+
)`,
|
|
59
|
+
[TEST_NODE, "test-shape", "test-shape", "claude-agent-sdk", "claude-opus",
|
|
60
|
+
"/tmp/cfg.json", "[]", "localhost", "test-host", TEST_NETWORK,
|
|
61
|
+
7, JSON.stringify({ model: "claude-opus", flags: { x: true }, role: "host_supervisor" })],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Mirror handler SQL + mapping
|
|
65
|
+
const raw = db.all<Record<string, unknown>>(
|
|
66
|
+
`SELECT node_id, node_name, alias, runtime, model,
|
|
67
|
+
config_path, channels, server, hostname,
|
|
68
|
+
network_id, created_at, updated_at,
|
|
69
|
+
config_snapshot
|
|
70
|
+
FROM nodes WHERE node_id = ?1`,
|
|
71
|
+
TEST_NODE,
|
|
72
|
+
);
|
|
73
|
+
const rows = raw.map(mapRow);
|
|
74
|
+
|
|
75
|
+
expect(rows).toHaveLength(1);
|
|
76
|
+
const row = rows[0]!;
|
|
77
|
+
// Expected fields present (13 = 12 contract + new role)
|
|
78
|
+
for (const k of ["node_id", "node_name", "alias", "runtime", "model",
|
|
79
|
+
"config_path", "channels", "server", "hostname",
|
|
80
|
+
"network_id", "created_at", "updated_at", "role"]) {
|
|
81
|
+
expect(row).toHaveProperty(k);
|
|
82
|
+
}
|
|
83
|
+
// Internal fields NOT present
|
|
84
|
+
expect(row).not.toHaveProperty("config_revision");
|
|
85
|
+
expect(row).not.toHaveProperty("config_snapshot");
|
|
86
|
+
// role is EXTRACTED from config_snapshot JSON, not the raw blob
|
|
87
|
+
expect(row.role).toBe("host_supervisor");
|
|
88
|
+
// Belt: full key set is exactly the 13 contract fields
|
|
89
|
+
expect(Object.keys(row).sort()).toEqual([
|
|
90
|
+
"alias", "channels", "config_path", "created_at", "hostname",
|
|
91
|
+
"model", "network_id", "node_id", "node_name", "role", "runtime",
|
|
92
|
+
"server", "updated_at",
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("role is null when config_snapshot is empty / missing / malformed", () => {
|
|
97
|
+
// Empty snapshot → null
|
|
98
|
+
db.run(
|
|
99
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
|
|
100
|
+
VALUES (?1, ?2, ?3, ?4, NULL)`,
|
|
101
|
+
[TEST_NODE, "test-shape", "test-shape", TEST_NETWORK],
|
|
102
|
+
);
|
|
103
|
+
let row = mapRow(db.get<Record<string, unknown>>(
|
|
104
|
+
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
105
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
106
|
+
FROM nodes WHERE node_id = ?1`,
|
|
107
|
+
TEST_NODE,
|
|
108
|
+
)!);
|
|
109
|
+
expect(row.role).toBeNull();
|
|
110
|
+
expect(row).not.toHaveProperty("config_snapshot");
|
|
111
|
+
|
|
112
|
+
// Malformed JSON → null (handler doesn't throw, just leaves role null)
|
|
113
|
+
db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`, ["not-json{", TEST_NODE]);
|
|
114
|
+
row = mapRow(db.get<Record<string, unknown>>(
|
|
115
|
+
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
116
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
117
|
+
FROM nodes WHERE node_id = ?1`,
|
|
118
|
+
TEST_NODE,
|
|
119
|
+
)!);
|
|
120
|
+
expect(row.role).toBeNull();
|
|
121
|
+
|
|
122
|
+
// Snapshot without role key → null
|
|
123
|
+
db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
|
|
124
|
+
[JSON.stringify({ model: "x", flags: {} }), TEST_NODE]);
|
|
125
|
+
row = mapRow(db.get<Record<string, unknown>>(
|
|
126
|
+
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
127
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
128
|
+
FROM nodes WHERE node_id = ?1`,
|
|
129
|
+
TEST_NODE,
|
|
130
|
+
)!);
|
|
131
|
+
expect(row.role).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("daemon discovery use-case: dashboard finds role=host_supervisor without prefix heuristic", () => {
|
|
135
|
+
// Daemon registered with arbitrary node_id (NO `node_daemon_` prefix)
|
|
136
|
+
db.run(
|
|
137
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
|
|
138
|
+
VALUES (?1, ?2, ?3, ?4, ?5)`,
|
|
139
|
+
[TEST_NODE, "my-daemon", "my-daemon", TEST_NETWORK,
|
|
140
|
+
JSON.stringify({ role: "host_supervisor", runtime: "claude-agent-sdk" })],
|
|
141
|
+
);
|
|
142
|
+
const rows = db.all<Record<string, unknown>>(
|
|
143
|
+
`SELECT node_id, node_name, alias, runtime, model, config_path, channels,
|
|
144
|
+
server, hostname, network_id, created_at, updated_at, config_snapshot
|
|
145
|
+
FROM nodes WHERE network_id = ?1`,
|
|
146
|
+
TEST_NETWORK,
|
|
147
|
+
).map(mapRow);
|
|
148
|
+
// Dashboard's lookup: find first node with role==='host_supervisor'
|
|
149
|
+
const daemon = rows.find(r => r.role === "host_supervisor");
|
|
150
|
+
expect(daemon).toBeTruthy();
|
|
151
|
+
expect(daemon!.node_id).toBe(TEST_NODE);
|
|
152
|
+
// Even though node_id does NOT start with `node_daemon_` (the prior heuristic)
|
|
153
|
+
expect(String(daemon!.node_id).startsWith("node_daemon_")).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("internal columns are still readable via dedicated query (config snapshot endpoint path)", () => {
|
|
157
|
+
db.run(
|
|
158
|
+
`INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id,
|
|
159
|
+
config_revision, config_snapshot)
|
|
160
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
|
|
161
|
+
[TEST_NODE, "test-shape", "test-shape", TEST_NETWORK,
|
|
162
|
+
42, JSON.stringify({ model: "claude-opus" })],
|
|
163
|
+
);
|
|
164
|
+
// Mirrors the SELECT in GET /api/nodes/:id/config (server/src/index.ts):
|
|
165
|
+
const cfgRow = db.get<{ config_revision: number; config_snapshot: string }>(
|
|
166
|
+
"SELECT config_revision, config_snapshot FROM nodes WHERE node_id = ?1",
|
|
167
|
+
TEST_NODE,
|
|
168
|
+
);
|
|
169
|
+
expect(cfgRow?.config_revision).toBe(42);
|
|
170
|
+
expect(cfgRow?.config_snapshot).toContain("claude-opus");
|
|
171
|
+
});
|
|
172
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -211,7 +211,9 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
|
|
|
211
211
|
`SELECT t.token_id, t.user_id, t.network_id, t.scope, t.name AS token_name,
|
|
212
212
|
u.username, u.display_name, u.email, u.role
|
|
213
213
|
FROM api_tokens t JOIN users u ON t.user_id = u.user_id
|
|
214
|
-
WHERE t.token_hash = ?1
|
|
214
|
+
WHERE t.token_hash = ?1
|
|
215
|
+
AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))
|
|
216
|
+
AND t.revoked_at IS NULL`,
|
|
215
217
|
tHash);
|
|
216
218
|
|
|
217
219
|
if (!row) return null;
|