@sleep2agi/commhub-server 0.9.0-preview.2 → 0.9.0-preview.21
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 +2 -2
- package/src/ack-create-request.test.ts +331 -0
- package/src/api-host-supervisors-fallback.test.ts +262 -0
- package/src/api-nodes-shape.test.ts +172 -0
- package/src/auth.ts +3 -1
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- 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/db.ts +196 -0
- package/src/index.ts +216 -3
- 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/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/stop-delete-node.test.ts +720 -0
- package/src/tools.ts +1776 -13
- package/src/update-provider.test.ts +234 -0
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +36 -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";
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
isPathInsideUploadsRoot,
|
|
20
21
|
sanitizeExt,
|
|
21
22
|
sharedUploadRateLimiter,
|
|
23
|
+
stripHostLocalPathsForCrossHostSafe,
|
|
22
24
|
validateAttachments,
|
|
23
25
|
validateIndexEntry,
|
|
24
26
|
} from "./uploads.js";
|
|
@@ -73,7 +75,12 @@ console.error = (...args: any[]) => { pushLog("error", args); _origConsole.error
|
|
|
73
75
|
|
|
74
76
|
function normalizeMetaJson(meta: unknown): string | null {
|
|
75
77
|
if (!meta || typeof meta !== "object") return null;
|
|
76
|
-
|
|
78
|
+
// #222 cross-host safety — same sanitization as tools.ts so REST
|
|
79
|
+
// /api/task and MCP send_task produce identical meta_json shapes.
|
|
80
|
+
// Conditional: only strips `path` when `file_id` is also present
|
|
81
|
+
// (preserves single-host feishu fallback per team rule — assume-unit-before-threshold: cap is in bytes
|
|
82
|
+
// discipline of not breaking other call sites).
|
|
83
|
+
try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
|
|
77
84
|
}
|
|
78
85
|
|
|
79
86
|
// ── Rate limiter (in-memory, per IP) ──
|
|
@@ -165,6 +172,11 @@ function normalizeRuntime(agent: unknown): string | null {
|
|
|
165
172
|
if (agent === "claude-code") return "claude-code-cli";
|
|
166
173
|
if (agent.startsWith("agent-node:codex")) return "codex-sdk";
|
|
167
174
|
if (agent.startsWith("agent-node:claude")) return "claude-agent-sdk";
|
|
175
|
+
if (agent.startsWith("agent-node:grok")) return "grok-build-acp";
|
|
176
|
+
// RFC-029 — agent-node reports `agent-node:opencode` when RUNTIME
|
|
177
|
+
// bucket resolves to "opencode". Dashboard needs the canonical
|
|
178
|
+
// launcher name so its Runtime badge matches wizard choices.
|
|
179
|
+
if (agent.startsWith("agent-node:opencode")) return "opencode-cli";
|
|
168
180
|
if (agent === "http-api" || agent === "http" || agent === "api") return "http-api";
|
|
169
181
|
return null;
|
|
170
182
|
}
|
|
@@ -1621,6 +1633,18 @@ Bun.serve({
|
|
|
1621
1633
|
return withCors(req, Response.json(payload, { status: 429 }));
|
|
1622
1634
|
}
|
|
1623
1635
|
|
|
1636
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.2a REST site
|
|
1637
|
+
// 1/2 — paired with the 6 MCP sites guarded in tools.ts at PR1.1).
|
|
1638
|
+
// Dashboard's "Dispatch" button hits this endpoint, so without
|
|
1639
|
+
// the guard a stopping/stopped/deleting node still got tasks
|
|
1640
|
+
// routed through REST. Returns 409 with the same structured
|
|
1641
|
+
// payload the MCP handlers use.
|
|
1642
|
+
{
|
|
1643
|
+
const lc = assertNodeActive(targetAlias, taskNetId ?? null);
|
|
1644
|
+
if (!lc.ok) {
|
|
1645
|
+
return withCors(req, Response.json(lc, { status: 409 }));
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1624
1648
|
// Mirror send_task MCP: write inbox + tasks rows in a single
|
|
1625
1649
|
// transaction so the dispatch is visible to dashboard's Tasks page
|
|
1626
1650
|
// and the parent_task_id lineage chain. Previously this endpoint
|
|
@@ -1701,6 +1725,12 @@ Bun.serve({
|
|
|
1701
1725
|
const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
|
|
1702
1726
|
const ids: string[] = [];
|
|
1703
1727
|
for (const t of targets) {
|
|
1728
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.2a REST site
|
|
1729
|
+
// 2/2). Broadcast skips non-active recipients silently per its
|
|
1730
|
+
// best-effort semantics — mirrors the MCP broadcast handler
|
|
1731
|
+
// in tools.ts (PR1.1 site 6/6).
|
|
1732
|
+
const lc = assertNodeActive(t.alias, t.network_id ?? null);
|
|
1733
|
+
if (!lc.ok) continue;
|
|
1704
1734
|
const id = crypto.randomUUID();
|
|
1705
1735
|
db.run(
|
|
1706
1736
|
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
|
|
@@ -1950,16 +1980,180 @@ Bun.serve({
|
|
|
1950
1980
|
}
|
|
1951
1981
|
|
|
1952
1982
|
// ── REST: nodes table (V2 Sprint 2) ──
|
|
1983
|
+
// Explicit column list — NOT `SELECT *`. Two reasons:
|
|
1984
|
+
// 1. `SELECT *` silently broadcasts any column added later to the
|
|
1985
|
+
// response (RFC-024 added config_revision/config_snapshot which
|
|
1986
|
+
// the dashboard's GET /api/nodes/:id/config endpoint owns;
|
|
1987
|
+
// RFC-028 will add more vault-flavored columns). Tests asserting
|
|
1988
|
+
// response shape break on every ALTER; integrations that key
|
|
1989
|
+
// off Object.keys() can pick up internals.
|
|
1990
|
+
// 2. The list endpoint and the per-node config snapshot endpoint
|
|
1991
|
+
// have different consumer contracts; this list should be the
|
|
1992
|
+
// stable, dashboard-facing fields only.
|
|
1993
|
+
// Add a new column to this list explicitly when a real client needs
|
|
1994
|
+
// it; do NOT switch back to SELECT *.
|
|
1995
|
+
// ── REST: list host_supervisor daemons (RFC-026 §9.2.2 / #338 PR2) ──
|
|
1996
|
+
// Mirror of the `list_host_supervisors` MCP tool for non-MCP callers.
|
|
1997
|
+
// Same SQL + role-extract + member-脱敏 logic; SEC-1 scoped via REST
|
|
1998
|
+
// auth pipeline (restScope). Returns {ok, daemons:[...], count}.
|
|
1999
|
+
if (url.pathname === "/api/host-supervisors") {
|
|
2000
|
+
// #380 — utok callers with exactly one accessible network get a
|
|
2001
|
+
// safe fallback so the create-node wizard doesn't have to bake
|
|
2002
|
+
// network_id into every request (it was hard-4xxing when the
|
|
2003
|
+
// dashboard omitted the query param — that's what "hub 400" was).
|
|
2004
|
+
// `singleNetworkId` returns:
|
|
2005
|
+
// - scope.networkId when it's set (ntok binding, or an
|
|
2006
|
+
// explicitly-requested network the user has verified access to)
|
|
2007
|
+
// - the sole member of scope.networkIds when the utok user
|
|
2008
|
+
// belongs to exactly one network (safe unambiguous fallback)
|
|
2009
|
+
// - null when the user belongs to 0 or 2+ networks (authz
|
|
2010
|
+
// boundary — refuse to guess which one, mirrors 通信龙 spec
|
|
2011
|
+
// "别 fallback 到错 network")
|
|
2012
|
+
// The 400 branch is retained for the ambiguous / no-access cases
|
|
2013
|
+
// and distinguishes them in the error message so the client can
|
|
2014
|
+
// recover (send network_id explicitly).
|
|
2015
|
+
const effectiveNetId = singleNetworkId(restScope);
|
|
2016
|
+
if (!effectiveNetId) {
|
|
2017
|
+
const memberships = restScope.networkIds?.length ?? 0;
|
|
2018
|
+
const error = memberships > 1
|
|
2019
|
+
? "network_id_required_multi"
|
|
2020
|
+
: "missing_network_id";
|
|
2021
|
+
return withCors(req, Response.json({ ok: false, error, memberships }, { status: 400 }));
|
|
2022
|
+
}
|
|
2023
|
+
// Role for member-脱敏 (admin/owner = full host_telemetry, others = masked)
|
|
2024
|
+
let isPrivileged = false;
|
|
2025
|
+
if (restAuth?.userId) {
|
|
2026
|
+
const role = getUserNetworkRole(restAuth.userId, effectiveNetId);
|
|
2027
|
+
isPrivileged = role === "admin" || role === "owner";
|
|
2028
|
+
} else {
|
|
2029
|
+
// Network-token caller (no user) — treat as privileged within its network
|
|
2030
|
+
isPrivileged = true;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
// EXISTS subquery handles BOTH revoked_at SET and DELETEd token
|
|
2034
|
+
// rows (revokeToken DELETEs the row in production — PR2 v1
|
|
2035
|
+
// SHOULD-FIX per 通信龙 audit). EXISTS naturally dedupes daemons
|
|
2036
|
+
// with rotated tokens.
|
|
2037
|
+
const sqlRows = db.all<Record<string, any>>(`
|
|
2038
|
+
SELECT
|
|
2039
|
+
n.node_id, n.alias, n.hostname, n.network_id,
|
|
2040
|
+
n.runtimes_supported, n.allowed_secret_keys,
|
|
2041
|
+
n.created_at, n.updated_at,
|
|
2042
|
+
s.last_seen_at AS session_last_seen,
|
|
2043
|
+
s.cpu_cores AS session_cpu_cores,
|
|
2044
|
+
s.mem_total_gb AS session_mem_total_gb,
|
|
2045
|
+
s.ip AS session_ip,
|
|
2046
|
+
n.config_snapshot
|
|
2047
|
+
FROM nodes n
|
|
2048
|
+
LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
|
|
2049
|
+
WHERE n.network_id = ?1
|
|
2050
|
+
AND EXISTS (
|
|
2051
|
+
SELECT 1 FROM api_tokens t
|
|
2052
|
+
WHERE t.network_id = n.network_id
|
|
2053
|
+
AND t.name = 'node:' || n.alias
|
|
2054
|
+
AND t.revoked_at IS NULL
|
|
2055
|
+
)
|
|
2056
|
+
ORDER BY n.updated_at DESC
|
|
2057
|
+
`, effectiveNetId);
|
|
2058
|
+
|
|
2059
|
+
const nowMs = Date.now();
|
|
2060
|
+
const ONLINE_MS = 60_000;
|
|
2061
|
+
const daemons = sqlRows
|
|
2062
|
+
.map(r => {
|
|
2063
|
+
let snapRole: string | null = null;
|
|
2064
|
+
if (r.config_snapshot) {
|
|
2065
|
+
try {
|
|
2066
|
+
const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
|
|
2067
|
+
snapRole = typeof parsed?.role === "string" ? parsed.role : null;
|
|
2068
|
+
} catch { /* malformed */ }
|
|
2069
|
+
}
|
|
2070
|
+
return { row: r, role: snapRole };
|
|
2071
|
+
})
|
|
2072
|
+
.filter(({ role }) => role === "host_supervisor")
|
|
2073
|
+
.map(({ row: r }) => {
|
|
2074
|
+
let online = false;
|
|
2075
|
+
let lastSeenAt: string | null = null;
|
|
2076
|
+
if (r.session_last_seen) {
|
|
2077
|
+
lastSeenAt = r.session_last_seen;
|
|
2078
|
+
const t = Date.parse(r.session_last_seen);
|
|
2079
|
+
if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
|
|
2080
|
+
}
|
|
2081
|
+
let runtimes: string[] = [];
|
|
2082
|
+
let secrets: string[] = [];
|
|
2083
|
+
try {
|
|
2084
|
+
if (r.runtimes_supported) {
|
|
2085
|
+
const parsed = JSON.parse(r.runtimes_supported);
|
|
2086
|
+
runtimes = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
|
|
2087
|
+
}
|
|
2088
|
+
} catch {}
|
|
2089
|
+
try {
|
|
2090
|
+
if (r.allowed_secret_keys) {
|
|
2091
|
+
const parsed = JSON.parse(r.allowed_secret_keys);
|
|
2092
|
+
secrets = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
|
|
2093
|
+
}
|
|
2094
|
+
} catch {}
|
|
2095
|
+
const telemetry: Record<string, unknown> = {
|
|
2096
|
+
alert_level: online ? "green" : "gray",
|
|
2097
|
+
};
|
|
2098
|
+
if (isPrivileged) {
|
|
2099
|
+
telemetry.cpu_cores = r.session_cpu_cores ?? null;
|
|
2100
|
+
telemetry.mem_gb = r.session_mem_total_gb ?? null;
|
|
2101
|
+
telemetry.ip_internal = r.session_ip ?? null;
|
|
2102
|
+
}
|
|
2103
|
+
return {
|
|
2104
|
+
daemon_node_id: r.node_id,
|
|
2105
|
+
alias: r.alias,
|
|
2106
|
+
hostname: r.hostname,
|
|
2107
|
+
online,
|
|
2108
|
+
last_seen_at: lastSeenAt,
|
|
2109
|
+
runtimes_supported: runtimes,
|
|
2110
|
+
allowed_secret_keys: secrets,
|
|
2111
|
+
host_telemetry: telemetry,
|
|
2112
|
+
};
|
|
2113
|
+
});
|
|
2114
|
+
return withCors(req, Response.json({ ok: true, daemons, count: daemons.length }));
|
|
2115
|
+
}
|
|
2116
|
+
|
|
1953
2117
|
if (url.pathname === "/api/nodes") {
|
|
1954
2118
|
const nodeId = url.searchParams.get("node_id");
|
|
1955
2119
|
const alias = url.searchParams.get("alias");
|
|
1956
|
-
|
|
2120
|
+
// `config_snapshot` SELECTed ONLY to extract the daemon `role` field
|
|
2121
|
+
// for dashboard discovery — full snapshot is NOT broadcast (would
|
|
2122
|
+
// re-introduce the #312 SELECT * fragility). Mapping step pulls
|
|
2123
|
+
// `role` out then drops `config_snapshot` from the response row.
|
|
2124
|
+
// RFC-027 PR2 dashboard prereq: lifecycle_state is needed
|
|
2125
|
+
// client-side to drive the ⋮ menu (active → "停止/删除";
|
|
2126
|
+
// stopped → "重启/删除"). Hub schema gained the column at PR1
|
|
2127
|
+
// (#345) but /api/nodes never SELECTed it. Following #312
|
|
2128
|
+
// "explicit SELECT" discipline — extend the projection here,
|
|
2129
|
+
// do NOT switch to SELECT *.
|
|
2130
|
+
let sql = `SELECT node_id, node_name, alias, runtime, model,
|
|
2131
|
+
config_path, channels, server, hostname,
|
|
2132
|
+
network_id, created_at, updated_at,
|
|
2133
|
+
config_snapshot, lifecycle_state
|
|
2134
|
+
FROM nodes WHERE 1=1`;
|
|
1957
2135
|
const params: any[] = [];
|
|
1958
2136
|
sql = addNetworkScope(sql, params, restScope);
|
|
1959
2137
|
if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
|
|
1960
2138
|
if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
|
|
1961
2139
|
sql += " ORDER BY updated_at DESC";
|
|
1962
|
-
const
|
|
2140
|
+
const rawRows = db.all<Record<string, any>>(sql, ...params);
|
|
2141
|
+
// Add a single new field `role: string | null` per row, extracted from
|
|
2142
|
+
// config_snapshot JSON. Daemon nodes that posted role='host_supervisor'
|
|
2143
|
+
// via report_status surface here; dashboard uses this for daemon
|
|
2144
|
+
// discovery (replacing the prior `node_daemon_` prefix heuristic).
|
|
2145
|
+
// RFC-026 P1 P2 §9 will supersede this REST proxy with the
|
|
2146
|
+
// `list_host_supervisors` MCP tool.
|
|
2147
|
+
const rows = rawRows.map(r => {
|
|
2148
|
+
let role: string | null = null;
|
|
2149
|
+
const snap = r.config_snapshot;
|
|
2150
|
+
if (snap) {
|
|
2151
|
+
try { role = (typeof snap === "string" ? JSON.parse(snap) : snap)?.role ?? null; }
|
|
2152
|
+
catch { /* malformed snapshot — leave role null */ }
|
|
2153
|
+
}
|
|
2154
|
+
const { config_snapshot, ...rest } = r;
|
|
2155
|
+
return { ...rest, role };
|
|
2156
|
+
});
|
|
1963
2157
|
return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
|
|
1964
2158
|
}
|
|
1965
2159
|
|
|
@@ -2170,3 +2364,22 @@ console.log(`
|
|
|
2170
2364
|
║ Health: http://${HOST}:${PORT}/health ║
|
|
2171
2365
|
╚══════════════════════════════════════════════════╝
|
|
2172
2366
|
`);
|
|
2367
|
+
|
|
2368
|
+
// RFC-028 P1 boot banner — vault key configuration status.
|
|
2369
|
+
// F2 invariant: hub MUST boot regardless of vault state. This is
|
|
2370
|
+
// banner-only (informational); errors are raised lazily at vault op
|
|
2371
|
+
// time. needsKeyToOp=true means the operator should set
|
|
2372
|
+
// ANET_HUB_SECRET_VAULT_KEY before vault/provider features will work.
|
|
2373
|
+
try {
|
|
2374
|
+
const { vaultStatusForBoot } = await import("./vault.js");
|
|
2375
|
+
const s = vaultStatusForBoot();
|
|
2376
|
+
if (s.needsKeyToOp) {
|
|
2377
|
+
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).");
|
|
2378
|
+
} else if (s.configured) {
|
|
2379
|
+
console.log(`[rfc-028 vault] master key configured (tables_have_data=${s.tablesHaveData})`);
|
|
2380
|
+
} else {
|
|
2381
|
+
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.");
|
|
2382
|
+
}
|
|
2383
|
+
} catch (e: any) {
|
|
2384
|
+
console.warn(`[rfc-028 vault] boot banner skipped (${e?.message || e})`);
|
|
2385
|
+
}
|
|
@@ -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 team rule (grep every write site before adding a 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
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { db, hashToken, generateId } from "./db.js";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { registerTools } from "./tools.js";
|
|
5
|
+
|
|
6
|
+
// RFC-026 §9.2 / #338 PR2 v2 — list_host_supervisors HANDLER tests.
|
|
7
|
+
//
|
|
8
|
+
// PR2 v1 used a mirror-SQL pattern (re-implemented the query in the test
|
|
9
|
+
// itself) → trivially passed but didn't exercise the handler. 通信龙 audit
|
|
10
|
+
// caught: that style misses the SEC-1 scope-derivation bug
|
|
11
|
+
// (`getNetworkId(clientNetId)` returns the unchecked client-supplied
|
|
12
|
+
// network_id for utok_ callers when enforceNetworkId is null, leaking
|
|
13
|
+
// cross-tenant daemons). This rewrite drives the registered MCP tool
|
|
14
|
+
// through registerTools() so the auth context is real.
|
|
15
|
+
//
|
|
16
|
+
// Covers:
|
|
17
|
+
// SEC-1.1 — cross-tenant utok_ caller with foreign network_id: DENIED
|
|
18
|
+
// SEC-1.2 — admin caller with own network_id: returns own daemons
|
|
19
|
+
// SEC-1.3 — caller with no network_id: returns daemons in all member networks
|
|
20
|
+
// masking — non-admin viewer: host_telemetry IP/cpu/mem stripped, alert_level still surfaces
|
|
21
|
+
// revoked — DELETEd token row (revokeToken path): daemon excluded
|
|
22
|
+
// revoked — revoked_at SET token: daemon excluded
|
|
23
|
+
// self-declare — runtimes_supported/allowed_secret_keys surface from columns
|
|
24
|
+
// pre-PR2 daemon — empty arrays on NULL columns (back-compat)
|
|
25
|
+
|
|
26
|
+
const NET_ALPHA = "net_lhs_alpha";
|
|
27
|
+
const NET_BETA = "net_lhs_beta";
|
|
28
|
+
|
|
29
|
+
interface ToolHandler { (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>; }
|
|
30
|
+
interface Reply { ok?: boolean; error?: string; daemons?: any[]; count?: number; }
|
|
31
|
+
|
|
32
|
+
function cleanup() {
|
|
33
|
+
for (const n of [NET_ALPHA, NET_BETA]) {
|
|
34
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [n]); } catch {}
|
|
35
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [n]); } catch {}
|
|
36
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [n]); } catch {}
|
|
37
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [n]); } catch {}
|
|
38
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [n]); } catch {}
|
|
39
|
+
}
|
|
40
|
+
try { db.run("DELETE FROM users WHERE user_id LIKE 'u_lhs_%'"); } catch {}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeEach(cleanup);
|
|
44
|
+
afterAll(cleanup);
|
|
45
|
+
|
|
46
|
+
function seedUser(user_id: string) {
|
|
47
|
+
db.run(
|
|
48
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
49
|
+
VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
50
|
+
[user_id, user_id],
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
function seedNetwork(net_id: string, owner_id = "u_lhs_owner") {
|
|
54
|
+
db.run(
|
|
55
|
+
`INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at)
|
|
56
|
+
VALUES (?1, ?2, ?3, datetime('now'))`,
|
|
57
|
+
[net_id, net_id, owner_id],
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
function seedMembership(user_id: string, net_id: string, role: "owner" | "admin" | "member" | "viewer" = "member") {
|
|
61
|
+
db.run(
|
|
62
|
+
`INSERT INTO network_members (user_id, network_id, role, joined_at)
|
|
63
|
+
VALUES (?1, ?2, ?3, datetime('now'))`,
|
|
64
|
+
[user_id, net_id, role],
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
function seedDaemon(opts: {
|
|
68
|
+
network_id: string, alias: string, node_id?: string,
|
|
69
|
+
role?: string,
|
|
70
|
+
runtimes?: string[],
|
|
71
|
+
secrets?: string[],
|
|
72
|
+
sessionAgeMs?: number,
|
|
73
|
+
mintToken?: boolean,
|
|
74
|
+
tokenRevokedAt?: boolean,
|
|
75
|
+
tokenDeleted?: boolean,
|
|
76
|
+
}) {
|
|
77
|
+
const node_id = opts.node_id || `node_${opts.network_id}_${opts.alias}`;
|
|
78
|
+
const snapshot = JSON.stringify({
|
|
79
|
+
model: "claude-opus", flags: {}, role: opts.role ?? "host_supervisor",
|
|
80
|
+
runtimes_supported: opts.runtimes ?? ["claude-agent-sdk"],
|
|
81
|
+
allowed_secret_keys: opts.secrets ?? [],
|
|
82
|
+
});
|
|
83
|
+
db.run(
|
|
84
|
+
`INSERT OR REPLACE INTO nodes (
|
|
85
|
+
node_id, node_name, alias, network_id,
|
|
86
|
+
runtimes_supported, allowed_secret_keys,
|
|
87
|
+
config_snapshot, hostname,
|
|
88
|
+
created_at, updated_at
|
|
89
|
+
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, datetime('now'), datetime('now'))`,
|
|
90
|
+
[node_id, opts.alias, opts.alias, opts.network_id,
|
|
91
|
+
JSON.stringify(opts.runtimes ?? ["claude-agent-sdk"]),
|
|
92
|
+
JSON.stringify(opts.secrets ?? []),
|
|
93
|
+
snapshot, `host-${opts.alias}`],
|
|
94
|
+
);
|
|
95
|
+
if (opts.sessionAgeMs !== undefined) {
|
|
96
|
+
const ts = new Date(Date.now() - opts.sessionAgeMs).toISOString().replace("T", " ").replace(/\..+/, "");
|
|
97
|
+
db.run(
|
|
98
|
+
`INSERT OR REPLACE INTO sessions (resume_id, alias, network_id, last_seen_at, status, cpu_cores, mem_total_gb, ip)
|
|
99
|
+
VALUES (?1, ?2, ?3, ?4, 'idle', 8, 16, '10.0.0.5')`,
|
|
100
|
+
[`s_${node_id}`, opts.alias, opts.network_id, ts],
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (opts.mintToken !== false && !opts.tokenDeleted) {
|
|
104
|
+
db.run(
|
|
105
|
+
`INSERT OR REPLACE INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
|
|
106
|
+
VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, ?6)`,
|
|
107
|
+
[`tok_${node_id}`, `u_seed_${node_id}`, opts.network_id, `node:${opts.alias}`,
|
|
108
|
+
`hash_${node_id}`,
|
|
109
|
+
opts.tokenRevokedAt ? new Date().toISOString().replace("T", " ").replace(/\..+/, "") : null],
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return node_id;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Build a server with registerTools wired to a specific enforceUserId. */
|
|
116
|
+
function buildToolHandler(enforceUserId: string | null): ToolHandler {
|
|
117
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
118
|
+
const tools: Record<string, ToolHandler> = {};
|
|
119
|
+
// Capture the registered tool function. The MCP SDK's server.tool stores
|
|
120
|
+
// the handler internally; we intercept by overriding before registerTools.
|
|
121
|
+
const origTool = server.tool.bind(server);
|
|
122
|
+
server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
|
|
123
|
+
tools[name] = handler;
|
|
124
|
+
return origTool(name, _desc, _schema, handler);
|
|
125
|
+
};
|
|
126
|
+
// Some tools use registerTool; intercept that too
|
|
127
|
+
const origRegisterTool = server.registerTool?.bind(server);
|
|
128
|
+
if (origRegisterTool) {
|
|
129
|
+
server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
|
|
130
|
+
tools[name] = handler;
|
|
131
|
+
return origRegisterTool(name, _cfg, handler);
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
registerTools(
|
|
135
|
+
server,
|
|
136
|
+
/* clientIP */ undefined,
|
|
137
|
+
/* enforceNetworkId */ null,
|
|
138
|
+
/* enforceUserId */ enforceUserId,
|
|
139
|
+
/* callerAlias */ null,
|
|
140
|
+
/* callerTokenIsNetwork */ false,
|
|
141
|
+
/* callerTokenId */ null,
|
|
142
|
+
);
|
|
143
|
+
const h = tools["list_host_supervisors"];
|
|
144
|
+
if (!h) throw new Error("list_host_supervisors tool not registered");
|
|
145
|
+
return h;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function callList(handler: ToolHandler, args: any = {}): Promise<Reply> {
|
|
149
|
+
const r = await handler(args);
|
|
150
|
+
return JSON.parse(r.content[0].text) as Reply;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe("list_host_supervisors HANDLER tests (RFC-026 §9.2, PR2 v2)", () => {
|
|
154
|
+
test("SEC-1.1: cross-tenant utok_ caller with foreign network_id → DENIED", async () => {
|
|
155
|
+
const userA = "u_lhs_alice"; // belongs to NET_ALPHA only
|
|
156
|
+
seedUser(userA);
|
|
157
|
+
seedNetwork(NET_ALPHA);
|
|
158
|
+
seedNetwork(NET_BETA);
|
|
159
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
160
|
+
seedDaemon({ network_id: NET_BETA, alias: "secret-beta-daemon" });
|
|
161
|
+
|
|
162
|
+
const handler = buildToolHandler(userA);
|
|
163
|
+
const reply = await callList(handler, { network_id: NET_BETA });
|
|
164
|
+
// PR2 v1 BUG: this returned ok:true with secret-beta-daemon in the list.
|
|
165
|
+
expect(reply.ok).toBe(false);
|
|
166
|
+
expect(reply.error).toMatch(/access denied|not a member/i);
|
|
167
|
+
// Belt: hostname not in error payload
|
|
168
|
+
expect(JSON.stringify(reply)).not.toContain("secret-beta-daemon");
|
|
169
|
+
expect(JSON.stringify(reply)).not.toContain("host-secret-beta-daemon");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("SEC-1.2: admin caller with own network_id returns own daemons", async () => {
|
|
173
|
+
const userA = "u_lhs_alice";
|
|
174
|
+
seedUser(userA);
|
|
175
|
+
seedNetwork(NET_ALPHA);
|
|
176
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
177
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "alpha-daemon-1" });
|
|
178
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "alpha-daemon-2" });
|
|
179
|
+
|
|
180
|
+
const handler = buildToolHandler(userA);
|
|
181
|
+
const reply = await callList(handler, { network_id: NET_ALPHA });
|
|
182
|
+
expect(reply.ok).toBe(true);
|
|
183
|
+
const aliases = (reply.daemons ?? []).map(d => d.alias).sort();
|
|
184
|
+
expect(aliases).toEqual(["alpha-daemon-1", "alpha-daemon-2"]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("SEC-1.3: caller without network_id arg → returns daemons across member networks only", async () => {
|
|
188
|
+
const userA = "u_lhs_alice"; // member of NET_ALPHA only
|
|
189
|
+
seedUser(userA);
|
|
190
|
+
seedNetwork(NET_ALPHA);
|
|
191
|
+
seedNetwork(NET_BETA);
|
|
192
|
+
seedMembership(userA, NET_ALPHA, "member");
|
|
193
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "ok-daemon" });
|
|
194
|
+
seedDaemon({ network_id: NET_BETA, alias: "forbidden-daemon" });
|
|
195
|
+
|
|
196
|
+
const handler = buildToolHandler(userA);
|
|
197
|
+
const reply = await callList(handler);
|
|
198
|
+
expect(reply.ok).toBe(true);
|
|
199
|
+
const aliases = (reply.daemons ?? []).map(d => d.alias);
|
|
200
|
+
expect(aliases).toContain("ok-daemon");
|
|
201
|
+
expect(aliases).not.toContain("forbidden-daemon");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("masking: member caller gets host_telemetry without IP/cpu/mem; admin gets full", async () => {
|
|
205
|
+
const userMember = "u_lhs_bob_member";
|
|
206
|
+
const userAdmin = "u_lhs_carol_admin";
|
|
207
|
+
seedUser(userMember);
|
|
208
|
+
seedUser(userAdmin);
|
|
209
|
+
seedNetwork(NET_ALPHA);
|
|
210
|
+
seedMembership(userMember, NET_ALPHA, "member");
|
|
211
|
+
seedMembership(userAdmin, NET_ALPHA, "admin");
|
|
212
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "telemetry-daemon", sessionAgeMs: 5_000 });
|
|
213
|
+
|
|
214
|
+
const memberHandler = buildToolHandler(userMember);
|
|
215
|
+
const memberReply = await callList(memberHandler, { network_id: NET_ALPHA });
|
|
216
|
+
expect(memberReply.daemons?.[0].host_telemetry.alert_level).toBeDefined();
|
|
217
|
+
expect(memberReply.daemons?.[0].host_telemetry).not.toHaveProperty("cpu_cores");
|
|
218
|
+
expect(memberReply.daemons?.[0].host_telemetry).not.toHaveProperty("mem_gb");
|
|
219
|
+
expect(memberReply.daemons?.[0].host_telemetry).not.toHaveProperty("ip_internal");
|
|
220
|
+
|
|
221
|
+
const adminHandler = buildToolHandler(userAdmin);
|
|
222
|
+
const adminReply = await callList(adminHandler, { network_id: NET_ALPHA });
|
|
223
|
+
expect(adminReply.daemons?.[0].host_telemetry.cpu_cores).toBe(8);
|
|
224
|
+
expect(adminReply.daemons?.[0].host_telemetry.mem_gb).toBe(16);
|
|
225
|
+
expect(adminReply.daemons?.[0].host_telemetry.ip_internal).toBe("10.0.0.5");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("revoked: DELETEd token row (revokeToken path) excludes daemon", async () => {
|
|
229
|
+
const userA = "u_lhs_alice";
|
|
230
|
+
seedUser(userA);
|
|
231
|
+
seedNetwork(NET_ALPHA);
|
|
232
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
233
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "alive-daemon" });
|
|
234
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "deleted-token-daemon", tokenDeleted: true });
|
|
235
|
+
|
|
236
|
+
const handler = buildToolHandler(userA);
|
|
237
|
+
const reply = await callList(handler, { network_id: NET_ALPHA });
|
|
238
|
+
const aliases = (reply.daemons ?? []).map(d => d.alias).sort();
|
|
239
|
+
expect(aliases).toContain("alive-daemon");
|
|
240
|
+
expect(aliases).not.toContain("deleted-token-daemon");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("revoked: token with revoked_at SET also excludes daemon", async () => {
|
|
244
|
+
const userA = "u_lhs_alice";
|
|
245
|
+
seedUser(userA);
|
|
246
|
+
seedNetwork(NET_ALPHA);
|
|
247
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
248
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "alive-daemon" });
|
|
249
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "revoked-at-daemon", tokenRevokedAt: true });
|
|
250
|
+
|
|
251
|
+
const handler = buildToolHandler(userA);
|
|
252
|
+
const reply = await callList(handler, { network_id: NET_ALPHA });
|
|
253
|
+
const aliases = (reply.daemons ?? []).map(d => d.alias).sort();
|
|
254
|
+
expect(aliases).toContain("alive-daemon");
|
|
255
|
+
expect(aliases).not.toContain("revoked-at-daemon");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("role filter: regular member nodes (role=member) excluded after extraction", async () => {
|
|
259
|
+
const userA = "u_lhs_alice";
|
|
260
|
+
seedUser(userA);
|
|
261
|
+
seedNetwork(NET_ALPHA);
|
|
262
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
263
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "real-daemon", role: "host_supervisor" });
|
|
264
|
+
seedDaemon({ network_id: NET_ALPHA, alias: "regular-node", role: "member" });
|
|
265
|
+
|
|
266
|
+
const handler = buildToolHandler(userA);
|
|
267
|
+
const reply = await callList(handler, { network_id: NET_ALPHA });
|
|
268
|
+
const aliases = (reply.daemons ?? []).map(d => d.alias).sort();
|
|
269
|
+
expect(aliases).toEqual(["real-daemon"]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("self-declare: runtimes_supported + allowed_secret_keys surface from columns", async () => {
|
|
273
|
+
const userA = "u_lhs_alice";
|
|
274
|
+
seedUser(userA);
|
|
275
|
+
seedNetwork(NET_ALPHA);
|
|
276
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
277
|
+
seedDaemon({
|
|
278
|
+
network_id: NET_ALPHA, alias: "rich-daemon",
|
|
279
|
+
runtimes: ["claude-agent-sdk", "codex-sdk", "grok-build-acp"],
|
|
280
|
+
secrets: ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const handler = buildToolHandler(userA);
|
|
284
|
+
const reply = await callList(handler, { network_id: NET_ALPHA });
|
|
285
|
+
const d = reply.daemons?.[0];
|
|
286
|
+
expect(d.runtimes_supported).toEqual(["claude-agent-sdk", "codex-sdk", "grok-build-acp"]);
|
|
287
|
+
expect(d.allowed_secret_keys).toEqual(["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("pre-PR2 daemon (NULL columns) extracts to empty arrays back-compat", async () => {
|
|
291
|
+
const userA = "u_lhs_alice";
|
|
292
|
+
seedUser(userA);
|
|
293
|
+
seedNetwork(NET_ALPHA);
|
|
294
|
+
seedMembership(userA, NET_ALPHA, "admin");
|
|
295
|
+
db.run(
|
|
296
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, runtimes_supported, allowed_secret_keys, created_at, updated_at)
|
|
297
|
+
VALUES (?1, 'legacy', 'legacy', ?2, ?3, NULL, NULL, datetime('now'), datetime('now'))`,
|
|
298
|
+
["node_legacy_alpha", NET_ALPHA, JSON.stringify({ role: "host_supervisor" })],
|
|
299
|
+
);
|
|
300
|
+
// Seed active token so the EXISTS guard lets it through
|
|
301
|
+
db.run(
|
|
302
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash)
|
|
303
|
+
VALUES ('tok_legacy', 'u_seed_legacy', ?1, 'network', 'node:legacy', 'hash_legacy')`,
|
|
304
|
+
[NET_ALPHA],
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const handler = buildToolHandler(userA);
|
|
308
|
+
const reply = await callList(handler, { network_id: NET_ALPHA });
|
|
309
|
+
const d = reply.daemons?.find(d => d.alias === "legacy");
|
|
310
|
+
expect(d).toBeTruthy();
|
|
311
|
+
expect(d.runtimes_supported).toEqual([]);
|
|
312
|
+
expect(d.allowed_secret_keys).toEqual([]);
|
|
313
|
+
});
|
|
314
|
+
});
|