@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.
Files changed (99) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-networks-http.test.ts +84 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-fallback.test.ts +267 -0
  10. package/src/api-nodes-shape.test.ts +188 -0
  11. package/src/auth.ts +93 -14
  12. package/src/avatar-validate.test.ts +163 -0
  13. package/src/avatar-validate.ts +103 -0
  14. package/src/boot-side-effect.test.ts +124 -0
  15. package/src/config-apply-sec1.test.ts +107 -0
  16. package/src/config-apply-validate.test.ts +92 -0
  17. package/src/config-apply-validate.ts +81 -3
  18. package/src/create-node.ts +48 -1
  19. package/src/dashboard-slash-routing-http.test.ts +103 -0
  20. package/src/db-adapter-guard.test.ts +85 -0
  21. package/src/db-adapter.ts +64 -23
  22. package/src/db.ts +522 -32
  23. package/src/ext-token-shared.test.ts +60 -0
  24. package/src/ext-validation-regression.test.ts +131 -0
  25. package/src/external-schedule-edits-http.test.ts +316 -0
  26. package/src/external-schedule-edits.ts +314 -0
  27. package/src/file-download-authz-dev-open.test.ts +135 -0
  28. package/src/file-download-authz.test.ts +403 -0
  29. package/src/file-download-date-bucket.test.ts +436 -0
  30. package/src/file-network-scope-dev-open.test.ts +97 -0
  31. package/src/file-network-scope.test.ts +731 -0
  32. package/src/health-redaction.test.ts +637 -0
  33. package/src/index.ts +15 -2172
  34. package/src/lifecycle-guard.ts +50 -0
  35. package/src/list-host-supervisors.test.ts +314 -0
  36. package/src/mcp-write-network-resolution.test.ts +429 -0
  37. package/src/network-name-http.test.ts +71 -0
  38. package/src/network-scope.ts +101 -0
  39. package/src/node-attrs-validate.ts +88 -0
  40. package/src/node-attrs.test.ts +213 -0
  41. package/src/observer-avatar-http.test.ts +349 -0
  42. package/src/observer-push.test.ts +176 -0
  43. package/src/peer-reply-atomic.test.ts +455 -0
  44. package/src/peer-reply-capability.test.ts +70 -0
  45. package/src/probe-validate.test.ts +221 -0
  46. package/src/probe-validate.ts +104 -0
  47. package/src/probe.test.ts +80 -0
  48. package/src/probe.ts +200 -0
  49. package/src/push.ts +96 -7
  50. package/src/require-explicit-test-db.ts +9 -0
  51. package/src/resolve-port.test.ts +57 -0
  52. package/src/resolve-port.ts +44 -0
  53. package/src/response-charset.test.ts +130 -0
  54. package/src/rest-explicit-columns-http.test.ts +211 -0
  55. package/src/rest-identity.test.ts +129 -0
  56. package/src/rest-identity.ts +100 -0
  57. package/src/rest-projections.ts +67 -0
  58. package/src/rest-write-network-resolution.test.ts +133 -0
  59. package/src/runtime-label.test.ts +22 -0
  60. package/src/runtime-label.ts +21 -0
  61. package/src/scheduled-run-terminal.test.ts +268 -0
  62. package/src/scheduled-tasks-http.test.ts +479 -0
  63. package/src/scheduled-tasks.ts +549 -0
  64. package/src/send-reply-agent-warning.test.ts +202 -0
  65. package/src/send-reply-attachments.test.ts +437 -0
  66. package/src/server.ts +3492 -0
  67. package/src/shared/external-schedule-contract.ts +60 -0
  68. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  69. package/src/shared/probe-host-allowlist.ts +128 -0
  70. package/src/side-thread-command-transport.test.ts +140 -0
  71. package/src/side-thread-command-transport.ts +309 -0
  72. package/src/side-thread-http-integration.test.ts +229 -0
  73. package/src/side-thread-http.ts +504 -0
  74. package/src/side-thread.test.ts +1038 -0
  75. package/src/side-thread.ts +2241 -0
  76. package/src/skillhub-http.test.ts +88 -0
  77. package/src/stop-delete-node.test.ts +720 -0
  78. package/src/task-auth-origin-http.test.ts +87 -0
  79. package/src/task-auth-origin.test.ts +24 -0
  80. package/src/task-auth-origin.ts +7 -0
  81. package/src/task-consumption.test.ts +398 -0
  82. package/src/task-diagnostic-http.test.ts +145 -0
  83. package/src/task-diagnostic.test.ts +115 -0
  84. package/src/task-diagnostic.ts +118 -0
  85. package/src/task-idempotency-mcp.test.ts +142 -0
  86. package/src/task-idempotency.test.ts +60 -0
  87. package/src/task-idempotency.ts +75 -0
  88. package/src/task-lifecycle-watcher.test.ts +200 -0
  89. package/src/task-lifecycle-watcher.ts +49 -0
  90. package/src/task-network-resolution-http.test.ts +137 -0
  91. package/src/tasks-pagination-http.test.ts +137 -0
  92. package/src/tools.ts +2302 -169
  93. package/src/update-provider.test.ts +234 -0
  94. package/src/upload-early-reject-guard.test.ts +155 -0
  95. package/src/uploads-http.test.ts +92 -14
  96. package/src/uploads.test.ts +66 -0
  97. package/src/uploads.ts +130 -5
  98. package/src/vault.test.ts +200 -0
  99. 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). Any
103
- * restart-required field present → "restart". Otherwise → "hot".
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" };
@@ -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: "create_node_dispatched" | "create_node_rejected" | "create_node_succeeded" | "create_node_sweeper_revoked";
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
- * Create the appropriate adapter based on environment.
217
- * - DATABASE_URL starts with "postgres://" → PgAdapter
218
- * - Otherwise → SQLiteAdapter with COMMHUB_DB or default path
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 guard does NOT fire when:
231
- * - COMMHUB_DB is set (the test runner's documented contract); or
232
- * - NODE_ENV is unset (production server) or any non-"test" value.
233
- *
234
- * Operators who run `bun -e` snippets that touch the DB are expected to
235
- * `COMMHUB_DB=/tmp/...` themselves — `bun -e` doesn't set NODE_ENV, so the
236
- * guard can't catch that case. The rule (never read or write the
237
- * production hub database) is documented in CLAUDE.md.
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
- export function createAdapter(): DbAdapter {
240
- const dbUrl = process.env.DATABASE_URL;
254
+ function resolveDatabaseTargetAfterGuard(env: NodeJS.ProcessEnv): DbTarget {
255
+ const dbUrl = env.DATABASE_URL;
241
256
  if (dbUrl && (dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://"))) {
242
- console.log("[commhub] database: PostgreSQL");
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 (process.env.NODE_ENV === "test" && !process.env.COMMHUB_DB) {
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
- const dbPath = process.env.COMMHUB_DB || `${process.env.HOME}/.commhub/commhub.db`;
261
- mkdirSync(dirname(dbPath), { recursive: true });
262
- console.log(`[commhub] database: ${dbPath}`);
263
- const rawDb = new Database(dbPath);
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);