@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.12
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/ack-create-request.test.ts +331 -0
- package/src/api-nodes-shape.test.ts +172 -0
- package/src/auth-kdf-migration.test.ts +378 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth.ts +65 -5
- package/src/config-apply-sec1.test.ts +723 -0
- package/src/config-apply-validate.test.ts +260 -0
- package/src/config-apply-validate.ts +171 -0
- 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 +346 -0
- package/src/cross-tenant-injection.test.ts +110 -0
- package/src/db.ts +499 -8
- package/src/index.ts +247 -19
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/retention.test.ts +450 -0
- package/src/retention.ts +269 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -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/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -0
- package/src/stop-delete-node.test.ts +720 -0
- package/src/tools.ts +2285 -64
- package/src/update-provider.test.ts +234 -0
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { z } from "zod/v4";
|
|
|
4
4
|
import { registerTools } from "./tools.js";
|
|
5
5
|
import { db, logTaskEvent, logAudit } from "./db.js";
|
|
6
6
|
import { createSSEStream, pushEvent, getSSEStats } from "./push.js";
|
|
7
|
+
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
7
8
|
import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
8
9
|
import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
|
|
9
10
|
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
@@ -24,6 +25,8 @@ import {
|
|
|
24
25
|
} from "./uploads.js";
|
|
25
26
|
import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from "fs";
|
|
26
27
|
import { dirname as pathDirname } from "path";
|
|
28
|
+
import { startRetentionSweeper } from "./retention.js";
|
|
29
|
+
import { startStaleSessionSweeper } from "./stale-sweeper.js";
|
|
27
30
|
|
|
28
31
|
const PORT = Number(process.env.PORT) || 9200;
|
|
29
32
|
const HOST = process.env.HOST || "127.0.0.1";
|
|
@@ -1066,11 +1069,9 @@ Bun.serve({
|
|
|
1066
1069
|
|
|
1067
1070
|
// ── REST: all sessions status ──
|
|
1068
1071
|
if (url.pathname === "/api/status") {
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
staleSql = addNetworkScope(staleSql, staleParams, restScope);
|
|
1073
|
-
db.run(staleSql, staleParams);
|
|
1072
|
+
// Round-2/4 review ③: stale-marking moved to startStaleSessionSweeper()
|
|
1073
|
+
// (background timer, ~60s cadence). Read paths no longer fire UPDATE.
|
|
1074
|
+
// Per-request UPDATE was a write-amp vector — see stale-sweeper.ts.
|
|
1074
1075
|
cleanupCommittedRenameSessions(restScope.networkId ? [restScope.networkId] : restScope.networkIds ?? null);
|
|
1075
1076
|
// `?light=1` returns a narrow projection (alias / status / agent / task /
|
|
1076
1077
|
// server / updated_at + runtime + network_id) — used by the mobile APP
|
|
@@ -1138,12 +1139,7 @@ Bun.serve({
|
|
|
1138
1139
|
|
|
1139
1140
|
// ── REST: aggregate agents by physical server ──
|
|
1140
1141
|
if (url.pathname === "/api/servers") {
|
|
1141
|
-
|
|
1142
|
-
const staleParams: any[] = [cutoff];
|
|
1143
|
-
let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
|
|
1144
|
-
staleSql = addNetworkScope(staleSql, staleParams, restScope);
|
|
1145
|
-
db.run(staleSql, staleParams);
|
|
1146
|
-
|
|
1142
|
+
// Round-2/4 review ③: stale-marking moved to background sweeper.
|
|
1147
1143
|
const params: any[] = [];
|
|
1148
1144
|
let sql = `
|
|
1149
1145
|
SELECT hostname, ip, cpu_load_1min, cpu_cores, mem_avail_gb, mem_used_gb,
|
|
@@ -1220,12 +1216,7 @@ Bun.serve({
|
|
|
1220
1216
|
const detailKind = serverDetailMatch[2];
|
|
1221
1217
|
if (!host) return withCors(req, Response.json({ ok: false, error: "host required" }, { status: 400 }));
|
|
1222
1218
|
|
|
1223
|
-
|
|
1224
|
-
const staleParams: any[] = [cutoff];
|
|
1225
|
-
let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
|
|
1226
|
-
staleSql = addNetworkScope(staleSql, staleParams, restScope);
|
|
1227
|
-
db.run(staleSql, staleParams);
|
|
1228
|
-
|
|
1219
|
+
// Round-2/4 review ③: stale-marking moved to background sweeper.
|
|
1229
1220
|
if (detailKind === "agents") {
|
|
1230
1221
|
const params: any[] = [host, host];
|
|
1231
1222
|
let sql = `
|
|
@@ -1631,6 +1622,18 @@ Bun.serve({
|
|
|
1631
1622
|
return withCors(req, Response.json(payload, { status: 429 }));
|
|
1632
1623
|
}
|
|
1633
1624
|
|
|
1625
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.2a REST site
|
|
1626
|
+
// 1/2 — paired with the 6 MCP sites guarded in tools.ts at PR1.1).
|
|
1627
|
+
// Dashboard's "Dispatch" button hits this endpoint, so without
|
|
1628
|
+
// the guard a stopping/stopped/deleting node still got tasks
|
|
1629
|
+
// routed through REST. Returns 409 with the same structured
|
|
1630
|
+
// payload the MCP handlers use.
|
|
1631
|
+
{
|
|
1632
|
+
const lc = assertNodeActive(targetAlias, taskNetId ?? null);
|
|
1633
|
+
if (!lc.ok) {
|
|
1634
|
+
return withCors(req, Response.json(lc, { status: 409 }));
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1634
1637
|
// Mirror send_task MCP: write inbox + tasks rows in a single
|
|
1635
1638
|
// transaction so the dispatch is visible to dashboard's Tasks page
|
|
1636
1639
|
// and the parent_task_id lineage chain. Previously this endpoint
|
|
@@ -1711,6 +1714,12 @@ Bun.serve({
|
|
|
1711
1714
|
const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
|
|
1712
1715
|
const ids: string[] = [];
|
|
1713
1716
|
for (const t of targets) {
|
|
1717
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.2a REST site
|
|
1718
|
+
// 2/2). Broadcast skips non-active recipients silently per its
|
|
1719
|
+
// best-effort semantics — mirrors the MCP broadcast handler
|
|
1720
|
+
// in tools.ts (PR1.1 site 6/6).
|
|
1721
|
+
const lc = assertNodeActive(t.alias, t.network_id ?? null);
|
|
1722
|
+
if (!lc.ok) continue;
|
|
1714
1723
|
const id = crypto.randomUUID();
|
|
1715
1724
|
db.run(
|
|
1716
1725
|
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
|
|
@@ -1927,17 +1936,195 @@ Bun.serve({
|
|
|
1927
1936
|
}));
|
|
1928
1937
|
}
|
|
1929
1938
|
|
|
1939
|
+
// ── REST: single node config snapshot (RFC-024 B5) ──
|
|
1940
|
+
// Dashboard calls this for the snapshot path. Returns the masked
|
|
1941
|
+
// {model, flags, config_revision} the node last posted via
|
|
1942
|
+
// report_status (B6). NEVER reads per-node files. Network-scoped
|
|
1943
|
+
// via addNetworkScope so a netA viewer can't read a netB node.
|
|
1944
|
+
const nodeConfigMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/config$/);
|
|
1945
|
+
if (nodeConfigMatch && req.method === "GET") {
|
|
1946
|
+
const nodeId = decodeURIComponent(nodeConfigMatch[1] ?? "");
|
|
1947
|
+
const params: any[] = [nodeId];
|
|
1948
|
+
let sql = "SELECT node_id, alias, network_id, config_revision, config_snapshot FROM nodes WHERE node_id = ?1";
|
|
1949
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
1950
|
+
sql += " LIMIT 1";
|
|
1951
|
+
const node = db.get<any>(sql, ...params);
|
|
1952
|
+
if (!node) {
|
|
1953
|
+
return withCors(req, Response.json({ ok: false, error: "node_not_found", node_id: nodeId }, { status: 404 }));
|
|
1954
|
+
}
|
|
1955
|
+
let snapshot: any = {};
|
|
1956
|
+
if (node.config_snapshot) {
|
|
1957
|
+
try { snapshot = JSON.parse(node.config_snapshot); } catch { snapshot = {}; }
|
|
1958
|
+
}
|
|
1959
|
+
return withCors(req, Response.json({
|
|
1960
|
+
ok: true,
|
|
1961
|
+
node_id: node.node_id,
|
|
1962
|
+
alias: node.alias,
|
|
1963
|
+
network_id: node.network_id,
|
|
1964
|
+
config_revision: node.config_revision || 0,
|
|
1965
|
+
model: snapshot.model ?? null,
|
|
1966
|
+
flags: snapshot.flags ?? {},
|
|
1967
|
+
config_update_capable: snapshot.config_update_capable ?? false,
|
|
1968
|
+
}));
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1930
1971
|
// ── REST: nodes table (V2 Sprint 2) ──
|
|
1972
|
+
// Explicit column list — NOT `SELECT *`. Two reasons:
|
|
1973
|
+
// 1. `SELECT *` silently broadcasts any column added later to the
|
|
1974
|
+
// response (RFC-024 added config_revision/config_snapshot which
|
|
1975
|
+
// the dashboard's GET /api/nodes/:id/config endpoint owns;
|
|
1976
|
+
// RFC-028 will add more vault-flavored columns). Tests asserting
|
|
1977
|
+
// response shape break on every ALTER; integrations that key
|
|
1978
|
+
// off Object.keys() can pick up internals.
|
|
1979
|
+
// 2. The list endpoint and the per-node config snapshot endpoint
|
|
1980
|
+
// have different consumer contracts; this list should be the
|
|
1981
|
+
// stable, dashboard-facing fields only.
|
|
1982
|
+
// Add a new column to this list explicitly when a real client needs
|
|
1983
|
+
// it; do NOT switch back to SELECT *.
|
|
1984
|
+
// ── REST: list host_supervisor daemons (RFC-026 §9.2.2 / #338 PR2) ──
|
|
1985
|
+
// Mirror of the `list_host_supervisors` MCP tool for non-MCP callers.
|
|
1986
|
+
// Same SQL + role-extract + member-脱敏 logic; SEC-1 scoped via REST
|
|
1987
|
+
// auth pipeline (restScope). Returns {ok, daemons:[...], count}.
|
|
1988
|
+
if (url.pathname === "/api/host-supervisors") {
|
|
1989
|
+
const netParam = url.searchParams.get("network_id");
|
|
1990
|
+
const effectiveNetId = restScope.networkId ?? netParam ?? null;
|
|
1991
|
+
if (!effectiveNetId) {
|
|
1992
|
+
return withCors(req, Response.json({ ok: false, error: "missing_network_id" }, { status: 400 }));
|
|
1993
|
+
}
|
|
1994
|
+
// Role for member-脱敏 (admin/owner = full host_telemetry, others = masked)
|
|
1995
|
+
let isPrivileged = false;
|
|
1996
|
+
if (restAuth?.userId) {
|
|
1997
|
+
const role = getUserNetworkRole(restAuth.userId, effectiveNetId);
|
|
1998
|
+
isPrivileged = role === "admin" || role === "owner";
|
|
1999
|
+
} else {
|
|
2000
|
+
// Network-token caller (no user) — treat as privileged within its network
|
|
2001
|
+
isPrivileged = true;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// EXISTS subquery handles BOTH revoked_at SET and DELETEd token
|
|
2005
|
+
// rows (revokeToken DELETEs the row in production — PR2 v1
|
|
2006
|
+
// SHOULD-FIX per 通信龙 audit). EXISTS naturally dedupes daemons
|
|
2007
|
+
// with rotated tokens.
|
|
2008
|
+
const sqlRows = db.all<Record<string, any>>(`
|
|
2009
|
+
SELECT
|
|
2010
|
+
n.node_id, n.alias, n.hostname, n.network_id,
|
|
2011
|
+
n.runtimes_supported, n.allowed_secret_keys,
|
|
2012
|
+
n.created_at, n.updated_at,
|
|
2013
|
+
s.last_seen_at AS session_last_seen,
|
|
2014
|
+
s.cpu_cores AS session_cpu_cores,
|
|
2015
|
+
s.mem_total_gb AS session_mem_total_gb,
|
|
2016
|
+
s.ip AS session_ip,
|
|
2017
|
+
n.config_snapshot
|
|
2018
|
+
FROM nodes n
|
|
2019
|
+
LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
|
|
2020
|
+
WHERE n.network_id = ?1
|
|
2021
|
+
AND EXISTS (
|
|
2022
|
+
SELECT 1 FROM api_tokens t
|
|
2023
|
+
WHERE t.network_id = n.network_id
|
|
2024
|
+
AND t.name = 'node:' || n.alias
|
|
2025
|
+
AND t.revoked_at IS NULL
|
|
2026
|
+
)
|
|
2027
|
+
ORDER BY n.updated_at DESC
|
|
2028
|
+
`, effectiveNetId);
|
|
2029
|
+
|
|
2030
|
+
const nowMs = Date.now();
|
|
2031
|
+
const ONLINE_MS = 60_000;
|
|
2032
|
+
const daemons = sqlRows
|
|
2033
|
+
.map(r => {
|
|
2034
|
+
let snapRole: string | null = null;
|
|
2035
|
+
if (r.config_snapshot) {
|
|
2036
|
+
try {
|
|
2037
|
+
const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
|
|
2038
|
+
snapRole = typeof parsed?.role === "string" ? parsed.role : null;
|
|
2039
|
+
} catch { /* malformed */ }
|
|
2040
|
+
}
|
|
2041
|
+
return { row: r, role: snapRole };
|
|
2042
|
+
})
|
|
2043
|
+
.filter(({ role }) => role === "host_supervisor")
|
|
2044
|
+
.map(({ row: r }) => {
|
|
2045
|
+
let online = false;
|
|
2046
|
+
let lastSeenAt: string | null = null;
|
|
2047
|
+
if (r.session_last_seen) {
|
|
2048
|
+
lastSeenAt = r.session_last_seen;
|
|
2049
|
+
const t = Date.parse(r.session_last_seen);
|
|
2050
|
+
if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
|
|
2051
|
+
}
|
|
2052
|
+
let runtimes: string[] = [];
|
|
2053
|
+
let secrets: string[] = [];
|
|
2054
|
+
try {
|
|
2055
|
+
if (r.runtimes_supported) {
|
|
2056
|
+
const parsed = JSON.parse(r.runtimes_supported);
|
|
2057
|
+
runtimes = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
|
|
2058
|
+
}
|
|
2059
|
+
} catch {}
|
|
2060
|
+
try {
|
|
2061
|
+
if (r.allowed_secret_keys) {
|
|
2062
|
+
const parsed = JSON.parse(r.allowed_secret_keys);
|
|
2063
|
+
secrets = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
|
|
2064
|
+
}
|
|
2065
|
+
} catch {}
|
|
2066
|
+
const telemetry: Record<string, unknown> = {
|
|
2067
|
+
alert_level: online ? "green" : "gray",
|
|
2068
|
+
};
|
|
2069
|
+
if (isPrivileged) {
|
|
2070
|
+
telemetry.cpu_cores = r.session_cpu_cores ?? null;
|
|
2071
|
+
telemetry.mem_gb = r.session_mem_total_gb ?? null;
|
|
2072
|
+
telemetry.ip_internal = r.session_ip ?? null;
|
|
2073
|
+
}
|
|
2074
|
+
return {
|
|
2075
|
+
daemon_node_id: r.node_id,
|
|
2076
|
+
alias: r.alias,
|
|
2077
|
+
hostname: r.hostname,
|
|
2078
|
+
online,
|
|
2079
|
+
last_seen_at: lastSeenAt,
|
|
2080
|
+
runtimes_supported: runtimes,
|
|
2081
|
+
allowed_secret_keys: secrets,
|
|
2082
|
+
host_telemetry: telemetry,
|
|
2083
|
+
};
|
|
2084
|
+
});
|
|
2085
|
+
return withCors(req, Response.json({ ok: true, daemons, count: daemons.length }));
|
|
2086
|
+
}
|
|
2087
|
+
|
|
1931
2088
|
if (url.pathname === "/api/nodes") {
|
|
1932
2089
|
const nodeId = url.searchParams.get("node_id");
|
|
1933
2090
|
const alias = url.searchParams.get("alias");
|
|
1934
|
-
|
|
2091
|
+
// `config_snapshot` SELECTed ONLY to extract the daemon `role` field
|
|
2092
|
+
// for dashboard discovery — full snapshot is NOT broadcast (would
|
|
2093
|
+
// re-introduce the #312 SELECT * fragility). Mapping step pulls
|
|
2094
|
+
// `role` out then drops `config_snapshot` from the response row.
|
|
2095
|
+
// RFC-027 PR2 dashboard prereq: lifecycle_state is needed
|
|
2096
|
+
// client-side to drive the ⋮ menu (active → "停止/删除";
|
|
2097
|
+
// stopped → "重启/删除"). Hub schema gained the column at PR1
|
|
2098
|
+
// (#345) but /api/nodes never SELECTed it. Following #312
|
|
2099
|
+
// "explicit SELECT" discipline — extend the projection here,
|
|
2100
|
+
// do NOT switch to SELECT *.
|
|
2101
|
+
let sql = `SELECT node_id, node_name, alias, runtime, model,
|
|
2102
|
+
config_path, channels, server, hostname,
|
|
2103
|
+
network_id, created_at, updated_at,
|
|
2104
|
+
config_snapshot, lifecycle_state
|
|
2105
|
+
FROM nodes WHERE 1=1`;
|
|
1935
2106
|
const params: any[] = [];
|
|
1936
2107
|
sql = addNetworkScope(sql, params, restScope);
|
|
1937
2108
|
if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
|
|
1938
2109
|
if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
|
|
1939
2110
|
sql += " ORDER BY updated_at DESC";
|
|
1940
|
-
const
|
|
2111
|
+
const rawRows = db.all<Record<string, any>>(sql, ...params);
|
|
2112
|
+
// Add a single new field `role: string | null` per row, extracted from
|
|
2113
|
+
// config_snapshot JSON. Daemon nodes that posted role='host_supervisor'
|
|
2114
|
+
// via report_status surface here; dashboard uses this for daemon
|
|
2115
|
+
// discovery (replacing the prior `node_daemon_` prefix heuristic).
|
|
2116
|
+
// RFC-026 P1 P2 §9 will supersede this REST proxy with the
|
|
2117
|
+
// `list_host_supervisors` MCP tool.
|
|
2118
|
+
const rows = rawRows.map(r => {
|
|
2119
|
+
let role: string | null = null;
|
|
2120
|
+
const snap = r.config_snapshot;
|
|
2121
|
+
if (snap) {
|
|
2122
|
+
try { role = (typeof snap === "string" ? JSON.parse(snap) : snap)?.role ?? null; }
|
|
2123
|
+
catch { /* malformed snapshot — leave role null */ }
|
|
2124
|
+
}
|
|
2125
|
+
const { config_snapshot, ...rest } = r;
|
|
2126
|
+
return { ...rest, role };
|
|
2127
|
+
});
|
|
1941
2128
|
return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
|
|
1942
2129
|
}
|
|
1943
2130
|
|
|
@@ -2105,9 +2292,31 @@ Security: ${SECURITY_LABEL}
|
|
|
2105
2292
|
},
|
|
2106
2293
|
});
|
|
2107
2294
|
|
|
2295
|
+
// Round-2/4 review ② — periodic retention sweep + incremental VACUUM.
|
|
2296
|
+
// Sweeps every hour by default. Operators can disable any single table
|
|
2297
|
+
// by setting COMMHUB_RETENTION_*_DAYS to a negative value, or shorten
|
|
2298
|
+
// the sweep window via COMMHUB_RETENTION_SWEEP_MINUTES.
|
|
2299
|
+
const sweepIntervalMinutes = Number(process.env.COMMHUB_RETENTION_SWEEP_MINUTES);
|
|
2300
|
+
const sweepIntervalMs = Number.isFinite(sweepIntervalMinutes) && sweepIntervalMinutes > 0
|
|
2301
|
+
? sweepIntervalMinutes * 60 * 1000
|
|
2302
|
+
: 60 * 60 * 1000;
|
|
2303
|
+
const retentionSweeperTimer = startRetentionSweeper(sweepIntervalMs);
|
|
2304
|
+
|
|
2305
|
+
// Round-2/4 review ③ — stale session sweeper (coexists with the
|
|
2306
|
+
// retention sweeper above; different concerns + different cadences).
|
|
2307
|
+
// Replaces the per-request UPDATE in GET /api/status (+ /api/servers,
|
|
2308
|
+
// /api/server-detail/*, MCP get_all_status). Each of those endpoints
|
|
2309
|
+
// used to run UPDATE on the sessions table just to maintain the
|
|
2310
|
+
// derived `offline` status; with the dashboard polling fast that was
|
|
2311
|
+
// 99% no-op write-amp under hot read paths. Now done globally once
|
|
2312
|
+
// every COMMHUB_STALE_SWEEP_SECONDS (default 60s).
|
|
2313
|
+
const staleSweeperTimer = startStaleSessionSweeper();
|
|
2314
|
+
|
|
2108
2315
|
// ── Graceful shutdown ───────────────────────────────
|
|
2109
2316
|
function shutdown() {
|
|
2110
2317
|
console.log("[commhub] shutting down...");
|
|
2318
|
+
clearInterval(retentionSweeperTimer);
|
|
2319
|
+
clearInterval(staleSweeperTimer);
|
|
2111
2320
|
db.close();
|
|
2112
2321
|
process.exit(0);
|
|
2113
2322
|
}
|
|
@@ -2126,3 +2335,22 @@ console.log(`
|
|
|
2126
2335
|
║ Health: http://${HOST}:${PORT}/health ║
|
|
2127
2336
|
╚══════════════════════════════════════════════════╝
|
|
2128
2337
|
`);
|
|
2338
|
+
|
|
2339
|
+
// RFC-028 P1 boot banner — vault key configuration status.
|
|
2340
|
+
// F2 invariant: hub MUST boot regardless of vault state. This is
|
|
2341
|
+
// banner-only (informational); errors are raised lazily at vault op
|
|
2342
|
+
// time. needsKeyToOp=true means the operator should set
|
|
2343
|
+
// ANET_HUB_SECRET_VAULT_KEY before vault/provider features will work.
|
|
2344
|
+
try {
|
|
2345
|
+
const { vaultStatusForBoot } = await import("./vault.js");
|
|
2346
|
+
const s = vaultStatusForBoot();
|
|
2347
|
+
if (s.needsKeyToOp) {
|
|
2348
|
+
console.warn("[rfc-028 vault] ⚠️ network_secrets/providers have data BUT ANET_HUB_SECRET_VAULT_KEY is unset — vault ops will throw vault_master_key_missing until you set the env. Generate one: `openssl rand -hex 32` (must match the key used at write time).");
|
|
2349
|
+
} else if (s.configured) {
|
|
2350
|
+
console.log(`[rfc-028 vault] master key configured (tables_have_data=${s.tablesHaveData})`);
|
|
2351
|
+
} else {
|
|
2352
|
+
console.log("[rfc-028 vault] master key not set + no vault rows — vault gating is lazy; boot OK. Set ANET_HUB_SECRET_VAULT_KEY when enabling provider/secret features.");
|
|
2353
|
+
}
|
|
2354
|
+
} catch (e: any) {
|
|
2355
|
+
console.warn(`[rfc-028 vault] boot banner skipped (${e?.message || e})`);
|
|
2356
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// RFC-027 §2.3 race-free invariant — shared helper for the inbox-
|
|
2
|
+
// enqueue lifecycle guard.
|
|
3
|
+
//
|
|
4
|
+
// PR1.1 (#346) added an `assertNodeActive` helper inside the
|
|
5
|
+
// registerTools() closure and applied it at the 6 INSERT INTO inbox
|
|
6
|
+
// sites in server/src/tools.ts. PR1.1 review (通信龙 #346 ack) caught
|
|
7
|
+
// that the closure scope made the helper unreachable from REST
|
|
8
|
+
// handlers in server/src/index.ts — POST /api/task (:1631) and
|
|
9
|
+
// POST /api/broadcast (:1706) are dashboard's "Dispatch" entry
|
|
10
|
+
// points and bypassed the guard, leaving the §2.3 race open on the
|
|
11
|
+
// REST path.
|
|
12
|
+
//
|
|
13
|
+
// PR1.2a (#346 follow-up) extracts the helper to this module so both
|
|
14
|
+
// the MCP tools and the REST handlers can import the same code path,
|
|
15
|
+
// per [[feedback_grep_all_sites_before_apply_guard]]: any SQL-level
|
|
16
|
+
// guard's helper lives at module scope so every write site
|
|
17
|
+
// (MCP + REST + internal db.ts helpers) can use it.
|
|
18
|
+
//
|
|
19
|
+
// Behaviour:
|
|
20
|
+
// - no nodes row for the alias → ok:true (brand-new alias allowed;
|
|
21
|
+
// could be the first INSERT for a child about to register)
|
|
22
|
+
// - lifecycle_state = 'active' OR NULL (pre-RFC-027 row) → ok:true
|
|
23
|
+
// - anything else (stopping / stopped / stop_failed / deleting) →
|
|
24
|
+
// ok:false + structured `node_not_active` reply
|
|
25
|
+
//
|
|
26
|
+
// COALESCE on the WHERE keeps NULL-network inbox rows / legacy nodes
|
|
27
|
+
// rows correctly scoped (PR1 SF-5 lineage).
|
|
28
|
+
|
|
29
|
+
import { db } from "./db.js";
|
|
30
|
+
|
|
31
|
+
export type LifecycleGuardResult =
|
|
32
|
+
| { ok: true }
|
|
33
|
+
| { ok: false; error: "node_not_active"; lifecycle_state: string; alias: string };
|
|
34
|
+
|
|
35
|
+
export function assertNodeActive(
|
|
36
|
+
sessionAlias: string,
|
|
37
|
+
networkId: string | null,
|
|
38
|
+
): LifecycleGuardResult {
|
|
39
|
+
if (!sessionAlias) return { ok: true };
|
|
40
|
+
const row = db.get<{ lifecycle_state: string | null }>(
|
|
41
|
+
networkId
|
|
42
|
+
? `SELECT lifecycle_state FROM nodes WHERE alias = ?1 AND COALESCE(network_id, ?2) = ?2 LIMIT 1`
|
|
43
|
+
: `SELECT lifecycle_state FROM nodes WHERE alias = ?1 LIMIT 1`,
|
|
44
|
+
...(networkId ? [sessionAlias, networkId] : [sessionAlias]),
|
|
45
|
+
);
|
|
46
|
+
if (!row) return { ok: true };
|
|
47
|
+
const st = row.lifecycle_state ?? "active";
|
|
48
|
+
if (st === "active") return { ok: true };
|
|
49
|
+
return { ok: false, error: "node_not_active", lifecycle_state: st, alias: sessionAlias };
|
|
50
|
+
}
|