@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/package.json +3 -2
- package/src/auth.ts +3 -1
- package/src/config-apply-sec1.test.ts +163 -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 +491 -3
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
|
// ────────────────────────────────────────────────────────────────────
|
|
@@ -1843,6 +2220,117 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
|
|
|
1843
2220
|
`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
|
|
1844
2221
|
[JSON.stringify(input.config_snapshot), input.node_id],
|
|
1845
2222
|
);
|
|
2223
|
+
// RFC-024 — finalize any pending/restarting update whose target
|
|
2224
|
+
// patch is now reflected in the snapshot. Closes the
|
|
2225
|
+
// restart-required-never-reaches-applied gap that 通信牛 caught:
|
|
2226
|
+
// the old child acks `restarting` + exits 75; the new child boots,
|
|
2227
|
+
// reads the new config, reports status — but never had the
|
|
2228
|
+
// update_id to call ack_config_update(applied) itself. Hub does
|
|
2229
|
+
// it on the new child's behalf by content-matching the patch
|
|
2230
|
+
// against the reported snapshot.
|
|
2231
|
+
finalizePendingMatchingUpdates(input.node_id, input.config_snapshot);
|
|
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}`);
|
|
1846
2248
|
}
|
|
1847
2249
|
return { result: existing ? "updated" : "inserted", node_id: input.node_id };
|
|
1848
2250
|
}
|
|
2251
|
+
|
|
2252
|
+
/**
|
|
2253
|
+
* RFC-024 restart-finalize (Option A per 通信牛 final review).
|
|
2254
|
+
*
|
|
2255
|
+
* Called from `upsertNodeWithSec1Guard` after writing a fresh
|
|
2256
|
+
* `config_snapshot` for the node. For each pending/restarting update
|
|
2257
|
+
* row, parse the patch and compare every field against the snapshot.
|
|
2258
|
+
* If everything in the patch is now reflected in the live snapshot,
|
|
2259
|
+
* mark the update applied + bump `nodes.config_revision`. The new
|
|
2260
|
+
* child doesn't need to know the update_id — content-matching against
|
|
2261
|
+
* the live state IS the proof that the apply landed.
|
|
2262
|
+
*
|
|
2263
|
+
* Edge cases:
|
|
2264
|
+
* - Empty patch (apply_mode=restart_only from `restart_node` tool) →
|
|
2265
|
+
* ANY snapshot matches (the restart itself was the goal).
|
|
2266
|
+
* - Patch field absent from snapshot → not a match (snapshot
|
|
2267
|
+
* post-dates the apply only when every requested field shows up).
|
|
2268
|
+
* - Multiple concurrent pending rows → impossible by single-flight,
|
|
2269
|
+
* but defensive: finalize OLDEST first so the chain stays linear.
|
|
2270
|
+
*
|
|
2271
|
+
* Pure-ish: takes a snapshot blob (TS shape, see config-snapshot type)
|
|
2272
|
+
* and runs only db.run / db.get. No external IO.
|
|
2273
|
+
*/
|
|
2274
|
+
export function finalizePendingMatchingUpdates(
|
|
2275
|
+
nodeId: string,
|
|
2276
|
+
snapshot: any,
|
|
2277
|
+
): { finalizedCount: number; finalizedIds: string[] } {
|
|
2278
|
+
const pending = db.all<{
|
|
2279
|
+
update_id: string;
|
|
2280
|
+
patch_json: string;
|
|
2281
|
+
apply_mode: string;
|
|
2282
|
+
base_revision: number;
|
|
2283
|
+
}>(
|
|
2284
|
+
"SELECT update_id, patch_json, apply_mode, base_revision FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting') ORDER BY created_at ASC",
|
|
2285
|
+
nodeId,
|
|
2286
|
+
);
|
|
2287
|
+
if (pending.length === 0) return { finalizedCount: 0, finalizedIds: [] };
|
|
2288
|
+
|
|
2289
|
+
const snapModel: string | null | undefined = snapshot?.model;
|
|
2290
|
+
const snapFlags: Record<string, unknown> = (snapshot?.flags && typeof snapshot.flags === "object") ? snapshot.flags : {};
|
|
2291
|
+
|
|
2292
|
+
const finalizedIds: string[] = [];
|
|
2293
|
+
|
|
2294
|
+
for (const row of pending) {
|
|
2295
|
+
let patch: any = {};
|
|
2296
|
+
try { patch = JSON.parse(row.patch_json); } catch { continue; }
|
|
2297
|
+
|
|
2298
|
+
let matches = true;
|
|
2299
|
+
// restart_only / empty patch → any snapshot proves the restart
|
|
2300
|
+
// happened, finalize unconditionally.
|
|
2301
|
+
if (row.apply_mode !== "restart_only") {
|
|
2302
|
+
if (patch.model !== undefined) {
|
|
2303
|
+
if (snapModel !== patch.model) { matches = false; }
|
|
2304
|
+
}
|
|
2305
|
+
if (matches && patch.flags && typeof patch.flags === "object") {
|
|
2306
|
+
for (const [k, v] of Object.entries(patch.flags as Record<string, unknown>)) {
|
|
2307
|
+
// Canonical-key fallback for legacy aliases. The dashboard
|
|
2308
|
+
// schema uses `budget` and `timeout`; node-side config may
|
|
2309
|
+
// also carry the older `maxBudgetUsd` / `claudeTimeoutMs` /
|
|
2310
|
+
// `codexTimeoutMs` keys. agent-node's buildConfigSnapshot
|
|
2311
|
+
// only reports the canonical key, so we just compare on `k`.
|
|
2312
|
+
if (snapFlags[k] !== v) { matches = false; break; }
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
if (!matches) continue;
|
|
2318
|
+
|
|
2319
|
+
// Promote nodes.config_revision atomically with the update row.
|
|
2320
|
+
const nextRev = (db.get<{ config_revision: number }>(
|
|
2321
|
+
"SELECT config_revision FROM nodes WHERE node_id = ?1",
|
|
2322
|
+
nodeId,
|
|
2323
|
+
)?.config_revision || 0) + 1;
|
|
2324
|
+
db.run(
|
|
2325
|
+
"UPDATE node_config_updates SET status = 'applied', acked_at = ?1, new_revision = ?2 WHERE update_id = ?3 AND status IN ('pending', 'restarting')",
|
|
2326
|
+
[Date.now(), nextRev, row.update_id],
|
|
2327
|
+
);
|
|
2328
|
+
db.run("UPDATE nodes SET config_revision = ?1 WHERE node_id = ?2", [nextRev, nodeId]);
|
|
2329
|
+
finalizedIds.push(row.update_id);
|
|
2330
|
+
console.log(
|
|
2331
|
+
`[commhub] ✓ finalize update ${row.update_id} via report_status content-match: node=${nodeId} new_revision=${nextRev} apply_mode=${row.apply_mode}`,
|
|
2332
|
+
);
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
return { finalizedCount: finalizedIds.length, finalizedIds };
|
|
2336
|
+
}
|