@sleep2agi/commhub-server 0.9.0-preview.3 → 0.9.0-preview.30
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/README.md +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +84 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-fallback.test.ts +267 -0
- package/src/api-nodes-shape.test.ts +188 -0
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/boot-side-effect.test.ts +124 -0
- 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.ts +48 -1
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db.ts +522 -32
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +403 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +731 -0
- package/src/health-redaction.test.ts +637 -0
- package/src/index.ts +15 -2172
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/observer-avatar-http.test.ts +349 -0
- package/src/observer-push.test.ts +176 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -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/push.ts +96 -7
- package/src/require-explicit-test-db.ts +9 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +211 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +67 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +479 -0
- package/src/scheduled-tasks.ts +549 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +3492 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/side-thread-command-transport.test.ts +140 -0
- package/src/side-thread-command-transport.ts +309 -0
- package/src/side-thread-http-integration.test.ts +229 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread.test.ts +1038 -0
- package/src/side-thread.ts +2241 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +398 -0
- package/src/task-diagnostic-http.test.ts +145 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +200 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +2302 -169
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +92 -14
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -57,6 +57,35 @@ export const RESTART_REQUIRED_FLAGS = new Set<string>([
|
|
|
57
57
|
"timeout",
|
|
58
58
|
]);
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Channels the dashboard may enable/disable on a node via
|
|
62
|
+
* update_node_config. Intentionally narrow — the panel only exposes
|
|
63
|
+
* enable/disable, per-channel secrets stay in the node's local
|
|
64
|
+
* config.json (see #260 wire discipline).
|
|
65
|
+
*
|
|
66
|
+
* The list mirrors agent-node's actual runtime capability. See
|
|
67
|
+
* agent-node/src/cli.ts:671-676 — CHANNELS are parsed into per-type
|
|
68
|
+
* worker inits (initTelegramChannel + initFeishuChannel), and any
|
|
69
|
+
* other channel type triggers `process.exit(1)` at boot. So the
|
|
70
|
+
* hub-side allow-list here is telegram + feishu ONLY. `commhub` is
|
|
71
|
+
* the RPC transport that every node speaks unconditionally; treating
|
|
72
|
+
* it as a per-node channel would be a UX lie (toggle looks off but
|
|
73
|
+
* commhub is always on).
|
|
74
|
+
*
|
|
75
|
+
* Dashboard PR #31 currently includes `commhub` in its own
|
|
76
|
+
* EDITABLE_CHANNELS whitelist — that follow-up narrowing is filed for
|
|
77
|
+
* the next dashboard rally, and until then hub silently drops any
|
|
78
|
+
* `commhub` entry via narrowChannelsPatch.
|
|
79
|
+
*
|
|
80
|
+
* Not part of SECURITY_SENSITIVE_FLAGS (通信龙 P5 派工): flipping a
|
|
81
|
+
* channel on/off is a lifecycle-tier operation like `restart_node`, not
|
|
82
|
+
* a privilege elevation. Cross-tenant reach is already gated by SEC-1.
|
|
83
|
+
*/
|
|
84
|
+
export const EDITABLE_CHANNELS = new Set<string>([
|
|
85
|
+
"telegram",
|
|
86
|
+
"feishu",
|
|
87
|
+
]);
|
|
88
|
+
|
|
60
89
|
/**
|
|
61
90
|
* Role-gate for the patch's flag set. SEC-2 enforcement lives here.
|
|
62
91
|
*
|
|
@@ -99,22 +128,53 @@ export function isAllowedToChangeFlag(
|
|
|
99
128
|
|
|
100
129
|
/**
|
|
101
130
|
* Classify the patch's required apply mode. Empty patch (model + flags
|
|
102
|
-
* both empty) → "restart_only" (used by restart_node tool).
|
|
103
|
-
* restart-required field present
|
|
131
|
+
* both empty, no channels) → "restart_only" (used by restart_node tool).
|
|
132
|
+
* Any restart-required field present, OR a channels change → "restart"
|
|
133
|
+
* (channels is restart-tier by design: the node reads its channel set
|
|
134
|
+
* once at boot in agent-node/src/cli.ts and forks per-channel workers
|
|
135
|
+
* from it, so a channel enable/disable takes effect on process restart
|
|
136
|
+
* only — see #260 P5 wire discipline). Otherwise → "hot".
|
|
104
137
|
*/
|
|
105
138
|
export function computeApplyMode(
|
|
106
139
|
model: string | undefined,
|
|
107
140
|
flags: Record<string, unknown>,
|
|
141
|
+
channels?: string[] | undefined,
|
|
108
142
|
): "hot" | "restart" | "restart_only" {
|
|
109
|
-
const fieldCount = (model !== undefined ? 1 : 0) + Object.keys(flags).length;
|
|
143
|
+
const fieldCount = (model !== undefined ? 1 : 0) + Object.keys(flags).length + (channels !== undefined ? 1 : 0);
|
|
110
144
|
if (fieldCount === 0) return "restart_only";
|
|
111
145
|
if (model !== undefined) return "restart";
|
|
146
|
+
if (channels !== undefined) return "restart";
|
|
112
147
|
for (const key of Object.keys(flags)) {
|
|
113
148
|
if (RESTART_REQUIRED_FLAGS.has(key)) return "restart";
|
|
114
149
|
}
|
|
115
150
|
return "hot";
|
|
116
151
|
}
|
|
117
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Narrow + allow-list `channels` from untrusted patch JSON. Returns the
|
|
155
|
+
* final channel key list (case-folded, deduped, in EDITABLE_CHANNELS
|
|
156
|
+
* order-preserving), OR null if the input isn't an array. Non-strings,
|
|
157
|
+
* unknown keys, empty strings, and duplicates are silently dropped —
|
|
158
|
+
* same fail-shape as the dashboard route's whitelist so the two sides
|
|
159
|
+
* behave identically end-to-end. Callers pass `undefined` through when
|
|
160
|
+
* the patch didn't include a channels key at all (a distinct case from
|
|
161
|
+
* `channels: []` which means "disable all editable channels").
|
|
162
|
+
*/
|
|
163
|
+
export function narrowChannelsPatch(raw: unknown): string[] | null {
|
|
164
|
+
if (!Array.isArray(raw)) return null;
|
|
165
|
+
const allow = EDITABLE_CHANNELS;
|
|
166
|
+
const seen = new Set<string>();
|
|
167
|
+
const out: string[] = [];
|
|
168
|
+
for (const v of raw) {
|
|
169
|
+
if (typeof v !== "string") continue;
|
|
170
|
+
const key = v.trim().toLowerCase();
|
|
171
|
+
if (!allow.has(key) || seen.has(key)) continue;
|
|
172
|
+
seen.add(key);
|
|
173
|
+
out.push(key);
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
118
178
|
/**
|
|
119
179
|
* Validate the patch shape — allowlist + per-field range / enum. Returns
|
|
120
180
|
* null on pass, or `{ field, reason }` on reject. Caller wraps in error
|
|
@@ -123,12 +183,30 @@ export function computeApplyMode(
|
|
|
123
183
|
export function validatePatch(
|
|
124
184
|
model: string | undefined,
|
|
125
185
|
flags: Record<string, unknown>,
|
|
186
|
+
channels?: string[] | undefined,
|
|
126
187
|
): { field: string; reason: string } | null {
|
|
127
188
|
if (model !== undefined) {
|
|
128
189
|
if (typeof model !== "string" || model.length === 0 || model.length > 200) {
|
|
129
190
|
return { field: "model", reason: "must be a non-empty string ≤ 200 chars" };
|
|
130
191
|
}
|
|
131
192
|
}
|
|
193
|
+
if (channels !== undefined) {
|
|
194
|
+
// Zod already enforced string[], and narrowChannelsPatch dropped
|
|
195
|
+
// unknown keys before this point — but re-verify defensively so a
|
|
196
|
+
// future caller that skips narrowChannelsPatch can't smuggle
|
|
197
|
+
// unknown keys through validatePatch alone.
|
|
198
|
+
if (!Array.isArray(channels)) {
|
|
199
|
+
return { field: "channels", reason: "must be an array of strings" };
|
|
200
|
+
}
|
|
201
|
+
for (const c of channels) {
|
|
202
|
+
if (typeof c !== "string") {
|
|
203
|
+
return { field: "channels", reason: "must contain only strings" };
|
|
204
|
+
}
|
|
205
|
+
if (!EDITABLE_CHANNELS.has(c)) {
|
|
206
|
+
return { field: `channels.${c}`, reason: "not in editable channels allowlist" };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
132
210
|
for (const [key, val] of Object.entries(flags)) {
|
|
133
211
|
if (!ALLOWED_FLAGS.has(key)) {
|
|
134
212
|
return { field: `flags.${key}`, reason: "not in allowlist" };
|
package/src/create-node.ts
CHANGED
|
@@ -272,8 +272,55 @@ export function resolveCallerDaemonTokenBound(opts: {
|
|
|
272
272
|
// RFC-026 §4.5 — append a row to audit_log for every create_node
|
|
273
273
|
// lifecycle event. Best-effort: never throw out (calling tool should
|
|
274
274
|
// continue even if audit insert fails for any reason).
|
|
275
|
+
/** RFC-027 §4.5 D8 — tx-aware variant. Identical SQL to auditCreateNode
|
|
276
|
+
* but RE-THROWS on failure so a BEGIN..COMMIT around lifecycle UPDATE +
|
|
277
|
+
* audit INSERT can ROLLBACK if the audit row would have been lost.
|
|
278
|
+
* Required for the "no audit-but state changed" window that §4.5 / D8
|
|
279
|
+
* explicitly closes (PR1 SF-2 review catch — auditCreateNode's
|
|
280
|
+
* swallow-and-warn defeats atomicity).
|
|
281
|
+
*
|
|
282
|
+
* Callers in a non-transaction context should keep using auditCreateNode
|
|
283
|
+
* (best-effort, never throws). */
|
|
284
|
+
export function auditCreateNodeStrict(input: {
|
|
285
|
+
action: Parameters<typeof auditCreateNode>[0]["action"];
|
|
286
|
+
user_id?: string | null;
|
|
287
|
+
username?: string | null;
|
|
288
|
+
network_id?: string | null;
|
|
289
|
+
target_id?: string | null;
|
|
290
|
+
detail: Record<string, unknown>;
|
|
291
|
+
}): void {
|
|
292
|
+
db.run(
|
|
293
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
294
|
+
VALUES (?1, ?2, ?3, 'node_create_request', ?4, ?5, ?6)`,
|
|
295
|
+
[
|
|
296
|
+
input.user_id || null,
|
|
297
|
+
input.username || null,
|
|
298
|
+
input.action,
|
|
299
|
+
input.target_id || null,
|
|
300
|
+
JSON.stringify(input.detail),
|
|
301
|
+
input.network_id || null,
|
|
302
|
+
],
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
275
306
|
export function auditCreateNode(input: {
|
|
276
|
-
action:
|
|
307
|
+
action:
|
|
308
|
+
| "create_node_dispatched"
|
|
309
|
+
| "create_node_rejected"
|
|
310
|
+
| "create_node_succeeded"
|
|
311
|
+
| "create_node_sweeper_revoked"
|
|
312
|
+
| "daemon_capability_lied" // RFC-026 §9.3 D2 — daemon declared runtime support, child died before serving
|
|
313
|
+
// RFC-027 §4.5 — stop/delete lifecycle audit action enum extension.
|
|
314
|
+
// Re-using auditCreateNode is fine: the action column carries the
|
|
315
|
+
// discriminator; target_type stays 'node_create_request' which is a
|
|
316
|
+
// misnomer for stop/delete rows but updating the schema would force a
|
|
317
|
+
// wider migration (see P1.1 issue).
|
|
318
|
+
| "stop_node_dispatched"
|
|
319
|
+
| "stop_node_completed"
|
|
320
|
+
| "delete_node_dispatched"
|
|
321
|
+
| "delete_node_completed"
|
|
322
|
+
| "forced_stop_with_in_flight"
|
|
323
|
+
| "backup_purged";
|
|
277
324
|
user_id?: string | null;
|
|
278
325
|
username?: string | null;
|
|
279
326
|
network_id?: string | null;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { isInteractiveDashboardTask } from "../../agent-node/src/inbox-dispatch";
|
|
6
|
+
import { shouldCreateScheduledGoal } from "../../agent-node/src/goals/routing";
|
|
7
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
8
|
+
import { db } from "./db.js";
|
|
9
|
+
|
|
10
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-dashboard-slash-http-"));
|
|
11
|
+
const TARGET = `dashboard-slash-target-${process.pid}`;
|
|
12
|
+
const TARGET_NODE_ID = `node_dashboard_slash_target_${process.pid}`;
|
|
13
|
+
const TARGET_RESUME_ID = `resume_dashboard_slash_target_${process.pid}`;
|
|
14
|
+
let server: any;
|
|
15
|
+
let base = "";
|
|
16
|
+
let userToken = "";
|
|
17
|
+
let nodeToken = "";
|
|
18
|
+
let networkId = "";
|
|
19
|
+
let requestSequence = 0;
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
|
|
23
|
+
const username = `dashboard_slash_${Date.now()}`;
|
|
24
|
+
const registered = register(username, "DashboardSlash123!", undefined, "seed");
|
|
25
|
+
expect(registered.ok).toBe(true);
|
|
26
|
+
userToken = registered.token!;
|
|
27
|
+
networkId = registered.network_id!;
|
|
28
|
+
const userId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [username])!.user_id;
|
|
29
|
+
const minted = createNetworkTokenForNode(userId, networkId, "dashboard-slash-node");
|
|
30
|
+
expect(minted.ok).toBe(true);
|
|
31
|
+
nodeToken = minted.token!;
|
|
32
|
+
db.run(
|
|
33
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
|
|
34
|
+
VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
|
|
35
|
+
[TARGET_RESUME_ID, TARGET, TARGET_NODE_ID, networkId],
|
|
36
|
+
);
|
|
37
|
+
db.run(
|
|
38
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at, lifecycle_state)
|
|
39
|
+
VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'), 'active')`,
|
|
40
|
+
[TARGET_NODE_ID, TARGET, networkId],
|
|
41
|
+
);
|
|
42
|
+
const mod: any = await import("./server.js");
|
|
43
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
44
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterAll(() => {
|
|
48
|
+
try { server?.stop?.(true); } catch {}
|
|
49
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
async function postAndRead(token: string, from: string, task: string) {
|
|
53
|
+
requestSequence += 1;
|
|
54
|
+
const clientRequestId = `dreq_${requestSequence.toString(16).padStart(32, "0")}`;
|
|
55
|
+
const response = await fetch(`${base}/api/task`, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
alias: TARGET,
|
|
60
|
+
from,
|
|
61
|
+
network_id: networkId,
|
|
62
|
+
task,
|
|
63
|
+
meta: { source: "dashboard-chat", client_request_id: clientRequestId, auth_origin: "user" },
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
const body = await response.json() as any;
|
|
67
|
+
expect([200, 202]).toContain(response.status);
|
|
68
|
+
const taskId = body.task_id ?? body.message_id;
|
|
69
|
+
const row = db.get<{ content: string; meta_json: string }>(
|
|
70
|
+
"SELECT content, meta_json FROM inbox WHERE id = ?1",
|
|
71
|
+
[taskId],
|
|
72
|
+
);
|
|
73
|
+
expect(row).toBeTruthy();
|
|
74
|
+
return { type: "task", content: row!.content, meta_json: row!.meta_json };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("real Hub → authenticated Dashboard slash routing", () => {
|
|
78
|
+
test("Dashboard /goal and /loop remain exact native payloads for every runtime", async () => {
|
|
79
|
+
for (const command of ["/goal 更新一下文档", "/loop 5m native loop"] as const) {
|
|
80
|
+
const row = await postAndRead(userToken, "dashboard-user", command);
|
|
81
|
+
expect(row.content).toBe(command);
|
|
82
|
+
expect(isInteractiveDashboardTask(row)).toBe(true);
|
|
83
|
+
for (const runtime of ["codex-app-server", "codex", "claude", "grok", "opencode"] as const) {
|
|
84
|
+
expect(shouldCreateScheduledGoal(row.content, runtime, true)).toBe(false);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("Dashboard /agoal and /aloop use the Agent Network scheduler", async () => {
|
|
90
|
+
for (const command of ["/agoal 5m update docs", "/aloop 5m update docs"] as const) {
|
|
91
|
+
const row = await postAndRead(userToken, "dashboard-user", command);
|
|
92
|
+
expect(isInteractiveDashboardTask(row)).toBe(true);
|
|
93
|
+
expect(shouldCreateScheduledGoal(row.content, "codex-app-server", true)).toBe(true);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("node token cannot forge Dashboard pass-through and keeps legacy compatibility", async () => {
|
|
98
|
+
const row = await postAndRead(nodeToken, "dashboard-slash-node", "/loop 5m legacy automation");
|
|
99
|
+
expect(JSON.parse(row.meta_json).auth_origin).toBe("node");
|
|
100
|
+
expect(isInteractiveDashboardTask(row)).toBe(false);
|
|
101
|
+
expect(shouldCreateScheduledGoal(row.content, "codex-app-server", false)).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
assertSafeTestDatabaseEnv,
|
|
4
|
+
resolveDatabaseTarget,
|
|
5
|
+
} from "./db-adapter";
|
|
6
|
+
|
|
7
|
+
const BASE_ENV: NodeJS.ProcessEnv = { HOME: "/nonexistent" };
|
|
8
|
+
|
|
9
|
+
describe("#435 inherited DATABASE_URL guard", () => {
|
|
10
|
+
for (const databaseUrl of [
|
|
11
|
+
"postgres://user:pw@prod.example:5432/commhub",
|
|
12
|
+
"postgresql://user:pw@prod.example:5432/commhub",
|
|
13
|
+
"sqlite:///tmp/not-an-escape",
|
|
14
|
+
]) {
|
|
15
|
+
test(`NODE_ENV=test rejects ${databaseUrl.split(":", 1)[0]} DATABASE_URL`, () => {
|
|
16
|
+
expect(() => assertSafeTestDatabaseEnv({
|
|
17
|
+
...BASE_ENV,
|
|
18
|
+
NODE_ENV: "test",
|
|
19
|
+
DATABASE_URL: databaseUrl,
|
|
20
|
+
})).toThrow(/REFUSING to honor inherited DATABASE_URL/);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("test without DATABASE_URL leaves the existing SQLite guard in charge", () => {
|
|
25
|
+
expect(() => assertSafeTestDatabaseEnv({ ...BASE_ENV, NODE_ENV: "test" })).not.toThrow();
|
|
26
|
+
expect(() => resolveDatabaseTarget({ ...BASE_ENV, NODE_ENV: "test" }))
|
|
27
|
+
.toThrow(/REFUSING to open the default SQLite database/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("DATABASE_URL refusal wins even when COMMHUB_DB is also set", () => {
|
|
31
|
+
expect(() => resolveDatabaseTarget({
|
|
32
|
+
...BASE_ENV,
|
|
33
|
+
NODE_ENV: "test",
|
|
34
|
+
DATABASE_URL: "postgres://user:pw@prod.example:5432/commhub",
|
|
35
|
+
COMMHUB_DB: "/tmp/isolated.db",
|
|
36
|
+
})).toThrow(/REFUSING to honor inherited DATABASE_URL/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("production postgres branch remains unchanged without constructing it", () => {
|
|
40
|
+
const url = "postgres://user:pw@prod.example:5432/commhub";
|
|
41
|
+
expect(resolveDatabaseTarget({
|
|
42
|
+
...BASE_ENV,
|
|
43
|
+
NODE_ENV: "production",
|
|
44
|
+
DATABASE_URL: url,
|
|
45
|
+
})).toEqual({ kind: "postgres", url });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("unset NODE_ENV still selects postgres", () => {
|
|
49
|
+
const url = "postgresql://user:pw@prod.example:5432/commhub";
|
|
50
|
+
expect(resolveDatabaseTarget({ ...BASE_ENV, DATABASE_URL: url }))
|
|
51
|
+
.toEqual({ kind: "postgres", url });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("explicit SQLite targets remain unchanged in test and production", () => {
|
|
55
|
+
for (const NODE_ENV of ["test", "production"]) {
|
|
56
|
+
expect(resolveDatabaseTarget({
|
|
57
|
+
...BASE_ENV,
|
|
58
|
+
NODE_ENV,
|
|
59
|
+
COMMHUB_DB: "/tmp/isolated.db",
|
|
60
|
+
})).toEqual({ kind: "sqlite", path: "/tmp/isolated.db" });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("non-server callers cannot silently fall through to the production default SQLite path", () => {
|
|
65
|
+
for (const NODE_ENV of [undefined, "development", "production"]) {
|
|
66
|
+
expect(() => resolveDatabaseTarget({ ...BASE_ENV, NODE_ENV }))
|
|
67
|
+
.toThrow(/explicit COMMHUB_DB|COMMHUB_SERVER=1/);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("the explicit server boot capability permits the canonical default SQLite path", () => {
|
|
72
|
+
expect(resolveDatabaseTarget({
|
|
73
|
+
...BASE_ENV,
|
|
74
|
+
NODE_ENV: "production",
|
|
75
|
+
COMMHUB_SERVER: "1",
|
|
76
|
+
})).toEqual({ kind: "sqlite", path: "/nonexistent/.commhub/commhub.db" });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("lookalike server capability values fail closed", () => {
|
|
80
|
+
for (const COMMHUB_SERVER of ["", "true", "yes", "01", " 1"] ) {
|
|
81
|
+
expect(() => resolveDatabaseTarget({ ...BASE_ENV, COMMHUB_SERVER }))
|
|
82
|
+
.toThrow(/COMMHUB_SERVER=1/);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
package/src/db-adapter.ts
CHANGED
|
@@ -212,10 +212,28 @@ export class PgAdapter implements DbAdapter {
|
|
|
212
212
|
// Factory
|
|
213
213
|
// ════════════════════════════════════════════
|
|
214
214
|
|
|
215
|
+
export type DbTarget =
|
|
216
|
+
| { kind: "postgres"; url: string }
|
|
217
|
+
| { kind: "sqlite"; path: string };
|
|
218
|
+
|
|
215
219
|
/**
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
220
|
+
* Refuse every inherited DATABASE_URL under the Bun test environment before
|
|
221
|
+
* adapter selection, logging, DNS, or construction. A test that intentionally
|
|
222
|
+
* exercises PostgreSQL needs a separately reviewed isolated harness; accepting
|
|
223
|
+
* a shell/CI DATABASE_URL here could connect a unit test to production.
|
|
224
|
+
*/
|
|
225
|
+
export function assertSafeTestDatabaseEnv(env: NodeJS.ProcessEnv = process.env): void {
|
|
226
|
+
if (env.NODE_ENV === "test" && env.DATABASE_URL) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
"[commhub] REFUSING to honor inherited DATABASE_URL under NODE_ENV=test.\n" +
|
|
229
|
+
" Tests must unset DATABASE_URL and set COMMHUB_DB to an isolated path.\n" +
|
|
230
|
+
" This guard is fail-closed and has no opt-in bypass."
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Pure target selection after the inherited-DATABASE_URL safety gate.
|
|
219
237
|
*
|
|
220
238
|
* Test-default-prod GUARD: when `NODE_ENV === "test"` (set automatically by
|
|
221
239
|
* `bun test`) AND `COMMHUB_DB` is unset, we REFUSE to fall through to the
|
|
@@ -227,27 +245,20 @@ export class PgAdapter implements DbAdapter {
|
|
|
227
245
|
* pre-existing history of test pollution in the same DB). The guard makes
|
|
228
246
|
* the failure mode loud + actionable instead of silent + destructive.
|
|
229
247
|
*
|
|
230
|
-
* The
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
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.
|
|
248
|
+
* The production default path is a capability, not a generic fallback:
|
|
249
|
+
* only canonical server entrypoints set COMMHUB_SERVER=1 before importing
|
|
250
|
+
* the database graph. Tests, scripts, and `bun -e` probes must name an
|
|
251
|
+
* explicit COMMHUB_DB (or a reviewed PostgreSQL DATABASE_URL), so forgetting
|
|
252
|
+
* one fails before mkdir/open/write instead of touching the live Hub.
|
|
238
253
|
*/
|
|
239
|
-
|
|
240
|
-
const dbUrl =
|
|
254
|
+
function resolveDatabaseTargetAfterGuard(env: NodeJS.ProcessEnv): DbTarget {
|
|
255
|
+
const dbUrl = env.DATABASE_URL;
|
|
241
256
|
if (dbUrl && (dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://"))) {
|
|
242
|
-
|
|
243
|
-
return new PgAdapter(dbUrl);
|
|
257
|
+
return { kind: "postgres", url: dbUrl };
|
|
244
258
|
}
|
|
245
|
-
// Default: SQLite
|
|
246
|
-
const { mkdirSync } = require("fs");
|
|
247
|
-
const { dirname } = require("path");
|
|
248
259
|
|
|
249
260
|
// Test-default-prod GUARD (see docblock above).
|
|
250
|
-
if (
|
|
261
|
+
if (env.NODE_ENV === "test" && !env.COMMHUB_DB) {
|
|
251
262
|
throw new Error(
|
|
252
263
|
"[commhub] REFUSING to open the default SQLite database under NODE_ENV=test.\n" +
|
|
253
264
|
" Tests must explicitly set COMMHUB_DB to a throwaway path so they don't\n" +
|
|
@@ -257,10 +268,40 @@ export function createAdapter(): DbAdapter {
|
|
|
257
268
|
);
|
|
258
269
|
}
|
|
259
270
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
271
|
+
if (!env.COMMHUB_DB && env.COMMHUB_SERVER !== "1") {
|
|
272
|
+
throw new Error(
|
|
273
|
+
"[commhub] REFUSING to open the default production SQLite database from a non-server entrypoint.\n" +
|
|
274
|
+
" Scripts and probes must set an explicit COMMHUB_DB path. The canonical\n" +
|
|
275
|
+
" Hub entrypoints set COMMHUB_SERVER=1 before loading the database."
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return { kind: "sqlite", path: env.COMMHUB_DB || `${env.HOME}/.commhub/commhub.db` };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Pure public resolver used by tests without constructing an adapter. */
|
|
283
|
+
export function resolveDatabaseTarget(env: NodeJS.ProcessEnv = process.env): DbTarget {
|
|
284
|
+
assertSafeTestDatabaseEnv(env);
|
|
285
|
+
return resolveDatabaseTargetAfterGuard(env);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Create the appropriate adapter based on environment.
|
|
290
|
+
* The inherited-DATABASE_URL guard is deliberately the first executable line.
|
|
291
|
+
*/
|
|
292
|
+
export function createAdapter(): DbAdapter {
|
|
293
|
+
assertSafeTestDatabaseEnv(process.env);
|
|
294
|
+
const target = resolveDatabaseTargetAfterGuard(process.env);
|
|
295
|
+
if (target.kind === "postgres") {
|
|
296
|
+
console.log("[commhub] database: PostgreSQL");
|
|
297
|
+
return new PgAdapter(target.url);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const { mkdirSync } = require("fs");
|
|
301
|
+
const { dirname } = require("path");
|
|
302
|
+
mkdirSync(dirname(target.path), { recursive: true });
|
|
303
|
+
console.log(`[commhub] database: ${target.path}`);
|
|
304
|
+
const rawDb = new Database(target.path);
|
|
264
305
|
rawDb.exec("PRAGMA journal_mode=WAL");
|
|
265
306
|
rawDb.exec("PRAGMA busy_timeout=5000");
|
|
266
307
|
return new SQLiteAdapter(rawDb);
|