@sleep2agi/commhub-server 0.9.0-preview.2 → 0.9.0-preview.4

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 (
package/src/index.ts CHANGED
@@ -1950,10 +1950,25 @@ Bun.serve({
1950
1950
  }
1951
1951
 
1952
1952
  // ── REST: nodes table (V2 Sprint 2) ──
1953
+ // Explicit column list — NOT `SELECT *`. Two reasons:
1954
+ // 1. `SELECT *` silently broadcasts any column added later to the
1955
+ // response (RFC-024 added config_revision/config_snapshot which
1956
+ // the dashboard's GET /api/nodes/:id/config endpoint owns;
1957
+ // RFC-028 will add more vault-flavored columns). Tests asserting
1958
+ // response shape break on every ALTER; integrations that key
1959
+ // off Object.keys() can pick up internals.
1960
+ // 2. The list endpoint and the per-node config snapshot endpoint
1961
+ // have different consumer contracts; this list should be the
1962
+ // stable, dashboard-facing fields only.
1963
+ // Add a new column to this list explicitly when a real client needs
1964
+ // it; do NOT switch back to SELECT *.
1953
1965
  if (url.pathname === "/api/nodes") {
1954
1966
  const nodeId = url.searchParams.get("node_id");
1955
1967
  const alias = url.searchParams.get("alias");
1956
- let sql = "SELECT * FROM nodes WHERE 1=1";
1968
+ let sql = `SELECT node_id, node_name, alias, runtime, model,
1969
+ config_path, channels, server, hostname,
1970
+ network_id, created_at, updated_at
1971
+ FROM nodes WHERE 1=1`;
1957
1972
  const params: any[] = [];
1958
1973
  sql = addNetworkScope(sql, params, restScope);
1959
1974
  if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
@@ -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
+ }
package/src/tools.ts CHANGED
@@ -1,8 +1,29 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod/v4";
3
- import { db, uuidv4, logTaskEvent, chainReplyToParent } from "./db.js";
3
+ import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken } from "./db.js";
4
4
  import { pushEvent } from "./push.js";
5
- import { getUserNetworkRole } from "./auth.js";
5
+ import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
6
+ import {
7
+ buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
8
+ validateName as validateChildName,
9
+ validateRuntime,
10
+ validateModel,
11
+ validateChannelsP1,
12
+ validateEnvRefs,
13
+ FLAG_KEYS,
14
+ validateFlagValue,
15
+ ValidationError,
16
+ } from "./create-node-validate.js";
17
+ import {
18
+ putPendingEnvBlob,
19
+ takePendingEnvBlob,
20
+ newRequestId,
21
+ finalizeCreateOnFirstRegister,
22
+ startPendingEnvGcTimer,
23
+ startSweeperTimer,
24
+ auditCreateNode,
25
+ resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
26
+ } from "./create-node.js";
6
27
  import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias } from "./rename.js";
7
28
  import {
8
29
  ALLOWED_FLAGS,
@@ -1418,7 +1439,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1418
1439
  callerNetworkId: string | null,
1419
1440
  ): { row: any | null; sec1Ok: boolean } => {
1420
1441
  const row = db.get<any>(
1421
- "SELECT node_id, alias, network_id, config_revision FROM nodes WHERE node_id = ?1",
1442
+ "SELECT node_id, alias, network_id, config_revision, config_snapshot FROM nodes WHERE node_id = ?1",
1422
1443
  nodeId,
1423
1444
  );
1424
1445
  if (!row) return { row: null, sec1Ok: false };
@@ -1751,6 +1772,362 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1751
1772
  };
1752
1773
  },
1753
1774
  );
