@sleep2agi/commhub-server 0.9.0-preview.1 → 0.9.0-preview.3

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/src/db.ts CHANGED
@@ -376,6 +376,66 @@ db.exec(`
376
376
  CREATE INDEX IF NOT EXISTS idx_tokens_user ON api_tokens(user_id);
377
377
  `);
378
378
 
379
+ // RFC-026 v4 §4.4.8 C4 — api_tokens metadata so sweeper can revoke
380
+ // orphan child-ntoks without ever touching plaintext tokens. ALTER in
381
+ // try/catch (idempotent for already-migrated DBs).
382
+ // request_id — links a child token to the create_node request that
383
+ // minted it; sweeper joins on this
384
+ // revoked_at — explicit revoke; resolveToken treats non-NULL as
385
+ // invalid even before expiry
386
+ for (const ddl of [
387
+ "ALTER TABLE api_tokens ADD COLUMN request_id TEXT",
388
+ "ALTER TABLE api_tokens ADD COLUMN revoked_at TEXT",
389
+ "ALTER TABLE api_tokens ADD COLUMN role TEXT",
390
+ ]) {
391
+ try { db.exec(ddl); }
392
+ catch (e: any) {
393
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
394
+ }
395
+ }
396
+ try { db.exec(`CREATE INDEX IF NOT EXISTS idx_tokens_request ON api_tokens(request_id)`); }
397
+ catch (e: any) { /* index may already exist */ void e; }
398
+
399
+ // RFC-026 v4 §2.5 + §4.4 — node_create_requests. Metadata only;
400
+ // env_blob NEVER lives in this table (F1 mint-stream-evict — secret
401
+ // blob is in-memory Map keyed by request_id, drained on daemon get,
402
+ // TTL 60s GC).
403
+ //
404
+ // env_keys is a JSON array of secret KEY NAMES only (for audit). The
405
+ // actual values flow through pendingEnvBlobs Map and never touch
406
+ // disk.
407
+ db.exec(`
408
+ CREATE TABLE IF NOT EXISTS node_create_requests (
409
+ request_id TEXT PRIMARY KEY,
410
+ daemon_node_id TEXT NOT NULL,
411
+ child_name TEXT NOT NULL,
412
+ network_id TEXT NOT NULL,
413
+ runtime TEXT NOT NULL,
414
+ model TEXT NOT NULL,
415
+ flags_json TEXT NOT NULL,
416
+ env_keys TEXT NOT NULL,
417
+ status TEXT NOT NULL,
418
+ error TEXT,
419
+ child_token_id TEXT,
420
+ created_at INTEGER NOT NULL,
421
+ created_by_token TEXT NOT NULL,
422
+ delivered_at INTEGER,
423
+ acked_at INTEGER,
424
+ child_node_id TEXT
425
+ );
426
+
427
+ CREATE INDEX IF NOT EXISTS idx_ncr_daemon_status ON node_create_requests(daemon_node_id, status);
428
+ CREATE INDEX IF NOT EXISTS idx_ncr_network ON node_create_requests(network_id);
429
+ CREATE INDEX IF NOT EXISTS idx_ncr_child_name ON node_create_requests(child_name);
430
+ `);
431
+ try {
432
+ // Single-flight per (daemon, child_name) — prevents racing dashboard
433
+ // create on same name. Mirrors RFC-024 uniq_ncu_node_inflight.
434
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_ncr_inflight ON node_create_requests(daemon_node_id, child_name) WHERE status IN ('pending', 'delivered')`);
435
+ } catch (e: any) {
436
+ console.warn(`[commhub] partial unique index uniq_ncr_inflight skipped: ${e?.message || e}`);
437
+ }
438
+
379
439
  // ── V3: audit_log table ──
380
440
  db.exec(`
381
441
  CREATE TABLE IF NOT EXISTS audit_log (
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import {
5
+ RESERVED_ENV_KEYS_EXACT as HUB_EXACT,
6
+ RESERVED_ENV_PREFIXES as HUB_PREFIXES,
7
+ } from "./reserved-env.js";
8
+
9
+ // RFC-026 v4 §4.4.7 G9 drift guard — denylist MUST be hub-and-daemon
10
+ // identical, or attacker walks the looser layer. Two enforcement modes:
11
+ //
12
+ // 1. byte-identical source files (this test)
13
+ // 2. runtime set-equality of the imported constants
14
+ //
15
+ // We assert both. If a future contributor edits one side without the
16
+ // other, this test breaks the merge.
17
+
18
+ const HUB_PATH = join(import.meta.dir, "reserved-env.ts");
19
+ const DAEMON_PATH = join(import.meta.dir, "..", "..", "..", "agent-node", "src", "shared", "reserved-env.ts");
20
+
21
+ describe("RFC-026 v4 §4.4.7 G9 — hub/daemon reserved-env drift guard", () => {
22
+ test("byte-identical source files", () => {
23
+ const hub = readFileSync(HUB_PATH, "utf-8");
24
+ const daemon = readFileSync(DAEMON_PATH, "utf-8");
25
+ expect(daemon).toBe(hub);
26
+ });
27
+
28
+ test("imported constants are set-equal at runtime", async () => {
29
+ // Use a dynamic file import path so this test breaks if the file
30
+ // moves but still references the daemon copy.
31
+ const daemonMod = await import(DAEMON_PATH);
32
+ const daemonExact = daemonMod.RESERVED_ENV_KEYS_EXACT as Set<string>;
33
+ const daemonPrefixes = daemonMod.RESERVED_ENV_PREFIXES as ReadonlyArray<string>;
34
+
35
+ // EXACT set equality
36
+ expect(daemonExact.size).toBe(HUB_EXACT.size);
37
+ for (const k of HUB_EXACT) expect(daemonExact.has(k)).toBe(true);
38
+ for (const k of daemonExact) expect(HUB_EXACT.has(k)).toBe(true);
39
+
40
+ // PREFIX array order-independent equality
41
+ expect(new Set(daemonPrefixes)).toEqual(new Set(HUB_PREFIXES));
42
+ });
43
+ });
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { RESERVED_ENV_KEYS_EXACT, RESERVED_ENV_PREFIXES, isReservedEnvKey } from "./reserved-env.js";
3
+
4
+ describe("RFC-026 v4 §4.4.7 reserved env denylist (B1)", () => {
5
+ test("exact PATH/HOME/LANG/NODE_OPTIONS blocked", () => {
6
+ for (const k of ["PATH", "HOME", "LANG", "LC_ALL", "SHELL", "USER", "LOGNAME", "NODE_OPTIONS", "IFS", "TMPDIR"]) {
7
+ expect(isReservedEnvKey(k)).toBe(true);
8
+ }
9
+ });
10
+ test("LD_ prefix family blocked (Linux dynamic loader)", () => {
11
+ for (const k of ["LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT", "LD_"]) {
12
+ expect(isReservedEnvKey(k)).toBe(true);
13
+ }
14
+ });
15
+ test("DYLD_ prefix blocked (macOS)", () => {
16
+ expect(isReservedEnvKey("DYLD_INSERT_LIBRARIES")).toBe(true);
17
+ expect(isReservedEnvKey("DYLD_LIBRARY_PATH")).toBe(true);
18
+ });
19
+ test("BUN_/NPM_/NPM_CONFIG_/NODE_ prefix blocked", () => {
20
+ expect(isReservedEnvKey("BUN_INSTALL")).toBe(true);
21
+ expect(isReservedEnvKey("NPM_TOKEN")).toBe(true);
22
+ expect(isReservedEnvKey("NPM_CONFIG_REGISTRY")).toBe(true);
23
+ expect(isReservedEnvKey("NODE_PATH")).toBe(true);
24
+ expect(isReservedEnvKey("NODE_TLS_REJECT_UNAUTHORIZED")).toBe(true);
25
+ });
26
+ test("legitimate API key style is NOT blocked", () => {
27
+ for (const k of ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "TELEGRAM_BOT_TOKEN", "DEEPSEEK_API_KEY", "MY_APP_CONFIG"]) {
28
+ expect(isReservedEnvKey(k)).toBe(false);
29
+ }
30
+ });
31
+ test("set shape sanity — both EXACT and PREFIX non-empty + frozen-ish", () => {
32
+ expect(RESERVED_ENV_KEYS_EXACT.size).toBeGreaterThan(10);
33
+ expect(RESERVED_ENV_PREFIXES.length).toBeGreaterThan(3);
34
+ });
35
+ });
@@ -0,0 +1,31 @@
1
+ // RFC-026 v4 §4.4.7 B1 — reserved env-key denylist (single source of
2
+ // truth for hub + daemon).
3
+ //
4
+ // Scope: env_refs naming a system/process-model sensitive key (PATH /
5
+ // LD_PRELOAD / NODE_OPTIONS / BUN_* / NPM_* / ...) MUST be rejected
6
+ // before the secret value ever reaches fork(), because such a key in
7
+ // the child env is effectively arbitrary code execution.
8
+ //
9
+ // G9 (drift guard): this module is the ONE source. Daemon-side runtime
10
+ // imports the same constants. A divergence between hub and daemon would
11
+ // let attacker target the looser layer; CI test asserts set equality.
12
+
13
+ export const RESERVED_ENV_KEYS_EXACT = new Set<string>([
14
+ "PATH", "HOME", "LANG", "LC_ALL", "SHELL", "USER", "LOGNAME",
15
+ "NODE_OPTIONS", "IFS", "PS1", "PS4", "ENV", "BASH_ENV",
16
+ "CDPATH", "PROMPT_COMMAND", "TMPDIR",
17
+ ]);
18
+
19
+ // Prefix-match denied. Order matters only for clarity (LD_ catches
20
+ // LD_PRELOAD / LD_LIBRARY_PATH / LD_AUDIT; DYLD_ catches DYLD_INSERT_
21
+ // LIBRARIES; BUN_ / NPM_ / NPM_CONFIG_ / NODE_ catch ecosystem-level
22
+ // runtime config).
23
+ export const RESERVED_ENV_PREFIXES: ReadonlyArray<string> = [
24
+ "LD_", "DYLD_", "BUN_", "NPM_", "NPM_CONFIG_", "NODE_",
25
+ ];
26
+
27
+ export function isReservedEnvKey(k: string): boolean {
28
+ if (RESERVED_ENV_KEYS_EXACT.has(k)) return true;
29
+ for (const p of RESERVED_ENV_PREFIXES) if (k.startsWith(p)) return true;
30
+ return false;
31
+ }