@sleep2agi/commhub-server 0.9.0-preview.2 → 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/package.json +1 -1
- package/src/auth.ts +3 -1
- package/src/create-node-validate.test.ts +187 -0
- package/src/create-node-validate.ts +206 -0
- package/src/create-node.test.ts +354 -0
- package/src/create-node.ts +299 -0
- package/src/db.ts +60 -0
- package/src/shared/reserved-env-drift.test.ts +43 -0
- package/src/shared/reserved-env.test.ts +35 -0
- package/src/shared/reserved-env.ts +31 -0
- package/src/tools.ts +396 -3
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
|
+
}
|
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
|
|