1775
+
1776
+ // ── RFC-026 P1 — create-node + host-daemon (3 MCP tools) ──────────
1777
+ // Background timers (GC + sweeper) are idempotent + cheap; safe to
1778
+ // call on every registerTools invocation (statless mode re-registers
1779
+ // per request). They unref themselves so don't hold the event loop.
1780
+ startPendingEnvGcTimer();
1781
+ startSweeperTimer();
1782
+
1783
+ // §4.1.4 C2 — caller daemon resolved via token-bound identity (NOT
1784
+ // alias). Thin closure over the module-level helper so callers in
1785
+ // this scope can use the captured request-level vars. The pure
1786
+ // helper lives in create-node.ts so unit tests call exactly the
1787
+ // same code path the tools do (per 通信龙 PR #299 nit 1 — no inline-
1788
+ // mirror SQL in tests).
1789
+ const resolveCallerDaemonTokenBound = () =>
1790
+ _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
1791
+
1792
+ // Helper — map a ValidationError thrown from create-node-validate
1793
+ // into the MCP-tool-call JSON reply shape.
1794
+ const validationFailReply = (e: unknown) => {
1795
+ if (e instanceof ValidationError) {
1796
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: e.code, ...(e.detail || {}) }) }] };
1797
+ }
1798
+ throw e;
1799
+ };
1800
+
1801
+ // §2.5 step 1+2 — dashboard-facing tool. Validates spec, mint
1802
+ // child-ntok, stash env_blob in pendingEnvBlobs Map, write request
1803
+ // row (metadata only — no env_blob in SQL), pushEvent doorbell.
1804
+ server.tool(
1805
+ "create_node",
1806
+ "Create a node on a host-daemon and start it. Daemon forks `anet node create + start` on the target machine; child reports back to hub. RFC-026.",
1807
+ {
1808
+ daemon_node_id: z.string().min(1).max(200),
1809
+ node_spec: z.object({
1810
+ name: z.string().min(1).max(64),
1811
+ runtime: z.string().min(1).max(64),
1812
+ model: z.string().min(1).max(100),
1813
+ flags: z.record(z.unknown()).optional(),
1814
+ env_refs: z.array(z.string().max(64)).optional(),
1815
+ channels: z.array(z.unknown()).optional(),
1816
+ }),
1817
+ network_id: z.string().max(200).optional(),
1818
+ },
1819
+ async ({ daemon_node_id, node_spec, network_id: clientNetId }) => {
1820
+ const effectiveNetId = getNetworkId(clientNetId);
1821
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
1822
+
1823
+ // §4.1.1 — admin+ for create_node (creating a node = pulling new
1824
+ // resource + burning API quota, one level above edit single flag)
1825
+ const callerRole = enforceUserId && effectiveNetId
1826
+ ? getUserNetworkRole(enforceUserId, effectiveNetId)
1827
+ : null;
1828
+ if (callerRole !== "admin" && callerRole !== "owner") {
1829
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_create_node", required_role: "admin", caller_role: callerRole }) }] };
1830
+ }
1831
+
1832
+ // Daemon must exist + must be in caller's network + must be
1833
+ // online with role=host_supervisor capability.
1834
+ const { row: daemon, sec1Ok } = resolveTargetNode(daemon_node_id, effectiveNetId);
1835
+ if (!daemon) {
1836
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found", daemon_node_id }) }] };
1837
+ }
1838
+ if (!sec1Ok) {
1839
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
1840
+ }
1841
+
1842
+ // Read daemon's host_supervisor capability + allowlist from its
1843
+ // last reported config_snapshot. P1 simplification: we accept
1844
+ // (allowed_runtimes empty / allowed_secret_keys absent) as
1845
+ // "accept any in the global enum"; P2 will tighten when daemon
1846
+ // self-publishes its own allowlist via daemon_capabilities.
1847
+ let daemonAllowList = new Set<string>();
1848
+ let daemonAllowedRuntimes: string[] | null = null;
1849
+ try {
1850
+ const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
1851
+ if (snap?.daemon_capabilities?.allowed_secret_keys) {
1852
+ daemonAllowList = new Set(snap.daemon_capabilities.allowed_secret_keys);
1853
+ }
1854
+ if (Array.isArray(snap?.daemon_capabilities?.allowed_runtimes)) {
1855
+ daemonAllowedRuntimes = snap.daemon_capabilities.allowed_runtimes;
1856
+ }
1857
+ } catch { /* permissive P1 fallback */ }
1858
+
1859
+ // §4.2.2 — structural validation (catches name/runtime/model/
1860
+ // flag injection at the hub edge). Daemon repeats this; double
1861
+ // layer per RFC §4.2.2.
1862
+ try {
1863
+ validateChildName(node_spec.name);
1864
+ validateRuntime(node_spec.runtime);
1865
+ validateModel(node_spec.model);
1866
+ validateChannelsP1((node_spec as any).channels);
1867
+ for (const [k, v] of Object.entries(node_spec.flags || {})) {
1868
+ if (!(FLAG_KEYS as readonly string[]).includes(k)) throw new ValidationError("flag_key_unknown", { field: k });
1869
+ validateFlagValue(k, v);
1870
+ }
1871
+ } catch (e) {
1872
+ return validationFailReply(e);
1873
+ }
1874
+
1875
+ // P1 daemon-side allowlist: if daemon publishes allowed_runtimes,
1876
+ // enforce at hub for fast-fail; daemon repeats.
1877
+ if (daemonAllowedRuntimes && !daemonAllowedRuntimes.includes(node_spec.runtime)) {
1878
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "runtime_not_in_local_allowlist", runtime: node_spec.runtime, allowed: daemonAllowedRuntimes }) }] };
1879
+ }
1880
+
1881
+ // §4.4.7 — env_refs strict (7-step gate) + resolve to env_blob.
1882
+ let envBlob: Record<string, string> = {};
1883
+ const envRefs = (node_spec as any).env_refs;
1884
+ try {
1885
+ envBlob = validateEnvRefs(envRefs, {
1886
+ callerNetworkId: effectiveNetId || "default",
1887
+ daemonAllowList,
1888
+ networkSecretsGet: (_net: string, _key: string) => undefined, // P1: no vault yet — see note below
1889
+ });
1890
+ } catch (e) {
1891
+ return validationFailReply(e);
1892
+ }
1893
+ // P1 NOTE — network_secrets vault is RFC §4.4 / §2.4 future
1894
+ // work. For now if dashboard sends env_refs we'll reject as
1895
+ // not-in-vault (above). When the vault lands, replace the
1896
+ // `networkSecretsGet: () => undefined` line with the real DB
1897
+ // lookup; nothing else in this tool needs to change.
1898
+
1899
+ // Single-flight per (daemon, child_name): partial unique index
1900
+ // uniq_ncr_inflight already prevents racing INSERT, but we
1901
+ // surface a friendly error rather than letting the DB constraint
1902
+ // raise.
1903
+ const existing = db.get<{ request_id: string; status: string }>(
1904
+ `SELECT request_id, status FROM node_create_requests WHERE daemon_node_id = ?1 AND child_name = ?2 AND status IN ('pending', 'delivered') ORDER BY created_at DESC LIMIT 1`,
1905
+ daemon_node_id, node_spec.name,
1906
+ );
1907
+ if (existing) {
1908
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_name_conflict", existing_request_id: existing.request_id, existing_status: existing.status }) }] };
1909
+ }
1910
+
1911
+ // §4.2.4 — daemon_max_children backpressure (best-effort: count
1912
+ // currently-active children for this daemon).
1913
+ const maxChildren = (() => {
1914
+ try {
1915
+ const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
1916
+ const m = snap?.daemon_capabilities?.max_concurrent_children;
1917
+ return (typeof m === "number" && m > 0) ? m : 20;
1918
+ } catch { return 20; }
1919
+ })();
1920
+ const childCount = db.get<{ n: number }>(
1921
+ `SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1 AND status IN ('pending', 'delivered', 'succeeded')`,
1922
+ daemon_node_id,
1923
+ )?.n || 0;
1924
+ if (childCount >= maxChildren) {
1925
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_max_children", current: childCount, max: maxChildren }) }] };
1926
+ }
1927
+
1928
+ // §2.5 step 2 + §4.4 F1 — mint child-ntok + stash env_blob in
1929
+ // Map (NOT in DB). Token row marked role='child' + request_id
1930
+ // for sweeper traceability per §4.4.8 impl note.
1931
+ const childToken = generateNetworkToken();
1932
+ const childTokenId = generateId("tok");
1933
+ const networkIdForChild = daemon.network_id || effectiveNetId || "default";
1934
+ const requestId = newRequestId();
1935
+ // Use the dashboard caller's user_id as the token's user_id so
1936
+ // resolveToken returns sane role / network on the child's side
1937
+ // (audit trail = whoever created it).
1938
+ if (!enforceUserId) {
1939
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "auth_required" }) }] };
1940
+ }
1941
+ db.run(
1942
+ `INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, role, request_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)`,
1943
+ [childTokenId, hashToken(childToken), enforceUserId, networkIdForChild, `node:${node_spec.name}`, "network", "child", requestId]
1944
+ );
1945
+
1946
+ putPendingEnvBlob({
1947
+ request_id: requestId,
1948
+ daemon_node_id,
1949
+ env_blob: envBlob,
1950
+ child_token: childToken,
1951
+ child_token_id: childTokenId,
1952
+ });
1953
+
1954
+ // Write metadata-only row. env_blob field deliberately ABSENT
1955
+ // from the schema (see db.ts CREATE TABLE) — F1 lock.
1956
+ const envKeys = Object.keys(envBlob);
1957
+ db.run(
1958
+ `INSERT INTO node_create_requests
1959
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
1960
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
1961
+ [
1962
+ requestId, daemon_node_id, node_spec.name, networkIdForChild,
1963
+ node_spec.runtime, node_spec.model, JSON.stringify(node_spec.flags || {}),
1964
+ JSON.stringify(envKeys), childTokenId, Date.now(), callerTokenId || "unknown",
1965
+ ],
1966
+ );
1967
+
1968
+ // SSE doorbell — daemon will pull via get_create_request.
1969
+ // Payload carries ONLY request_id (no secret); daemon current
1970
+ // SSE handler resolves the rest via MCP call.
1971
+ pushEvent(daemon.alias, { type: "create_node", request_id: requestId }, networkIdForChild);
1972
+
1973
+ // §4.5 audit — dispatch succeeded
1974
+ auditCreateNode({
1975
+ action: "create_node_dispatched",
1976
+ user_id: enforceUserId,
1977
+ network_id: networkIdForChild,
1978
+ target_id: requestId,
1979
+ detail: {
1980
+ daemon_node_id,
1981
+ child_name: node_spec.name,
1982
+ runtime: node_spec.runtime,
1983
+ model: node_spec.model,
1984
+ flag_keys: Object.keys(node_spec.flags || {}),
1985
+ env_keys: envKeys,
1986
+ },
1987
+ });
1988
+
1989
+ return {
1990
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId }) }],
1991
+ };
1992
+ },
1993
+ );
1994
+
1995
+ // §2.5 step 3 — daemon-facing pull. Returns full spec + env_blob +
1996
+ // child_ntok in one shot. Map evicted on take (one-shot consume).
1997
+ //
1998
+ // §4.1.4 C2 token-bound daemon resolution (PR #299 BLOCKER #1, 通信牛):
1999
+ // We MUST resolve the caller daemon via token-bound identity, NOT
2000
+ // alias. alias is NOT a security boundary — two daemons with the same
2001
+ // alias in different networks (or attacker-named-itself-the-same)
2002
+ // would otherwise resolve to the wrong row. Same class as the prior
2003
+ // report_status cross-tenant re-home bug.
2004
+ //
2005
+ // Resolution chain: caller's ntok (callerTokenId + callerTokenIsNetwork)
2006
+ // → api_tokens row → joins to nodes via name='node:<alias>' AND
2007
+ // network_id matches → unique daemon node row scoped to caller's
2008
+ // network. If the ntok isn't bound to a node, or the joined node
2009
+ // isn't a host_supervisor, reject.
2010
+ server.tool(
2011
+ "get_create_request",
2012
+ "Daemon pulls a pending create-node request (called when SSE create_node doorbell arrives). RFC-026.",
2013
+ {
2014
+ request_id: z.string().min(1).max(200),
2015
+ },
2016
+ async ({ request_id }) => {
2017
+ const callerDaemon = resolveCallerDaemonTokenBound();
2018
+ if (!callerDaemon.ok) {
2019
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
2020
+ }
2021
+
2022
+ const row = db.get<{ request_id: string; daemon_node_id: string; status: string; child_token_id: string | null; network_id: string }>(
2023
+ `SELECT request_id, daemon_node_id, status, child_token_id, network_id FROM node_create_requests WHERE request_id = ?1`,
2024
+ request_id,
2025
+ );
2026
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
2027
+ // §4.1.4 — strict daemon binding by token-derived node_id.
2028
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
2029
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
2030
+ }
2031
+ // Additional network-scope guard (defense in depth: row's
2032
+ // network_id MUST equal caller's network).
2033
+ if (row.network_id !== callerDaemon.networkId) {
2034
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
2035
+ }
2036
+ if (row.status !== "pending") {
2037
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_pending", current_status: row.status }) }] };
2038
+ }
2039
+
2040
+ // Take env_blob from Map; this is the one-shot consume per F1.
2041
+ // takePendingEnvBlob ALSO checks daemon binding (belt+braces).
2042
+ const blob = takePendingEnvBlob(request_id, callerDaemon.daemonNodeId);
2043
+ if (!blob) {
2044
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "env_blob_unavailable" }) }] };
2045
+ }
2046
+ // Hydrate spec from row.
2047
+ const specRow = db.get<{ child_name: string; runtime: string; model: string; flags_json: string }>(
2048
+ `SELECT child_name, runtime, model, flags_json FROM node_create_requests WHERE request_id = ?1`,
2049
+ request_id,
2050
+ );
2051
+ if (!specRow) {
2052
+ // Should never happen given the earlier row read.
2053
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_vanished" }) }] };
2054
+ }
2055
+ // Mark delivered (so sweeper distinguishes F-1 from F-2).
2056
+ db.run(
2057
+ `UPDATE node_create_requests SET status = 'delivered', delivered_at = ?1 WHERE request_id = ?2 AND status = 'pending'`,
2058
+ [Date.now(), request_id],
2059
+ );
2060
+
2061
+ return {
2062
+ content: [{ type: "text" as const, text: JSON.stringify({
2063
+ ok: true,
2064
+ request_id,
2065
+ node_spec: {
2066
+ name: specRow.child_name,
2067
+ runtime: specRow.runtime,
2068
+ model: specRow.model,
2069
+ flags: JSON.parse(specRow.flags_json),
2070
+ channels: [],
2071
+ },
2072
+ child_token: blob.child_token,
2073
+ env_blob: blob.env_blob,
2074
+ }) }],
2075
+ };
2076
+ },
2077
+ );
2078
+
2079
+ // §2.5 step 4 — daemon reports outcome. Note: 'succeeded' is set
2080
+ // automatically by hub when the child first registers (content-match
2081
+ // in upsertNodeWithSec1Guard); daemon's ack here is for explicit
2082
+ // failures (fork crashed, etc.). Daemon should still call this on
2083
+ // success too — it's a useful idempotent confirmation + lets hub
2084
+ // record fork-side info.
2085
+ server.tool(
2086
+ "ack_create_request",
2087
+ "Daemon acks a create-node request (called after fork). status='started' or 'failed'. RFC-026.",
2088
+ {
2089
+ request_id: z.string().min(1).max(200),
2090
+ status: z.enum(["started", "failed", "rejected"]),
2091
+ error: z.string().max(1000).optional(),
2092
+ child_pid: z.number().int().optional(),
2093
+ },
2094
+ async ({ request_id, status, error: ackError, child_pid: _pid }) => {
2095
+ const callerDaemon = resolveCallerDaemonTokenBound();
2096
+ if (!callerDaemon.ok) {
2097
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
2098
+ }
2099
+ const row = db.get<{ daemon_node_id: string; status: string; child_token_id: string | null; network_id: string }>(
2100
+ `SELECT daemon_node_id, status, child_token_id, network_id FROM node_create_requests WHERE request_id = ?1`,
2101
+ request_id,
2102
+ );
2103
+ if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
2104
+ if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
2105
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
2106
+ }
2107
+ if (row.network_id !== callerDaemon.networkId) {
2108
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
2109
+ }
2110
+ const ackedAt = Date.now();
2111
+ if (status === "started") {
2112
+ // Don't flip to 'succeeded' here — that happens via content-
2113
+ // match when the child actually registers. We just stamp ack.
2114
+ db.run(
2115
+ `UPDATE node_create_requests SET acked_at = ?1 WHERE request_id = ?2 AND status IN ('delivered', 'pending')`,
2116
+ [ackedAt, request_id],
2117
+ );
2118
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "awaiting_register" }) }] };
2119
+ }
2120
+ // failed / rejected — revoke child-ntok + mark request terminal
2121
+ if (row.child_token_id) {
2122
+ db.run(`UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = ?1 AND revoked_at IS NULL`, [row.child_token_id]);
2123
+ }
2124
+ db.run(
2125
+ `UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3 WHERE request_id = ?4 AND status IN ('pending', 'delivered')`,
2126
+ [status, ackError || null, ackedAt, request_id],
2127
+ );
2128
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
2129
+ },
2130
+ );
1754
2131
  }
1755
2132
 
1756
2133
  // ────────────────────────────────────────────────────────────────────
@@ -1853,6 +2230,22 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
1853
2230
  // against the reported snapshot.
1854
2231
  finalizePendingMatchingUpdates(input.node_id, input.config_snapshot);
1855
2232
  }
2233
+ // RFC-026 §2.5 step 4 — on every register/report_status, opportunistically
2234
+ // close out any pending create_node request whose child_name matches
2235
+ // this incoming alias. Same content-match pattern as RFC-024's
2236
+ // finalizePendingMatchingUpdates; the new child doesn't have the
2237
+ // request_id, so hub does the matching on its behalf.
2238
+ try {
2239
+ finalizeCreateOnFirstRegister({
2240
+ node_id: input.node_id,
2241
+ alias: input.alias || input.node_name || "",
2242
+ network_id: input.callerNetworkId ?? null,
2243
+ });
2244
+ } catch (e: any) {
2245
+ // create-node finalize is best-effort — never block report_status
2246
+ // on it. Log + continue.
2247
+ console.warn(`[commhub] create-node finalize on report_status failed: ${e?.message || e}`);
2248
+ }
1856
2249
  return { result: existing ? "updated" : "inserted", node_id: input.node_id };
1857
2250
  }
1858
2251