@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/tools.ts
CHANGED
|
@@ -1,14 +1,56 @@
|
|
|
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 {
|
|
5
|
+
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
6
|
+
import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
|
|
7
|
+
import {
|
|
8
|
+
buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
|
|
9
|
+
validateName as validateChildName,
|
|
10
|
+
validateRuntime,
|
|
11
|
+
validateModel,
|
|
12
|
+
validateChannelsP1,
|
|
13
|
+
validateEnvRefs,
|
|
14
|
+
FLAG_KEYS,
|
|
15
|
+
validateFlagValue,
|
|
16
|
+
ValidationError,
|
|
17
|
+
} from "./create-node-validate.js";
|
|
18
|
+
import {
|
|
19
|
+
putPendingEnvBlob,
|
|
20
|
+
takePendingEnvBlob,
|
|
21
|
+
newRequestId,
|
|
22
|
+
finalizeCreateOnFirstRegister,
|
|
23
|
+
startPendingEnvGcTimer,
|
|
24
|
+
startSweeperTimer,
|
|
25
|
+
auditCreateNode,
|
|
26
|
+
auditCreateNodeStrict,
|
|
27
|
+
resolveCallerDaemonTokenBound as _resolveCallerDaemonTokenBound,
|
|
28
|
+
} from "./create-node.js";
|
|
29
|
+
import {
|
|
30
|
+
vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
|
|
31
|
+
VaultError,
|
|
32
|
+
} from "./vault.js";
|
|
33
|
+
import { stripHostLocalPathsForCrossHostSafe } from "./uploads.js";
|
|
34
|
+
import {
|
|
35
|
+
validateBaseUrl as _validateBaseUrl,
|
|
36
|
+
SUPPORTED_VENDORS,
|
|
37
|
+
ProbeValidationError,
|
|
38
|
+
} from "./probe-validate.js";
|
|
39
|
+
import {
|
|
40
|
+
putPendingProbeSecret,
|
|
41
|
+
newProbeId,
|
|
42
|
+
finalizeProbeAck,
|
|
43
|
+
startPendingProbeGcTimer,
|
|
44
|
+
startProbeSweeperTimer,
|
|
45
|
+
} from "./probe.js";
|
|
6
46
|
import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias } from "./rename.js";
|
|
7
47
|
import {
|
|
8
48
|
ALLOWED_FLAGS,
|
|
9
49
|
SECURITY_SENSITIVE_FLAGS,
|
|
50
|
+
EDITABLE_CHANNELS,
|
|
10
51
|
computeApplyMode,
|
|
11
52
|
validatePatch,
|
|
53
|
+
narrowChannelsPatch,
|
|
12
54
|
isAllowedToChangeFlag,
|
|
13
55
|
} from "./config-apply-validate.js";
|
|
14
56
|
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
@@ -24,7 +66,12 @@ function parseMetaJson(value: unknown): unknown | null {
|
|
|
24
66
|
|
|
25
67
|
function normalizeMetaJson(meta: unknown): string | null {
|
|
26
68
|
if (!meta || typeof meta !== "object") return null;
|
|
27
|
-
|
|
69
|
+
// #222 cross-host safety — strip `path` from meta.attachments[] when
|
|
70
|
+
// `file_id` is also present. Shared helper lives in uploads.ts so the
|
|
71
|
+
// REST handler in index.ts can apply the identical sanitization
|
|
72
|
+
// (otherwise REST and MCP transports would have different meta_json
|
|
73
|
+
// shapes for the same send).
|
|
74
|
+
try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
|
|
28
75
|
}
|
|
29
76
|
|
|
30
77
|
export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null) {
|
|
@@ -104,6 +151,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
104
151
|
return { networkId: null, networkIds: getReadableNetworkIds() };
|
|
105
152
|
};
|
|
106
153
|
|
|
154
|
+
// RFC-027 §2.3 race-free invariant — assertNodeActive lives in
|
|
155
|
+
// server/src/lifecycle-guard.ts so REST handlers in server/src/index.ts
|
|
156
|
+
// can use the SAME code path. PR1.1 had it inline here; PR1.2a
|
|
157
|
+
// (#346 review catch) extracted because the closure scope made it
|
|
158
|
+
// unreachable from REST and left the §2.3 race open on dashboard
|
|
159
|
+
// Dispatch (POST /api/task + /api/broadcast). Per
|
|
160
|
+
// per team rule: grep every write site (MCP tools.ts AND REST index.ts) before adding a guard, and extract the helper into a shared module so both transports import it.
|
|
161
|
+
|
|
107
162
|
const addReadScope = (sql: string, params: any[], scope: ReadScope, column = "network_id"): string => {
|
|
108
163
|
if (scope.networkId) {
|
|
109
164
|
sql += ` AND ${column} = ?${params.length + 1}`;
|
|
@@ -257,6 +312,21 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
257
312
|
flags: z.record(z.unknown()).optional(),
|
|
258
313
|
config_revision: z.number().int().min(0).optional(),
|
|
259
314
|
config_update_capable: z.boolean().optional(),
|
|
315
|
+
// RFC-026 P2 / #338 — daemon role surfaced for hub /api/nodes
|
|
316
|
+
// discovery (#337 extracts this field). "host_supervisor" =
|
|
317
|
+
// anet daemon. Default-stripping zod would drop this otherwise.
|
|
318
|
+
role: z.string().max(64).optional().nullable(),
|
|
319
|
+
// RFC-026 §9.3 / #338 PR3 — daemon self-declare nested under
|
|
320
|
+
// `daemon_capabilities` (canonical shape per existing hub reads
|
|
321
|
+
// at tools.ts:2010/2075 — PR1/PR2 placed these top-level, hub
|
|
322
|
+
// never saw them, max_concurrent_children stayed default + the
|
|
323
|
+
// allowlists stayed unenforced. PR3 nit ① per 通信龙).
|
|
324
|
+
// Soft caps avoid abuse via attacker daemon.
|
|
325
|
+
daemon_capabilities: z.object({
|
|
326
|
+
runtimes_supported: z.array(z.string().max(64)).max(16).optional(),
|
|
327
|
+
allowed_secret_keys: z.array(z.string().max(64)).max(64).optional(),
|
|
328
|
+
max_concurrent_children: z.number().int().min(1).max(1000).optional(),
|
|
329
|
+
}).optional(),
|
|
260
330
|
}).optional().describe("RFC-024 — masked node config snapshot"),
|
|
261
331
|
},
|
|
262
332
|
async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, config_snapshot: cfgSnap }) => {
|
|
@@ -297,6 +367,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
297
367
|
};
|
|
298
368
|
}
|
|
299
369
|
}
|
|
370
|
+
// #203 identity guard — network tokens must not silently rebind their
|
|
371
|
+
// own name via report_status. Without this, a runtime whose ALIAS
|
|
372
|
+
// drifted (env leak / wrong --alias / CurrentAliasResolver seeded from
|
|
373
|
+
// the wrong node_id) could rewrite api_tokens.name and cause every
|
|
374
|
+
// subsequent send_task from this token to be attributed to the drifted
|
|
375
|
+
// alias — the observed #203 symptom (grokB's send arriving as
|
|
376
|
+
// from=grokA). Only the legit rename path (rename.ts) may cross the
|
|
377
|
+
// token→alias binding. Symmetric to fromIdentityMismatchReply on the
|
|
378
|
+
// send side (test198).
|
|
379
|
+
if (callerTokenIsNetwork && callerAlias && !canonical.renamed && effectiveAlias !== callerAlias) {
|
|
380
|
+
return {
|
|
381
|
+
content: [{
|
|
382
|
+
type: "text" as const,
|
|
383
|
+
text: JSON.stringify({
|
|
384
|
+
ok: false,
|
|
385
|
+
error: "alias_identity_mismatch",
|
|
386
|
+
message: "report_status alias does not match the token-bound node alias; use anet node rename to change identity",
|
|
387
|
+
token_alias: callerAlias,
|
|
388
|
+
reported_alias: effectiveAlias,
|
|
389
|
+
}),
|
|
390
|
+
}],
|
|
391
|
+
};
|
|
392
|
+
}
|
|
300
393
|
console.log(`[${ts()}] ${effectiveAlias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
|
|
301
394
|
if (callerTokenIsNetwork && callerTokenId) {
|
|
302
395
|
try {
|
|
@@ -815,6 +908,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
815
908
|
return { content: [{ type: "text" as const, text: JSON.stringify(payload) }] };
|
|
816
909
|
}
|
|
817
910
|
|
|
911
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 1/6).
|
|
912
|
+
{
|
|
913
|
+
const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
|
|
914
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
915
|
+
}
|
|
916
|
+
|
|
818
917
|
console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
|
|
819
918
|
const id = uuidv4();
|
|
820
919
|
const fromNodeId = resolveNodeIdForAlias(from_session, effectiveNetId);
|
|
@@ -910,6 +1009,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
910
1009
|
const targetAlias = canonical.alias;
|
|
911
1010
|
const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
|
|
912
1011
|
if (target.state === "not_found") return deliveryTargetReply(target)!;
|
|
1012
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 2/6).
|
|
1013
|
+
{
|
|
1014
|
+
const lc = assertNodeActive(targetAlias, effectiveNetId ?? null);
|
|
1015
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
1016
|
+
}
|
|
913
1017
|
console.log(`[${ts()}] ${from_session} → send_message → ${targetAlias}: ${message.slice(0, 60)}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
|
|
914
1018
|
const id = uuidv4();
|
|
915
1019
|
db.run(
|
|
@@ -1001,6 +1105,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1001
1105
|
}
|
|
1002
1106
|
}
|
|
1003
1107
|
const replyTargetNodeId = resolveNodeIdForAlias(alias, effectiveNetId);
|
|
1108
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 3/6).
|
|
1109
|
+
{
|
|
1110
|
+
const lc = assertNodeActive(alias, effectiveNetId ?? null);
|
|
1111
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
1112
|
+
}
|
|
1004
1113
|
const replyLogged = db.transaction(() => {
|
|
1005
1114
|
db.run(
|
|
1006
1115
|
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
|
|
@@ -1135,6 +1244,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1135
1244
|
if (!["failed", "expired", "cancelled"].includes(task.status)) {
|
|
1136
1245
|
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task status is ${task.status}, not retryable` }) }] };
|
|
1137
1246
|
}
|
|
1247
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 4/6).
|
|
1248
|
+
{
|
|
1249
|
+
const lc = assertNodeActive(task.to_name, effectiveNetId ?? task.network_id ?? null);
|
|
1250
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
1251
|
+
}
|
|
1138
1252
|
db.transaction(() => {
|
|
1139
1253
|
// Reset task status
|
|
1140
1254
|
const updateParams: any[] = [task_id];
|
|
@@ -1282,6 +1396,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1282
1396
|
const reassignedAlias = canonical.alias;
|
|
1283
1397
|
const target = resolveDeliveryTarget(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
|
|
1284
1398
|
const newNodeId = target.state === "not_found" ? null : (target.session?.node_id ?? null);
|
|
1399
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 5/6).
|
|
1400
|
+
{
|
|
1401
|
+
const lc = assertNodeActive(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
|
|
1402
|
+
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
1403
|
+
}
|
|
1285
1404
|
db.transaction(() => {
|
|
1286
1405
|
// Ack old inbox to prevent original agent from picking it up
|
|
1287
1406
|
const inboxParams: any[] = [task_id];
|
|
@@ -1327,6 +1446,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1327
1446
|
const ids: string[] = [];
|
|
1328
1447
|
|
|
1329
1448
|
for (const t of targets) {
|
|
1449
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 6/6).
|
|
1450
|
+
// Broadcast skips non-active recipients silently rather than
|
|
1451
|
+
// failing the entire send — broadcast semantics are best-effort
|
|
1452
|
+
// per-recipient and a stopped node simply gets nothing.
|
|
1453
|
+
const lc = assertNodeActive(t.alias, effectiveNetId ?? t.network_id ?? null);
|
|
1454
|
+
if (!lc.ok) continue;
|
|
1330
1455
|
const id = uuidv4();
|
|
1331
1456
|
db.run(
|
|
1332
1457
|
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
|
|
@@ -1418,7 +1543,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1418
1543
|
callerNetworkId: string | null,
|
|
1419
1544
|
): { row: any | null; sec1Ok: boolean } => {
|
|
1420
1545
|
const row = db.get<any>(
|
|
1421
|
-
"SELECT node_id, alias, network_id, config_revision FROM nodes WHERE node_id = ?1",
|
|
1546
|
+
"SELECT node_id, alias, network_id, config_revision, config_snapshot FROM nodes WHERE node_id = ?1",
|
|
1422
1547
|
nodeId,
|
|
1423
1548
|
);
|
|
1424
1549
|
if (!row) return { row: null, sec1Ok: false };
|
|
@@ -1442,6 +1567,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1442
1567
|
patch: z.object({
|
|
1443
1568
|
model: z.string().max(200).optional(),
|
|
1444
1569
|
flags: z.record(z.unknown()).optional(),
|
|
1570
|
+
// #260 P5 — channel enable/disable. Restart-tier field (agent-node
|
|
1571
|
+
// reads config.channels once at boot to fork per-channel workers,
|
|
1572
|
+
// so the swap takes effect via process restart). Not
|
|
1573
|
+
// SECURITY_SENSITIVE — this is a lifecycle op, gated by SEC-1
|
|
1574
|
+
// (network scope) only.
|
|
1575
|
+
//
|
|
1576
|
+
// Deliberately `z.array(z.unknown()).max(16)` (mirrors flags's
|
|
1577
|
+
// `z.record(z.unknown())`): trust nothing at the wire boundary,
|
|
1578
|
+
// narrow the same way `narrowChannelsPatch` narrows raw untrusted
|
|
1579
|
+
// JSON (typeof + allowlist + dedup + case-fold). A strict
|
|
1580
|
+
// `z.array(z.string())` would fail-fast on a single non-string
|
|
1581
|
+
// entry, but the wire contract wants junk silently dropped so a
|
|
1582
|
+
// dashboard fat-finger doesn't turn into a 400 the user has to
|
|
1583
|
+
// interpret. validatePatch then re-rejects if the caller bypassed
|
|
1584
|
+
// narrowing.
|
|
1585
|
+
channels: z.array(z.unknown()).max(16).optional(),
|
|
1445
1586
|
}).describe("Fields to update. Empty patch → no-op (use restart_node for that)."),
|
|
1446
1587
|
network_id: z.string().max(200).optional(),
|
|
1447
1588
|
},
|
|
@@ -1459,6 +1600,50 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1459
1600
|
|
|
1460
1601
|
const model = typeof patch.model === "string" ? patch.model : undefined;
|
|
1461
1602
|
const flags = (patch.flags && typeof patch.flags === "object") ? patch.flags as Record<string, unknown> : {};
|
|
1603
|
+
// Narrow untrusted `channels` at the boundary (typeof + allowlist +
|
|
1604
|
+
// dedup + case-fold), and distinguish two very different cases
|
|
1605
|
+
// that both narrow to `[]`:
|
|
1606
|
+
// (a) `patch.channels === []` (explicit "disable all editable
|
|
1607
|
+
// channels"). Downstream must proceed and write channels=[]
|
|
1608
|
+
// so the node's next restart forks no workers.
|
|
1609
|
+
// (b) `patch.channels === ["commhub"]` or similar — the caller
|
|
1610
|
+
// sent items but every single one was invalid (dashboard PR
|
|
1611
|
+
// #31 still ships commhub, and a typo like "telegarm" would
|
|
1612
|
+
// hit the same path). Downstream MUST NOT treat this as
|
|
1613
|
+
// (a) — silently converting to disable-all would nuke the
|
|
1614
|
+
// user's existing telegram/feishu workers.
|
|
1615
|
+
//
|
|
1616
|
+
// The reject error is `channels_all_invalid` so the dashboard
|
|
1617
|
+
// can surface the mismatch instead of showing a false success.
|
|
1618
|
+
let channels: string[] | undefined = undefined;
|
|
1619
|
+
if (patch.channels !== undefined) {
|
|
1620
|
+
if (!Array.isArray(patch.channels)) {
|
|
1621
|
+
// Zod already permits arrays only; belt+braces if it drifts.
|
|
1622
|
+
return {
|
|
1623
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_patch", field: "channels", reason: "must be an array" }) }],
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
const narrowed = narrowChannelsPatch(patch.channels) ?? [];
|
|
1627
|
+
if (patch.channels.length === 0) {
|
|
1628
|
+
channels = []; // (a) explicit disable-all
|
|
1629
|
+
} else if (narrowed.length === 0) {
|
|
1630
|
+
// (b) every entry was invalid — refuse to write. Distinct
|
|
1631
|
+
// error so the dashboard can surface the failure.
|
|
1632
|
+
return {
|
|
1633
|
+
content: [{
|
|
1634
|
+
type: "text" as const,
|
|
1635
|
+
text: JSON.stringify({
|
|
1636
|
+
ok: false,
|
|
1637
|
+
error: "channels_all_invalid",
|
|
1638
|
+
requested: patch.channels,
|
|
1639
|
+
message: "every requested channel is unknown or unsupported; use an explicit empty array to disable-all",
|
|
1640
|
+
}),
|
|
1641
|
+
}],
|
|
1642
|
+
};
|
|
1643
|
+
} else {
|
|
1644
|
+
channels = narrowed;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1462
1647
|
|
|
1463
1648
|
// SEC-2 (final policy 2026-06-28) — security-sensitive flags
|
|
1464
1649
|
// (permissionMode / dangerouslySkipPermissions / teammateMode)
|
|
@@ -1485,7 +1670,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1485
1670
|
};
|
|
1486
1671
|
}
|
|
1487
1672
|
|
|
1488
|
-
const validationFail = validatePatch(model, flags);
|
|
1673
|
+
const validationFail = validatePatch(model, flags, channels);
|
|
1489
1674
|
if (validationFail) {
|
|
1490
1675
|
return {
|
|
1491
1676
|
content: [{
|
|
@@ -1563,8 +1748,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1563
1748
|
|
|
1564
1749
|
// Compute apply_mode + persist + push doorbell.
|
|
1565
1750
|
const updateId = `cu_${uuidv4()}`;
|
|
1566
|
-
const applyMode = computeApplyMode(model, flags);
|
|
1567
|
-
const patchJson = JSON.stringify({
|
|
1751
|
+
const applyMode = computeApplyMode(model, flags, channels);
|
|
1752
|
+
const patchJson = JSON.stringify({
|
|
1753
|
+
...(model !== undefined ? { model } : {}),
|
|
1754
|
+
flags,
|
|
1755
|
+
...(channels !== undefined ? { channels } : {}),
|
|
1756
|
+
});
|
|
1568
1757
|
const networkId = node.network_id || "default";
|
|
1569
1758
|
db.run(
|
|
1570
1759
|
`INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7, ?8)`,
|
|
@@ -1741,16 +1930,1513 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1741
1930
|
}
|
|
1742
1931
|
const updateId = `cu_${uuidv4()}`;
|
|
1743
1932
|
const networkId = node.network_id || "default";
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1933
|
+
// RFC-027 PR1.2a latent fix (#346 ack): restart_node must reset
|
|
1934
|
+
// lifecycle_state to 'active'. Otherwise a node that was previously
|
|
1935
|
+
// stop_node'd → 'stopped' would, after restart, stay marked
|
|
1936
|
+
// 'stopped' in the nodes table → the 6 MCP + 2 REST inbox guards
|
|
1937
|
+
// would refuse every routing attempt → node silently unreachable
|
|
1938
|
+
// (no error to operator, just no traffic). Pair the schema flip
|
|
1939
|
+
// with the config_updates INSERT inside one tx so we don't
|
|
1940
|
+
// half-commit if the dispatch INSERT throws.
|
|
1941
|
+
db.transaction(() => {
|
|
1942
|
+
db.run(
|
|
1943
|
+
`INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, '{}', 'restart_only', ?4, 'pending', ?5, ?6)`,
|
|
1944
|
+
[updateId, nodeId, networkId, node.config_revision || 0, Date.now(), callerTokenId || "unknown"],
|
|
1945
|
+
);
|
|
1946
|
+
db.run(
|
|
1947
|
+
`UPDATE nodes SET lifecycle_state = 'active' WHERE node_id = ?1`,
|
|
1948
|
+
[nodeId],
|
|
1949
|
+
);
|
|
1950
|
+
});
|
|
1748
1951
|
pushEvent(node.alias, { type: "restart", update_id: updateId }, networkId);
|
|
1749
1952
|
return {
|
|
1750
1953
|
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update_id: updateId, apply_mode: "restart_only" }) }],
|
|
1751
1954
|
};
|
|
1752
1955
|
},
|
|
1753
1956
|
);
|
|
1957
|
+
|
|
1958
|
+
// ── RFC-026 P1 — create-node + host-daemon (3 MCP tools) ──────────
|
|
1959
|
+
// Background timers (GC + sweeper) are idempotent + cheap; safe to
|
|
1960
|
+
// call on every registerTools invocation (statless mode re-registers
|
|
1961
|
+
// per request). They unref themselves so don't hold the event loop.
|
|
1962
|
+
startPendingEnvGcTimer();
|
|
1963
|
+
startSweeperTimer();
|
|
1964
|
+
|
|
1965
|
+
// §4.1.4 C2 — caller daemon resolved via token-bound identity (NOT
|
|
1966
|
+
// alias). Thin closure over the module-level helper so callers in
|
|
1967
|
+
// this scope can use the captured request-level vars. The pure
|
|
1968
|
+
// helper lives in create-node.ts so unit tests call exactly the
|
|
1969
|
+
// same code path the tools do (per 通信龙 PR #299 nit 1 — no inline-
|
|
1970
|
+
// mirror SQL in tests).
|
|
1971
|
+
const resolveCallerDaemonTokenBound = () =>
|
|
1972
|
+
_resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
|
|
1973
|
+
|
|
1974
|
+
// Helper — map a ValidationError thrown from create-node-validate
|
|
1975
|
+
// into the MCP-tool-call JSON reply shape.
|
|
1976
|
+
const validationFailReply = (e: unknown) => {
|
|
1977
|
+
if (e instanceof ValidationError) {
|
|
1978
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: e.code, ...(e.detail || {}) }) }] };
|
|
1979
|
+
}
|
|
1980
|
+
throw e;
|
|
1981
|
+
};
|
|
1982
|
+
|
|
1983
|
+
// RFC-026 §9.2.1 / #338 PR2 — list_host_supervisors.
|
|
1984
|
+
// Surfaces host_supervisor daemons in the caller's network with
|
|
1985
|
+
// online flag + declared capabilities. Replaces the prior `node_daemon_`
|
|
1986
|
+
// prefix heuristic (dashboard /api/anet/node-create) + the role-extract
|
|
1987
|
+
// path on /api/nodes (#337). Member脱敏 strips host_telemetry IP /
|
|
1988
|
+
// cpu / mem (per RFC-026 §6 #3 — daemons can leak internal topology
|
|
1989
|
+
// to non-admin readers). Revoked daemon ntoks filtered out via
|
|
1990
|
+
// join on api_tokens.revoked_at.
|
|
1991
|
+
server.tool(
|
|
1992
|
+
"list_host_supervisors",
|
|
1993
|
+
"List host_supervisor daemon nodes in the caller's network (online status + runtimes_supported + allowed_secret_keys + telemetry; member-脱敏). RFC-026 §9.2.1.",
|
|
1994
|
+
{
|
|
1995
|
+
network_id: z.string().max(200).optional(),
|
|
1996
|
+
},
|
|
1997
|
+
async ({ network_id: clientNetId }) => {
|
|
1998
|
+
// SEC-1 — use resolveReadScope, NOT getNetworkId. getNetworkId is
|
|
1999
|
+
// for write-tool helpers and pairs with `canWrite`; READ tools that
|
|
2000
|
+
// bypass canWrite and trust getNetworkId leak across tenants
|
|
2001
|
+
// (PR2 v1 BLOCKER per 通信龙 audit — utok_ caller with
|
|
2002
|
+
// enforceNetworkId=null could pass any network_id and read daemon
|
|
2003
|
+
// names + allowed_secret_keys for tenants they're not a member of).
|
|
2004
|
+
// resolveReadScope checks network_members for the user + denies
|
|
2005
|
+
// on non-membership, mirroring REST resolveRestNetworkScope.
|
|
2006
|
+
const readScope = resolveReadScope(clientNetId);
|
|
2007
|
+
if (readScope.denied) {
|
|
2008
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
2009
|
+
}
|
|
2010
|
+
// Caller role for 脱敏 decision (admin/owner = full, others = masked).
|
|
2011
|
+
// Use the resolved scope's networkId (the verified one), not the
|
|
2012
|
+
// unchecked clientNetId.
|
|
2013
|
+
const scopedNetId = readScope.networkId ?? null;
|
|
2014
|
+
const callerRole = enforceUserId && scopedNetId
|
|
2015
|
+
? getUserNetworkRole(enforceUserId, scopedNetId)
|
|
2016
|
+
: null;
|
|
2017
|
+
// Network-bound tokens get full telemetry within their bound network
|
|
2018
|
+
// (already gate-locked by enforceNetworkId path of resolveReadScope).
|
|
2019
|
+
const isPrivileged = callerRole === "admin" || callerRole === "owner" || (!enforceUserId);
|
|
2020
|
+
|
|
2021
|
+
// Pull candidate daemons. Active-token EXISTS subquery handles BOTH
|
|
2022
|
+
// revoked daemon ntoks (revoked_at set) AND DELETEd token rows
|
|
2023
|
+
// (the standard revokeToken path deletes the row, not flagging
|
|
2024
|
+
// revoked_at — PR2 v1 SHOULD-FIX per 通信龙 audit). EXISTS naturally
|
|
2025
|
+
// dedupes if a daemon ever had multiple tokens (e.g. rotation; nit ⚪).
|
|
2026
|
+
let sql = `
|
|
2027
|
+
SELECT
|
|
2028
|
+
n.node_id, n.alias, n.hostname, n.network_id,
|
|
2029
|
+
n.runtimes_supported, n.allowed_secret_keys,
|
|
2030
|
+
n.created_at, n.updated_at,
|
|
2031
|
+
s.last_seen_at AS session_last_seen,
|
|
2032
|
+
s.status AS session_status,
|
|
2033
|
+
s.cpu_cores AS session_cpu_cores,
|
|
2034
|
+
s.mem_total_gb AS session_mem_total_gb,
|
|
2035
|
+
s.ip AS session_ip,
|
|
2036
|
+
n.config_snapshot
|
|
2037
|
+
FROM nodes n
|
|
2038
|
+
LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
|
|
2039
|
+
WHERE EXISTS (
|
|
2040
|
+
SELECT 1 FROM api_tokens t
|
|
2041
|
+
WHERE t.network_id = n.network_id
|
|
2042
|
+
AND t.name = 'node:' || n.alias
|
|
2043
|
+
AND t.revoked_at IS NULL
|
|
2044
|
+
)
|
|
2045
|
+
`;
|
|
2046
|
+
const sqlParams: any[] = [];
|
|
2047
|
+
sql = addReadScope(sql, sqlParams, readScope, "n.network_id");
|
|
2048
|
+
sql += ` ORDER BY n.updated_at DESC`;
|
|
2049
|
+
const rows = db.all<Record<string, any>>(sql, ...sqlParams);
|
|
2050
|
+
|
|
2051
|
+
// Filter to role=host_supervisor (read from config_snapshot;
|
|
2052
|
+
// schema-promoted columns runtimes_supported/allowed_secret_keys
|
|
2053
|
+
// are pre-extracted but role isn't a first-class column).
|
|
2054
|
+
const nowMs = Date.now();
|
|
2055
|
+
const ONLINE_MS = 60_000;
|
|
2056
|
+
const daemons = rows
|
|
2057
|
+
.map(r => {
|
|
2058
|
+
let snapRole: string | null = null;
|
|
2059
|
+
if (r.config_snapshot) {
|
|
2060
|
+
try {
|
|
2061
|
+
const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
|
|
2062
|
+
snapRole = typeof parsed?.role === "string" ? parsed.role : null;
|
|
2063
|
+
} catch { /* malformed snapshot — role stays null */ }
|
|
2064
|
+
}
|
|
2065
|
+
return { row: r, role: snapRole };
|
|
2066
|
+
})
|
|
2067
|
+
.filter(({ role }) => role === "host_supervisor")
|
|
2068
|
+
.map(({ row: r }) => {
|
|
2069
|
+
// online = sessions.last_seen_at within ONLINE_MS
|
|
2070
|
+
let online = false;
|
|
2071
|
+
let lastSeenAt: string | null = null;
|
|
2072
|
+
if (r.session_last_seen) {
|
|
2073
|
+
lastSeenAt = r.session_last_seen;
|
|
2074
|
+
const t = Date.parse(r.session_last_seen);
|
|
2075
|
+
if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
|
|
2076
|
+
}
|
|
2077
|
+
// Parse self-declare arrays (default to [] for pre-PR2 daemons)
|
|
2078
|
+
let runtimes: string[] = [];
|
|
2079
|
+
let secrets: string[] = [];
|
|
2080
|
+
try {
|
|
2081
|
+
if (r.runtimes_supported) {
|
|
2082
|
+
const parsed = JSON.parse(r.runtimes_supported);
|
|
2083
|
+
runtimes = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
|
|
2084
|
+
}
|
|
2085
|
+
} catch { /* malformed — empty */ }
|
|
2086
|
+
try {
|
|
2087
|
+
if (r.allowed_secret_keys) {
|
|
2088
|
+
const parsed = JSON.parse(r.allowed_secret_keys);
|
|
2089
|
+
secrets = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
|
|
2090
|
+
}
|
|
2091
|
+
} catch { /* malformed — empty */ }
|
|
2092
|
+
// host_telemetry — member脱敏 drops IP/cpu/mem
|
|
2093
|
+
const telemetry: Record<string, unknown> = {
|
|
2094
|
+
alert_level: online ? "green" : "gray",
|
|
2095
|
+
};
|
|
2096
|
+
if (isPrivileged) {
|
|
2097
|
+
telemetry.cpu_cores = r.session_cpu_cores ?? null;
|
|
2098
|
+
telemetry.mem_gb = r.session_mem_total_gb ?? null;
|
|
2099
|
+
telemetry.ip_internal = r.session_ip ?? null;
|
|
2100
|
+
}
|
|
2101
|
+
return {
|
|
2102
|
+
daemon_node_id: r.node_id,
|
|
2103
|
+
alias: r.alias,
|
|
2104
|
+
hostname: r.hostname,
|
|
2105
|
+
online,
|
|
2106
|
+
last_seen_at: lastSeenAt,
|
|
2107
|
+
runtimes_supported: runtimes,
|
|
2108
|
+
allowed_secret_keys: secrets,
|
|
2109
|
+
host_telemetry: telemetry,
|
|
2110
|
+
};
|
|
2111
|
+
});
|
|
2112
|
+
|
|
2113
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, daemons, count: daemons.length }) }] };
|
|
2114
|
+
},
|
|
2115
|
+
);
|
|
2116
|
+
|
|
2117
|
+
// §2.5 step 1+2 — dashboard-facing tool. Validates spec, mint
|
|
2118
|
+
// child-ntok, stash env_blob in pendingEnvBlobs Map, write request
|
|
2119
|
+
// row (metadata only — no env_blob in SQL), pushEvent doorbell.
|
|
2120
|
+
server.tool(
|
|
2121
|
+
"create_node",
|
|
2122
|
+
"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.",
|
|
2123
|
+
{
|
|
2124
|
+
daemon_node_id: z.string().min(1).max(200),
|
|
2125
|
+
node_spec: z.object({
|
|
2126
|
+
name: z.string().min(1).max(64),
|
|
2127
|
+
runtime: z.string().min(1).max(64),
|
|
2128
|
+
model: z.string().min(1).max(100),
|
|
2129
|
+
flags: z.record(z.unknown()).optional(),
|
|
2130
|
+
env_refs: z.array(z.string().max(64)).optional(),
|
|
2131
|
+
channels: z.array(z.unknown()).optional(),
|
|
2132
|
+
}),
|
|
2133
|
+
network_id: z.string().max(200).optional(),
|
|
2134
|
+
},
|
|
2135
|
+
async ({ daemon_node_id, node_spec, network_id: clientNetId }) => {
|
|
2136
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
2137
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
2138
|
+
|
|
2139
|
+
// §4.1.1 — admin+ for create_node (creating a node = pulling new
|
|
2140
|
+
// resource + burning API quota, one level above edit single flag)
|
|
2141
|
+
const callerRole = enforceUserId && effectiveNetId
|
|
2142
|
+
? getUserNetworkRole(enforceUserId, effectiveNetId)
|
|
2143
|
+
: null;
|
|
2144
|
+
if (callerRole !== "admin" && callerRole !== "owner") {
|
|
2145
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_create_node", required_role: "admin", caller_role: callerRole }) }] };
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// Daemon must exist + must be in caller's network + must be
|
|
2149
|
+
// online with role=host_supervisor capability.
|
|
2150
|
+
const { row: daemon, sec1Ok } = resolveTargetNode(daemon_node_id, effectiveNetId);
|
|
2151
|
+
if (!daemon) {
|
|
2152
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found", daemon_node_id }) }] };
|
|
2153
|
+
}
|
|
2154
|
+
if (!sec1Ok) {
|
|
2155
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
// Read daemon's host_supervisor capability + allowlist from its
|
|
2159
|
+
// last reported config_snapshot. PR3 (#338) canonical path is
|
|
2160
|
+
// `daemon_capabilities.runtimes_supported` (RFC-026 §9.3).
|
|
2161
|
+
// Pre-PR3 daemons (preview.10 and earlier) place these at the
|
|
2162
|
+
// TOP level of the snapshot rather than nested — those reads
|
|
2163
|
+
// return undefined here and `daemonAllowedRuntimes` stays null
|
|
2164
|
+
// (permissive — no allowlist enforcement); they fall back to
|
|
2165
|
+
// §4.2.2 structural validation only, identical to pre-PR3
|
|
2166
|
+
// behavior. No regression on in-flight daemons.
|
|
2167
|
+
let daemonAllowList = new Set<string>();
|
|
2168
|
+
let daemonAllowedRuntimes: string[] | null = null;
|
|
2169
|
+
try {
|
|
2170
|
+
const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
|
|
2171
|
+
const caps = snap?.daemon_capabilities;
|
|
2172
|
+
if (Array.isArray(caps?.allowed_secret_keys)) {
|
|
2173
|
+
daemonAllowList = new Set(caps.allowed_secret_keys);
|
|
2174
|
+
}
|
|
2175
|
+
if (Array.isArray(caps?.runtimes_supported)) {
|
|
2176
|
+
daemonAllowedRuntimes = caps.runtimes_supported;
|
|
2177
|
+
}
|
|
2178
|
+
} catch { /* permissive fallback */ }
|
|
2179
|
+
|
|
2180
|
+
// §4.2.2 — structural validation (catches name/runtime/model/
|
|
2181
|
+
// flag injection at the hub edge). Daemon repeats this; double
|
|
2182
|
+
// layer per RFC §4.2.2.
|
|
2183
|
+
try {
|
|
2184
|
+
validateChildName(node_spec.name);
|
|
2185
|
+
validateRuntime(node_spec.runtime);
|
|
2186
|
+
validateModel(node_spec.model);
|
|
2187
|
+
validateChannelsP1((node_spec as any).channels);
|
|
2188
|
+
for (const [k, v] of Object.entries(node_spec.flags || {})) {
|
|
2189
|
+
if (!(FLAG_KEYS as readonly string[]).includes(k)) throw new ValidationError("flag_key_unknown", { field: k });
|
|
2190
|
+
validateFlagValue(k, v);
|
|
2191
|
+
}
|
|
2192
|
+
} catch (e) {
|
|
2193
|
+
return validationFailReply(e);
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
// P1 daemon-side allowlist: if daemon publishes allowed_runtimes,
|
|
2197
|
+
// enforce at hub for fast-fail; daemon repeats.
|
|
2198
|
+
if (daemonAllowedRuntimes && !daemonAllowedRuntimes.includes(node_spec.runtime)) {
|
|
2199
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "runtime_not_in_local_allowlist", runtime: node_spec.runtime, allowed: daemonAllowedRuntimes }) }] };
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
// §4.4.7 — env_refs strict (7-step gate) + resolve to env_blob.
|
|
2203
|
+
let envBlob: Record<string, string> = {};
|
|
2204
|
+
const envRefs = (node_spec as any).env_refs;
|
|
2205
|
+
try {
|
|
2206
|
+
envBlob = validateEnvRefs(envRefs, {
|
|
2207
|
+
callerNetworkId: effectiveNetId || "default",
|
|
2208
|
+
daemonAllowList,
|
|
2209
|
+
networkSecretsGet: (_net: string, _key: string) => undefined, // P1: no vault yet — see note below
|
|
2210
|
+
});
|
|
2211
|
+
} catch (e) {
|
|
2212
|
+
return validationFailReply(e);
|
|
2213
|
+
}
|
|
2214
|
+
// P1 NOTE — network_secrets vault is RFC §4.4 / §2.4 future
|
|
2215
|
+
// work. For now if dashboard sends env_refs we'll reject as
|
|
2216
|
+
// not-in-vault (above). When the vault lands, replace the
|
|
2217
|
+
// `networkSecretsGet: () => undefined` line with the real DB
|
|
2218
|
+
// lookup; nothing else in this tool needs to change.
|
|
2219
|
+
|
|
2220
|
+
// Single-flight per (daemon, child_name): partial unique index
|
|
2221
|
+
// uniq_ncr_inflight already prevents racing INSERT, but we
|
|
2222
|
+
// surface a friendly error rather than letting the DB constraint
|
|
2223
|
+
// raise.
|
|
2224
|
+
const existing = db.get<{ request_id: string; status: string }>(
|
|
2225
|
+
`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`,
|
|
2226
|
+
daemon_node_id, node_spec.name,
|
|
2227
|
+
);
|
|
2228
|
+
if (existing) {
|
|
2229
|
+
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 }) }] };
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
// §4.2.4 — daemon_max_children backpressure (best-effort: count
|
|
2233
|
+
// currently-active children for this daemon).
|
|
2234
|
+
const maxChildren = (() => {
|
|
2235
|
+
try {
|
|
2236
|
+
const snap = daemon.config_snapshot ? JSON.parse(daemon.config_snapshot) : null;
|
|
2237
|
+
const m = snap?.daemon_capabilities?.max_concurrent_children;
|
|
2238
|
+
return (typeof m === "number" && m > 0) ? m : 20;
|
|
2239
|
+
} catch { return 20; }
|
|
2240
|
+
})();
|
|
2241
|
+
const childCount = db.get<{ n: number }>(
|
|
2242
|
+
`SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1 AND status IN ('pending', 'delivered', 'succeeded')`,
|
|
2243
|
+
daemon_node_id,
|
|
2244
|
+
)?.n || 0;
|
|
2245
|
+
if (childCount >= maxChildren) {
|
|
2246
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_max_children", current: childCount, max: maxChildren }) }] };
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
// §2.5 step 2 + §4.4 F1 — mint child-ntok + stash env_blob in
|
|
2250
|
+
// Map (NOT in DB). Token row marked role='child' + request_id
|
|
2251
|
+
// for sweeper traceability per §4.4.8 impl note.
|
|
2252
|
+
const childToken = generateNetworkToken();
|
|
2253
|
+
const childTokenId = generateId("tok");
|
|
2254
|
+
const networkIdForChild = daemon.network_id || effectiveNetId || "default";
|
|
2255
|
+
const requestId = newRequestId();
|
|
2256
|
+
// Use the dashboard caller's user_id as the token's user_id so
|
|
2257
|
+
// resolveToken returns sane role / network on the child's side
|
|
2258
|
+
// (audit trail = whoever created it).
|
|
2259
|
+
if (!enforceUserId) {
|
|
2260
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "auth_required" }) }] };
|
|
2261
|
+
}
|
|
2262
|
+
db.run(
|
|
2263
|
+
`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)`,
|
|
2264
|
+
[childTokenId, hashToken(childToken), enforceUserId, networkIdForChild, `node:${node_spec.name}`, "network", "child", requestId]
|
|
2265
|
+
);
|
|
2266
|
+
|
|
2267
|
+
putPendingEnvBlob({
|
|
2268
|
+
request_id: requestId,
|
|
2269
|
+
daemon_node_id,
|
|
2270
|
+
env_blob: envBlob,
|
|
2271
|
+
child_token: childToken,
|
|
2272
|
+
child_token_id: childTokenId,
|
|
2273
|
+
});
|
|
2274
|
+
|
|
2275
|
+
// Write metadata-only row. env_blob field deliberately ABSENT
|
|
2276
|
+
// from the schema (see db.ts CREATE TABLE) — F1 lock.
|
|
2277
|
+
const envKeys = Object.keys(envBlob);
|
|
2278
|
+
db.run(
|
|
2279
|
+
`INSERT INTO node_create_requests
|
|
2280
|
+
(request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
|
|
2281
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
|
|
2282
|
+
[
|
|
2283
|
+
requestId, daemon_node_id, node_spec.name, networkIdForChild,
|
|
2284
|
+
node_spec.runtime, node_spec.model, JSON.stringify(node_spec.flags || {}),
|
|
2285
|
+
JSON.stringify(envKeys), childTokenId, Date.now(), callerTokenId || "unknown",
|
|
2286
|
+
],
|
|
2287
|
+
);
|
|
2288
|
+
|
|
2289
|
+
// SSE doorbell — daemon will pull via get_create_request.
|
|
2290
|
+
// Payload carries ONLY request_id (no secret); daemon current
|
|
2291
|
+
// SSE handler resolves the rest via MCP call.
|
|
2292
|
+
pushEvent(daemon.alias, { type: "create_node", request_id: requestId }, networkIdForChild);
|
|
2293
|
+
|
|
2294
|
+
// §4.5 audit — dispatch succeeded
|
|
2295
|
+
auditCreateNode({
|
|
2296
|
+
action: "create_node_dispatched",
|
|
2297
|
+
user_id: enforceUserId,
|
|
2298
|
+
network_id: networkIdForChild,
|
|
2299
|
+
target_id: requestId,
|
|
2300
|
+
detail: {
|
|
2301
|
+
daemon_node_id,
|
|
2302
|
+
child_name: node_spec.name,
|
|
2303
|
+
runtime: node_spec.runtime,
|
|
2304
|
+
model: node_spec.model,
|
|
2305
|
+
flag_keys: Object.keys(node_spec.flags || {}),
|
|
2306
|
+
env_keys: envKeys,
|
|
2307
|
+
},
|
|
2308
|
+
});
|
|
2309
|
+
|
|
2310
|
+
return {
|
|
2311
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId }) }],
|
|
2312
|
+
};
|
|
2313
|
+
},
|
|
2314
|
+
);
|
|
2315
|
+
|
|
2316
|
+
// §2.5 step 3 — daemon-facing pull. Returns full spec + env_blob +
|
|
2317
|
+
// child_ntok in one shot. Map evicted on take (one-shot consume).
|
|
2318
|
+
//
|
|
2319
|
+
// §4.1.4 C2 token-bound daemon resolution (PR #299 BLOCKER #1, 通信牛):
|
|
2320
|
+
// We MUST resolve the caller daemon via token-bound identity, NOT
|
|
2321
|
+
// alias. alias is NOT a security boundary — two daemons with the same
|
|
2322
|
+
// alias in different networks (or attacker-named-itself-the-same)
|
|
2323
|
+
// would otherwise resolve to the wrong row. Same class as the prior
|
|
2324
|
+
// report_status cross-tenant re-home bug.
|
|
2325
|
+
//
|
|
2326
|
+
// Resolution chain: caller's ntok (callerTokenId + callerTokenIsNetwork)
|
|
2327
|
+
// → api_tokens row → joins to nodes via name='node:<alias>' AND
|
|
2328
|
+
// network_id matches → unique daemon node row scoped to caller's
|
|
2329
|
+
// network. If the ntok isn't bound to a node, or the joined node
|
|
2330
|
+
// isn't a host_supervisor, reject.
|
|
2331
|
+
server.tool(
|
|
2332
|
+
"get_create_request",
|
|
2333
|
+
"Daemon pulls a pending create-node request (called when SSE create_node doorbell arrives). RFC-026.",
|
|
2334
|
+
{
|
|
2335
|
+
request_id: z.string().min(1).max(200),
|
|
2336
|
+
},
|
|
2337
|
+
async ({ request_id }) => {
|
|
2338
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
2339
|
+
if (!callerDaemon.ok) {
|
|
2340
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
const row = db.get<{ request_id: string; daemon_node_id: string; status: string; child_token_id: string | null; network_id: string }>(
|
|
2344
|
+
`SELECT request_id, daemon_node_id, status, child_token_id, network_id FROM node_create_requests WHERE request_id = ?1`,
|
|
2345
|
+
request_id,
|
|
2346
|
+
);
|
|
2347
|
+
if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
2348
|
+
// §4.1.4 — strict daemon binding by token-derived node_id.
|
|
2349
|
+
if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
|
|
2350
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
2351
|
+
}
|
|
2352
|
+
// Additional network-scope guard (defense in depth: row's
|
|
2353
|
+
// network_id MUST equal caller's network).
|
|
2354
|
+
if (row.network_id !== callerDaemon.networkId) {
|
|
2355
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
|
|
2356
|
+
}
|
|
2357
|
+
if (row.status !== "pending") {
|
|
2358
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_pending", current_status: row.status }) }] };
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
// Take env_blob from Map; this is the one-shot consume per F1.
|
|
2362
|
+
// takePendingEnvBlob ALSO checks daemon binding (belt+braces).
|
|
2363
|
+
const blob = takePendingEnvBlob(request_id, callerDaemon.daemonNodeId);
|
|
2364
|
+
if (!blob) {
|
|
2365
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "env_blob_unavailable" }) }] };
|
|
2366
|
+
}
|
|
2367
|
+
// Hydrate spec from row.
|
|
2368
|
+
const specRow = db.get<{ child_name: string; runtime: string; model: string; flags_json: string }>(
|
|
2369
|
+
`SELECT child_name, runtime, model, flags_json FROM node_create_requests WHERE request_id = ?1`,
|
|
2370
|
+
request_id,
|
|
2371
|
+
);
|
|
2372
|
+
if (!specRow) {
|
|
2373
|
+
// Should never happen given the earlier row read.
|
|
2374
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_vanished" }) }] };
|
|
2375
|
+
}
|
|
2376
|
+
// Mark delivered (so sweeper distinguishes F-1 from F-2).
|
|
2377
|
+
db.run(
|
|
2378
|
+
`UPDATE node_create_requests SET status = 'delivered', delivered_at = ?1 WHERE request_id = ?2 AND status = 'pending'`,
|
|
2379
|
+
[Date.now(), request_id],
|
|
2380
|
+
);
|
|
2381
|
+
|
|
2382
|
+
return {
|
|
2383
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
2384
|
+
ok: true,
|
|
2385
|
+
request_id,
|
|
2386
|
+
node_spec: {
|
|
2387
|
+
name: specRow.child_name,
|
|
2388
|
+
runtime: specRow.runtime,
|
|
2389
|
+
model: specRow.model,
|
|
2390
|
+
flags: JSON.parse(specRow.flags_json),
|
|
2391
|
+
channels: [],
|
|
2392
|
+
},
|
|
2393
|
+
child_token: blob.child_token,
|
|
2394
|
+
env_blob: blob.env_blob,
|
|
2395
|
+
}) }],
|
|
2396
|
+
};
|
|
2397
|
+
},
|
|
2398
|
+
);
|
|
2399
|
+
|
|
2400
|
+
// §2.5 step 4 — daemon reports outcome. Note: 'succeeded' is set
|
|
2401
|
+
// automatically by hub when the child first registers (content-match
|
|
2402
|
+
// in upsertNodeWithSec1Guard); daemon's ack here is for explicit
|
|
2403
|
+
// failures (fork crashed, etc.). Daemon should still call this on
|
|
2404
|
+
// success too — it's a useful idempotent confirmation + lets hub
|
|
2405
|
+
// record fork-side info.
|
|
2406
|
+
server.tool(
|
|
2407
|
+
"ack_create_request",
|
|
2408
|
+
"Daemon acks a create-node request (called after fork). status='started' | 'failed' | 'rejected' | 'runtime_capability_check_failed'. RFC-026 §9.3 D2.",
|
|
2409
|
+
{
|
|
2410
|
+
request_id: z.string().min(1).max(200),
|
|
2411
|
+
// RFC-026 §9.3 D2 — runtime_capability_check_failed signals the
|
|
2412
|
+
// daemon spawned the child OK but it died within FAIL_FAST_MS
|
|
2413
|
+
// (5s in agent-node v2.5.0-preview.11+), indicating a
|
|
2414
|
+
// declaration↔reality gap on this daemon's runtimes_supported.
|
|
2415
|
+
// Treated terminal like 'failed' but fires a distinct audit_log
|
|
2416
|
+
// action so dashboards can highlight "lying daemons" separately
|
|
2417
|
+
// from generic spawn failures.
|
|
2418
|
+
status: z.enum(["started", "failed", "rejected", "runtime_capability_check_failed"]),
|
|
2419
|
+
error: z.string().max(1000).optional(),
|
|
2420
|
+
child_pid: z.number().int().optional(),
|
|
2421
|
+
runtime: z.string().max(64).optional(), // populated by daemon when status=runtime_capability_check_failed
|
|
2422
|
+
},
|
|
2423
|
+
async ({ request_id, status, error: ackError, child_pid: _pid, runtime: ackRuntime }) => {
|
|
2424
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
2425
|
+
if (!callerDaemon.ok) {
|
|
2426
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
2427
|
+
}
|
|
2428
|
+
const row = db.get<{ daemon_node_id: string; status: string; child_token_id: string | null; network_id: string }>(
|
|
2429
|
+
`SELECT daemon_node_id, status, child_token_id, network_id FROM node_create_requests WHERE request_id = ?1`,
|
|
2430
|
+
request_id,
|
|
2431
|
+
);
|
|
2432
|
+
if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
2433
|
+
if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
|
|
2434
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
2435
|
+
}
|
|
2436
|
+
if (row.network_id !== callerDaemon.networkId) {
|
|
2437
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
|
|
2438
|
+
}
|
|
2439
|
+
const ackedAt = Date.now();
|
|
2440
|
+
if (status === "started") {
|
|
2441
|
+
// Don't flip to 'succeeded' here — that happens via content-
|
|
2442
|
+
// match when the child actually registers. We just stamp ack.
|
|
2443
|
+
db.run(
|
|
2444
|
+
`UPDATE node_create_requests SET acked_at = ?1 WHERE request_id = ?2 AND status IN ('delivered', 'pending')`,
|
|
2445
|
+
[ackedAt, request_id],
|
|
2446
|
+
);
|
|
2447
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status: "awaiting_register" }) }] };
|
|
2448
|
+
}
|
|
2449
|
+
// failed / rejected / runtime_capability_check_failed — revoke
|
|
2450
|
+
// child-ntok + mark request terminal.
|
|
2451
|
+
if (row.child_token_id) {
|
|
2452
|
+
db.run(`UPDATE api_tokens SET revoked_at = datetime('now') WHERE token_id = ?1 AND revoked_at IS NULL`, [row.child_token_id]);
|
|
2453
|
+
}
|
|
2454
|
+
db.run(
|
|
2455
|
+
`UPDATE node_create_requests SET status = ?1, error = ?2, acked_at = ?3 WHERE request_id = ?4 AND status IN ('pending', 'delivered')`,
|
|
2456
|
+
[status, ackError || null, ackedAt, request_id],
|
|
2457
|
+
);
|
|
2458
|
+
// RFC-026 §9.3 D2 — surface declaration↔reality gap on a
|
|
2459
|
+
// distinct audit_log action so dashboards / operators can spot
|
|
2460
|
+
// chronically-lying daemons separate from generic spawn-failed.
|
|
2461
|
+
if (status === "runtime_capability_check_failed") {
|
|
2462
|
+
auditCreateNode({
|
|
2463
|
+
action: "daemon_capability_lied",
|
|
2464
|
+
user_id: null,
|
|
2465
|
+
network_id: row.network_id,
|
|
2466
|
+
target_id: request_id,
|
|
2467
|
+
detail: {
|
|
2468
|
+
daemon_node_id: row.daemon_node_id,
|
|
2469
|
+
runtime: ackRuntime || null,
|
|
2470
|
+
error: ackError ? ackError.slice(0, 500) : null,
|
|
2471
|
+
acked_at: ackedAt,
|
|
2472
|
+
},
|
|
2473
|
+
});
|
|
2474
|
+
}
|
|
2475
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
|
|
2476
|
+
},
|
|
2477
|
+
);
|
|
2478
|
+
|
|
2479
|
+
// ─── RFC-027 §2 — stop/delete node lifecycle ───────────────────────
|
|
2480
|
+
//
|
|
2481
|
+
// Two user-facing MCP tools (stop_node, delete_node) + two daemon-
|
|
2482
|
+
// facing tools (get_stop_request, ack_stop_request). State machine
|
|
2483
|
+
// per §2.3 — nodes.lifecycle_state ∈ {active, stopping, stopped,
|
|
2484
|
+
// deleting}. row-gone implies deleted. Security per §4:
|
|
2485
|
+
// §4.1 SEC-1: trust-root SQL join, not getNetworkId
|
|
2486
|
+
// §4.2 D6: delete_node refuses target.role==host_supervisor
|
|
2487
|
+
// §4.3 D4: in-flight inbox default-refuse + force+audit
|
|
2488
|
+
// §4.4 D7: daemon writes backup chmod 700, sweeper真删
|
|
2489
|
+
// §4.5 D8: audit_log in same SQLite tx as state UPDATE
|
|
2490
|
+
//
|
|
2491
|
+
// The dispatcher logic shared by stop_node + delete_node lives in
|
|
2492
|
+
// dispatchStopOrDelete; the two tool handlers thin-wrap it with
|
|
2493
|
+
// their respective action discriminator.
|
|
2494
|
+
type DispatchAction = "stop" | "delete";
|
|
2495
|
+
type DispatchArgs = {
|
|
2496
|
+
action: DispatchAction;
|
|
2497
|
+
child_node_id: string;
|
|
2498
|
+
daemon_node_id: string;
|
|
2499
|
+
force: boolean;
|
|
2500
|
+
grace_seconds: number;
|
|
2501
|
+
delete_config: boolean;
|
|
2502
|
+
confirm_alias?: string;
|
|
2503
|
+
};
|
|
2504
|
+
// RFC-027 PR2 prereq — auto-resolve daemon_node_id from child_node_id.
|
|
2505
|
+
// The dashboard (and most callers of stop_node / delete_node) shouldn't
|
|
2506
|
+
// need to track the daemon→child mapping themselves; the hub has it on
|
|
2507
|
+
// node_create_requests at child creation time. Returns null when no
|
|
2508
|
+
// creation record exists (orphan node row OR pre-RFC-026 node) — caller
|
|
2509
|
+
// must then pass daemon_node_id explicitly. node_id derivation:
|
|
2510
|
+
// `node_${request_id.replace(/^cr_/,"")}` (see create-node-daemon.ts
|
|
2511
|
+
// and PR1 BLOCKER-1 fix), so we reverse it: `cr_${node_id.slice(5)}`.
|
|
2512
|
+
const resolveDaemonForChild = (child_node_id: string): string | null => {
|
|
2513
|
+
if (!child_node_id.startsWith("node_")) return null;
|
|
2514
|
+
const requestId = `cr_${child_node_id.slice(5)}`;
|
|
2515
|
+
const row = db.get<{ daemon_node_id: string }>(
|
|
2516
|
+
`SELECT daemon_node_id FROM node_create_requests WHERE request_id = ?1`,
|
|
2517
|
+
requestId,
|
|
2518
|
+
);
|
|
2519
|
+
return row?.daemon_node_id ?? null;
|
|
2520
|
+
};
|
|
2521
|
+
|
|
2522
|
+
const dispatchStopOrDelete = (args: DispatchArgs, clientNetId?: string | null) => {
|
|
2523
|
+
// §4.1 — SEC-1 trust-root join: resolve target node row WITHIN the
|
|
2524
|
+
// caller's scope. resolveReadScope returns 'denied' if the caller
|
|
2525
|
+
// doesn't belong to clientNetId; for the row-existence test we
|
|
2526
|
+
// re-join with member's networks so an attacker can't probe by
|
|
2527
|
+
// node_id from a network they don't belong to.
|
|
2528
|
+
const scope = resolveReadScope(clientNetId);
|
|
2529
|
+
if (scope.denied) {
|
|
2530
|
+
return { ok: false, error: "forbidden_cross_tenant", message: scope.denied };
|
|
2531
|
+
}
|
|
2532
|
+
const nodeQ: any[] = [args.child_node_id];
|
|
2533
|
+
let nodeSql = `SELECT node_id, alias, network_id, lifecycle_state, config_snapshot, runtimes_supported
|
|
2534
|
+
FROM nodes WHERE node_id = ?1`;
|
|
2535
|
+
nodeSql = addReadScope(nodeSql, nodeQ, scope);
|
|
2536
|
+
const node = db.get<{
|
|
2537
|
+
node_id: string;
|
|
2538
|
+
alias: string;
|
|
2539
|
+
network_id: string;
|
|
2540
|
+
lifecycle_state: string | null;
|
|
2541
|
+
config_snapshot: string | null;
|
|
2542
|
+
}>(nodeSql, ...nodeQ);
|
|
2543
|
+
if (!node) return { ok: false, error: "forbidden_cross_tenant", message: "node not found in your networks" };
|
|
2544
|
+
|
|
2545
|
+
// Caller must hold a write-capable role on the resolved network
|
|
2546
|
+
// (admin/owner/member; viewer denied). canWrite walks getUserNetworkRole.
|
|
2547
|
+
if (!canWrite(node.network_id)) {
|
|
2548
|
+
return { ok: false, error: "permission_denied", message: "viewer role cannot stop/delete nodes" };
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
// §4.2 D6 — delete refuses targets whose role is host_supervisor.
|
|
2552
|
+
// Stop is allowed against any node per RFC table (host_supervisor's
|
|
2553
|
+
// stop is functionally a no-op anyway: the daemon's children_map
|
|
2554
|
+
// only tracks its child nodes, never the daemon itself).
|
|
2555
|
+
//
|
|
2556
|
+
// PR1 SF-4 (#345 review) — fail-CLOSED on role read. The first cut
|
|
2557
|
+
// parsed config_snapshot and treated parse-fail / missing-field as
|
|
2558
|
+
// role=null which slipped through the gate. A daemon row with a
|
|
2559
|
+
// corrupt snapshot would then be deletable via delete_node. Fix:
|
|
2560
|
+
// - read role via two independent paths (snapshot.role + the
|
|
2561
|
+
// indexed `runtimes_supported` column whose presence ≈ daemon)
|
|
2562
|
+
// - if EITHER path indicates host_supervisor → refuse
|
|
2563
|
+
// - if snapshot parsed but role missing AND the node has any
|
|
2564
|
+
// daemon-shape evidence (non-null runtimes_supported column,
|
|
2565
|
+
// present in any node_create_requests as daemon_node_id) →
|
|
2566
|
+
// refuse defensively. The defense-in-depth daemon-side
|
|
2567
|
+
// children_map miss is the second layer; the hub gate must
|
|
2568
|
+
// itself fail-closed when role is ambiguous.
|
|
2569
|
+
if (args.action === "delete") {
|
|
2570
|
+
let role: string | null = null;
|
|
2571
|
+
let snapshotParseFailed = false;
|
|
2572
|
+
try {
|
|
2573
|
+
const snap = node.config_snapshot ? JSON.parse(node.config_snapshot) : null;
|
|
2574
|
+
role = typeof snap?.role === "string" ? snap.role : null;
|
|
2575
|
+
} catch { snapshotParseFailed = true; }
|
|
2576
|
+
const ambiguous = snapshotParseFailed || (role == null);
|
|
2577
|
+
// Independent corroborating check: nodes flagged as daemons by
|
|
2578
|
+
// create_node dispatchers will show up as daemon_node_id in
|
|
2579
|
+
// node_create_requests. Costs one indexed query.
|
|
2580
|
+
let looksLikeDaemon = false;
|
|
2581
|
+
if (ambiguous) {
|
|
2582
|
+
const m = db.get<{ n: number }>(
|
|
2583
|
+
`SELECT COUNT(*) AS n FROM node_create_requests WHERE daemon_node_id = ?1`,
|
|
2584
|
+
node.node_id,
|
|
2585
|
+
);
|
|
2586
|
+
if ((m?.n ?? 0) > 0) looksLikeDaemon = true;
|
|
2587
|
+
}
|
|
2588
|
+
if (role === "host_supervisor" || (ambiguous && looksLikeDaemon)) {
|
|
2589
|
+
return {
|
|
2590
|
+
ok: false, error: "cannot_delete_daemon_via_delete_node",
|
|
2591
|
+
message: snapshotParseFailed
|
|
2592
|
+
? "node config_snapshot unreadable; node is referenced as a daemon — refuse delete defensively (use delete_daemon path)"
|
|
2593
|
+
: "host_supervisor daemons must be removed via the dedicated delete_daemon path (RFC-027.5)",
|
|
2594
|
+
};
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
// delete_node confirm_alias gate: dashboard's二次确认 input must
|
|
2599
|
+
// match the actual alias byte-for-byte. Hub rejects mismatched
|
|
2600
|
+
// input even if dashboard UI claims it's disabled.
|
|
2601
|
+
if (args.action === "delete") {
|
|
2602
|
+
if (typeof args.confirm_alias !== "string" || args.confirm_alias !== node.alias) {
|
|
2603
|
+
return { ok: false, error: "confirm_alias_mismatch", message: "confirm_alias must equal the node's alias" };
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
// Daemon must exist + be in the same network. We do NOT enforce
|
|
2608
|
+
// daemon.role==host_supervisor here: a child whose creator daemon
|
|
2609
|
+
// got demoted should still be stoppable. The daemon will refuse
|
|
2610
|
+
// with noop_not_my_child if children_map doesn't know it.
|
|
2611
|
+
//
|
|
2612
|
+
// PR1.2 e2e catch (BLOCKER): pushEvent keys SSE by clientKey ≈
|
|
2613
|
+
// `${networkId}:${sessionName}`, and agent-node registers its SSE
|
|
2614
|
+
// connection under its ALIAS (not its node_id). The create_node
|
|
2615
|
+
// doorbell at line ~2197 correctly pushes via `daemon.alias`;
|
|
2616
|
+
// stop_node was pushing via `daemon_node_id` so every doorbell
|
|
2617
|
+
// missed every SSE client → daemon never woke up → request_id
|
|
2618
|
+
// stayed 'pending' forever → child never reaped. The 24 unit tests
|
|
2619
|
+
// in stop-delete-node.test.ts all mock pushEvent and inject the
|
|
2620
|
+
// post-doorbell handler call directly, so single-unit coverage
|
|
2621
|
+
// couldn't surface this — exactly the failure class 通信龙 said
|
|
2622
|
+
// the docker e2e gate exists to catch (BLOCKER-1 同源). Load the
|
|
2623
|
+
// alias along with node_id + network_id and dispatch to the alias.
|
|
2624
|
+
const daemon = db.get<{ node_id: string; alias: string; network_id: string }>(
|
|
2625
|
+
`SELECT node_id, alias, network_id FROM nodes WHERE node_id = ?1`, args.daemon_node_id,
|
|
2626
|
+
);
|
|
2627
|
+
if (!daemon) return { ok: false, error: "daemon_not_found" };
|
|
2628
|
+
if (daemon.network_id !== node.network_id) {
|
|
2629
|
+
// Cross-tenant child↔daemon mismatch shouldn't be possible in
|
|
2630
|
+
// normal flow but is a SEC-1 hardening — refuse loud.
|
|
2631
|
+
return { ok: false, error: "daemon_cross_tenant", message: "daemon and child are in different networks" };
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
// State machine gate.
|
|
2635
|
+
const state = node.lifecycle_state ?? "active";
|
|
2636
|
+
if (args.action === "stop" && state !== "active") {
|
|
2637
|
+
return { ok: false, error: state === "stopping" ? "node_already_stopping" : "node_not_active", current_state: state };
|
|
2638
|
+
}
|
|
2639
|
+
if (args.action === "delete" && state === "deleting") {
|
|
2640
|
+
return { ok: false, error: "node_already_deleting", current_state: state };
|
|
2641
|
+
}
|
|
2642
|
+
if (args.action === "delete" && state === "stopping") {
|
|
2643
|
+
// Can't start delete while a stop is mid-flight — race-prone.
|
|
2644
|
+
return { ok: false, error: "node_stopping_in_progress", current_state: state };
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
// §4.3 D4 — in-flight inbox check. Count unacked tasks routed to
|
|
2648
|
+
// this node's alias (inbox routing uses session_name == alias).
|
|
2649
|
+
// Default refuse with the count surfaced; force=true overrides
|
|
2650
|
+
// and triggers the forced_stop_with_in_flight audit row.
|
|
2651
|
+
// SF-5 (#345 review): legacy inbox rows may have network_id=NULL
|
|
2652
|
+
// and would silently miss a network_id=?2 strict equality match.
|
|
2653
|
+
// Use COALESCE so a NULL inbox row is counted against the same
|
|
2654
|
+
// network as the target node (which is the only safe default
|
|
2655
|
+
// — pre-multi-network rows existed in single-network mode).
|
|
2656
|
+
const inFlightRow = db.get<{ n: number }>(
|
|
2657
|
+
`SELECT COUNT(*) AS n FROM inbox WHERE session_name = ?1 AND acked = 0
|
|
2658
|
+
AND COALESCE(network_id, ?2) = ?2`,
|
|
2659
|
+
node.alias, node.network_id,
|
|
2660
|
+
);
|
|
2661
|
+
const inFlight = inFlightRow?.n ?? 0;
|
|
2662
|
+
if (inFlight > 0 && !args.force) {
|
|
2663
|
+
return { ok: false, error: "node_busy_in_flight", in_flight_count: inFlight, hint: "set force=true to override (audit logged)" };
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
// ── all gates passed; create request + transition state + push doorbell, transactionally ──
|
|
2667
|
+
const requestId = generateId("sr");
|
|
2668
|
+
const now = Date.now();
|
|
2669
|
+
const newState = args.action === "stop" ? "stopping" : "deleting";
|
|
2670
|
+
|
|
2671
|
+
// §4.5 D8 — lifecycle UPDATE + audit INSERTs + request INSERT atomic.
|
|
2672
|
+
// PR1.1: use db.transaction() (SQLiteAdapter wraps better-sqlite3's
|
|
2673
|
+
// native transaction; PgAdapter ships a real BEGIN/COMMIT/ROLLBACK
|
|
2674
|
+
// shim). Lets us drop the open-coded BEGIN/COMMIT + makes the
|
|
2675
|
+
// SF-2 failure-injection test (mock db.run throw inside callback)
|
|
2676
|
+
// reliable across both backends.
|
|
2677
|
+
try {
|
|
2678
|
+
db.transaction(() => {
|
|
2679
|
+
db.run(
|
|
2680
|
+
`INSERT INTO node_stop_requests
|
|
2681
|
+
(request_id, network_id, daemon_node_id, child_node_id, child_alias, action,
|
|
2682
|
+
delete_config, grace_seconds, force, in_flight_at_dispatch, created_by_token,
|
|
2683
|
+
status, created_at)
|
|
2684
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'pending', ?12)`,
|
|
2685
|
+
[
|
|
2686
|
+
requestId, node.network_id, args.daemon_node_id, node.node_id, node.alias, args.action,
|
|
2687
|
+
args.delete_config ? 1 : 0, args.grace_seconds, args.force ? 1 : 0, inFlight,
|
|
2688
|
+
callerTokenId || "unknown", now,
|
|
2689
|
+
],
|
|
2690
|
+
);
|
|
2691
|
+
db.run(
|
|
2692
|
+
`UPDATE nodes SET lifecycle_state = ?1 WHERE node_id = ?2`,
|
|
2693
|
+
[newState, node.node_id],
|
|
2694
|
+
);
|
|
2695
|
+
// §4.5 D8 — audit dispatch action. Uses the STRICT variant so a
|
|
2696
|
+
// failed audit INSERT propagates and triggers ROLLBACK (PR1 SF-2
|
|
2697
|
+
// review catch — auditCreateNode's swallow-and-warn would have
|
|
2698
|
+
// committed the lifecycle UPDATE without an audit row, leaving
|
|
2699
|
+
// exactly the "deleted but no audit" window §4.5 closes).
|
|
2700
|
+
auditCreateNodeStrict({
|
|
2701
|
+
action: args.action === "stop" ? "stop_node_dispatched" : "delete_node_dispatched",
|
|
2702
|
+
user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
|
|
2703
|
+
detail: {
|
|
2704
|
+
child_node_id: node.node_id, child_alias: node.alias,
|
|
2705
|
+
daemon_node_id: args.daemon_node_id, action: args.action,
|
|
2706
|
+
force: args.force, delete_config: args.delete_config,
|
|
2707
|
+
grace_seconds: args.grace_seconds, in_flight_at_dispatch: inFlight,
|
|
2708
|
+
lifecycle_state_before: state, lifecycle_state_after: newState,
|
|
2709
|
+
ts_request: now,
|
|
2710
|
+
},
|
|
2711
|
+
});
|
|
2712
|
+
if (args.force && inFlight > 0) {
|
|
2713
|
+
auditCreateNodeStrict({
|
|
2714
|
+
action: "forced_stop_with_in_flight",
|
|
2715
|
+
user_id: enforceUserId, network_id: node.network_id, target_id: requestId,
|
|
2716
|
+
detail: { in_flight_count: inFlight, child_alias: node.alias },
|
|
2717
|
+
});
|
|
2718
|
+
}
|
|
2719
|
+
}); // end db.transaction
|
|
2720
|
+
} catch (e: any) {
|
|
2721
|
+
return { ok: false, error: "dispatch_tx_failed", message: e?.message || String(e) };
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
// SSE doorbell — daemon will pull via get_stop_request.
|
|
2725
|
+
// PR1.2 e2e fix: route by daemon.alias (see SELECT above for the
|
|
2726
|
+
// BLOCKER explanation). Mirrors create_node's pushEvent target at
|
|
2727
|
+
// line ~2197.
|
|
2728
|
+
pushEvent(daemon.alias, { type: "stop_node", request_id: requestId }, node.network_id);
|
|
2729
|
+
return {
|
|
2730
|
+
ok: true, request_id: requestId, action: args.action,
|
|
2731
|
+
lifecycle_state: newState, in_flight_at_dispatch: inFlight,
|
|
2732
|
+
};
|
|
2733
|
+
};
|
|
2734
|
+
|
|
2735
|
+
server.tool(
|
|
2736
|
+
"stop_node",
|
|
2737
|
+
"Stop the agent-node child process; keep config dir intact. Reversible via restart_node. RFC-027 §2.2. daemon_node_id is auto-resolved from child_node_id when omitted (looked up in node_create_requests).",
|
|
2738
|
+
{
|
|
2739
|
+
child_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/),
|
|
2740
|
+
// PR2 prereq: dashboard rarely knows daemon_node_id directly.
|
|
2741
|
+
// When omitted, hub resolves from the original creation record
|
|
2742
|
+
// (node_create_requests.daemon_node_id keyed by this child).
|
|
2743
|
+
daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
|
|
2744
|
+
force: z.boolean().optional().default(false),
|
|
2745
|
+
grace_seconds: z.number().int().min(5).max(60).optional().default(10),
|
|
2746
|
+
network_id: z.string().max(200).optional(),
|
|
2747
|
+
},
|
|
2748
|
+
async ({ child_node_id, daemon_node_id, force, grace_seconds, network_id: clientNetId }) => {
|
|
2749
|
+
const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
|
|
2750
|
+
if (!resolved) {
|
|
2751
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
2752
|
+
ok: false, error: "daemon_not_resolvable",
|
|
2753
|
+
message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
|
|
2754
|
+
}) }] };
|
|
2755
|
+
}
|
|
2756
|
+
const r = dispatchStopOrDelete(
|
|
2757
|
+
{ action: "stop", child_node_id, daemon_node_id: resolved, force: force ?? false,
|
|
2758
|
+
grace_seconds: grace_seconds ?? 10, delete_config: false },
|
|
2759
|
+
clientNetId,
|
|
2760
|
+
);
|
|
2761
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
|
|
2762
|
+
},
|
|
2763
|
+
);
|
|
2764
|
+
|
|
2765
|
+
server.tool(
|
|
2766
|
+
"delete_node",
|
|
2767
|
+
"Stop child + revoke ntok + delete hub row + (default) backup config to ~/.anet/deleted/<ts>-<alias>/ for 30d. confirm_alias must equal the node's alias. daemon_node_id is auto-resolved when omitted. RFC-027 §2.2.",
|
|
2768
|
+
{
|
|
2769
|
+
child_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/),
|
|
2770
|
+
daemon_node_id: z.string().min(1).max(200).regex(/^node_[a-z0-9_-]+$/).optional(),
|
|
2771
|
+
confirm_alias: z.string().min(1).max(200),
|
|
2772
|
+
force: z.boolean().optional().default(false),
|
|
2773
|
+
grace_seconds: z.number().int().min(5).max(60).optional().default(10),
|
|
2774
|
+
delete_config: z.boolean().optional().default(true),
|
|
2775
|
+
network_id: z.string().max(200).optional(),
|
|
2776
|
+
},
|
|
2777
|
+
async ({ child_node_id, daemon_node_id, confirm_alias, force, grace_seconds, delete_config, network_id: clientNetId }) => {
|
|
2778
|
+
const resolved = daemon_node_id ?? resolveDaemonForChild(child_node_id);
|
|
2779
|
+
if (!resolved) {
|
|
2780
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
2781
|
+
ok: false, error: "daemon_not_resolvable",
|
|
2782
|
+
message: "no node_create_requests row found for this child_node_id; pass daemon_node_id explicitly",
|
|
2783
|
+
}) }] };
|
|
2784
|
+
}
|
|
2785
|
+
const r = dispatchStopOrDelete(
|
|
2786
|
+
{ action: "delete", child_node_id, daemon_node_id: resolved, force: force ?? false,
|
|
2787
|
+
grace_seconds: grace_seconds ?? 10, delete_config: delete_config ?? true, confirm_alias },
|
|
2788
|
+
clientNetId,
|
|
2789
|
+
);
|
|
2790
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
|
|
2791
|
+
},
|
|
2792
|
+
);
|
|
2793
|
+
|
|
2794
|
+
server.tool(
|
|
2795
|
+
"get_stop_request",
|
|
2796
|
+
"Daemon pulls a pending stop/delete request (called when SSE stop_node doorbell arrives). RFC-027 §2.4.",
|
|
2797
|
+
{
|
|
2798
|
+
request_id: z.string().min(1).max(200),
|
|
2799
|
+
},
|
|
2800
|
+
async ({ request_id }) => {
|
|
2801
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
2802
|
+
if (!callerDaemon.ok) {
|
|
2803
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
2804
|
+
}
|
|
2805
|
+
const row = db.get<{
|
|
2806
|
+
daemon_node_id: string; status: string; network_id: string;
|
|
2807
|
+
child_node_id: string; child_alias: string; action: string;
|
|
2808
|
+
delete_config: number; grace_seconds: number; force: number;
|
|
2809
|
+
}>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, action,
|
|
2810
|
+
delete_config, grace_seconds, force
|
|
2811
|
+
FROM node_stop_requests WHERE request_id = ?1`, request_id);
|
|
2812
|
+
if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
2813
|
+
if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
|
|
2814
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
2815
|
+
}
|
|
2816
|
+
if (row.network_id !== callerDaemon.networkId) {
|
|
2817
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
|
|
2818
|
+
}
|
|
2819
|
+
// Stamp delivered_at on first pull (idempotent — only if still pending).
|
|
2820
|
+
db.run(
|
|
2821
|
+
`UPDATE node_stop_requests SET status = 'delivered', delivered_at = ?1
|
|
2822
|
+
WHERE request_id = ?2 AND status = 'pending'`,
|
|
2823
|
+
[Date.now(), request_id],
|
|
2824
|
+
);
|
|
2825
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
2826
|
+
ok: true,
|
|
2827
|
+
request_id,
|
|
2828
|
+
child_node_id: row.child_node_id,
|
|
2829
|
+
child_alias: row.child_alias,
|
|
2830
|
+
action: row.action,
|
|
2831
|
+
delete_config: row.delete_config === 1,
|
|
2832
|
+
grace_seconds: row.grace_seconds,
|
|
2833
|
+
force: row.force === 1,
|
|
2834
|
+
}) }] };
|
|
2835
|
+
},
|
|
2836
|
+
);
|
|
2837
|
+
|
|
2838
|
+
server.tool(
|
|
2839
|
+
"ack_stop_request",
|
|
2840
|
+
"Daemon reports stop/delete completion (or per-status failure). On 'stopped' status finalizes the lifecycle: stop→stopped + keep config; delete→DB row gone + revoke ntok. RFC-027 §2.3 + §4.5.",
|
|
2841
|
+
{
|
|
2842
|
+
request_id: z.string().min(1).max(200),
|
|
2843
|
+
status: z.enum(["stopped", "stop_failed", "noop_not_my_child"]),
|
|
2844
|
+
exit_signal: z.string().max(16).optional(),
|
|
2845
|
+
backup_path: z.string().max(500).optional(),
|
|
2846
|
+
error: z.string().max(1000).optional(),
|
|
2847
|
+
},
|
|
2848
|
+
async ({ request_id, status, exit_signal, backup_path, error: ackError }) => {
|
|
2849
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
2850
|
+
if (!callerDaemon.ok) {
|
|
2851
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
2852
|
+
}
|
|
2853
|
+
const row = db.get<{
|
|
2854
|
+
daemon_node_id: string; status: string; network_id: string;
|
|
2855
|
+
child_node_id: string; child_alias: string; action: string;
|
|
2856
|
+
in_flight_at_dispatch: number; force: number;
|
|
2857
|
+
}>(`SELECT daemon_node_id, status, network_id, child_node_id, child_alias, action,
|
|
2858
|
+
in_flight_at_dispatch, force
|
|
2859
|
+
FROM node_stop_requests WHERE request_id = ?1`, request_id);
|
|
2860
|
+
if (!row) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "request_not_found" }) }] };
|
|
2861
|
+
if (row.daemon_node_id !== callerDaemon.daemonNodeId) {
|
|
2862
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "not_your_request" }) }] };
|
|
2863
|
+
}
|
|
2864
|
+
if (row.network_id !== callerDaemon.networkId) {
|
|
2865
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_request" }) }] };
|
|
2866
|
+
}
|
|
2867
|
+
const now = Date.now();
|
|
2868
|
+
|
|
2869
|
+
// §4.5 D8 — lifecycle UPDATE + request UPDATE + audit INSERT
|
|
2870
|
+
// (+ DELETE for delete action) atomic via db.transaction().
|
|
2871
|
+
// PR1.1: replaces hand-rolled BEGIN..COMMIT so PG adapter and
|
|
2872
|
+
// SQLite both get correct rollback semantics from one code path.
|
|
2873
|
+
try {
|
|
2874
|
+
db.transaction(() => {
|
|
2875
|
+
db.run(
|
|
2876
|
+
`UPDATE node_stop_requests SET status = ?1, error = ?2, exit_signal = ?3,
|
|
2877
|
+
backup_path = ?4, acked_at = ?5
|
|
2878
|
+
WHERE request_id = ?6`,
|
|
2879
|
+
[status, ackError || null, exit_signal || null, backup_path || null, now, request_id],
|
|
2880
|
+
);
|
|
2881
|
+
if (status === "stopped" && row.action === "stop") {
|
|
2882
|
+
db.run(`UPDATE nodes SET lifecycle_state = 'stopped' WHERE node_id = ?1`, [row.child_node_id]);
|
|
2883
|
+
auditCreateNodeStrict({
|
|
2884
|
+
action: "stop_node_completed",
|
|
2885
|
+
user_id: null, network_id: row.network_id, target_id: request_id,
|
|
2886
|
+
detail: {
|
|
2887
|
+
child_node_id: row.child_node_id, child_alias: row.child_alias,
|
|
2888
|
+
exit_signal: exit_signal || null, ts_daemon_ack: now,
|
|
2889
|
+
lifecycle_state_after: "stopped",
|
|
2890
|
+
},
|
|
2891
|
+
});
|
|
2892
|
+
} else if (status === "stopped" && row.action === "delete") {
|
|
2893
|
+
// Revoke the child's ntok (name='node:<alias>') in the daemon's
|
|
2894
|
+
// network, then DELETE the nodes row. Token revoke pattern mirrors
|
|
2895
|
+
// ack_create_request's terminal path.
|
|
2896
|
+
db.run(
|
|
2897
|
+
`UPDATE api_tokens SET revoked_at = datetime('now')
|
|
2898
|
+
WHERE network_id = ?1 AND name = ?2 AND revoked_at IS NULL`,
|
|
2899
|
+
[row.network_id, `node:${row.child_alias}`],
|
|
2900
|
+
);
|
|
2901
|
+
db.run(`DELETE FROM nodes WHERE node_id = ?1`, [row.child_node_id]);
|
|
2902
|
+
auditCreateNodeStrict({
|
|
2903
|
+
action: "delete_node_completed",
|
|
2904
|
+
user_id: null, network_id: row.network_id, target_id: request_id,
|
|
2905
|
+
detail: {
|
|
2906
|
+
child_node_id: row.child_node_id, child_alias: row.child_alias,
|
|
2907
|
+
exit_signal: exit_signal || null, backup_path: backup_path || null,
|
|
2908
|
+
ts_daemon_ack: now, lifecycle_state_after: "deleted",
|
|
2909
|
+
},
|
|
2910
|
+
});
|
|
2911
|
+
} else if (status === "stop_failed") {
|
|
2912
|
+
db.run(`UPDATE nodes SET lifecycle_state = 'stop_failed' WHERE node_id = ?1`, [row.child_node_id]);
|
|
2913
|
+
// No completion audit; the failure error is on the request row.
|
|
2914
|
+
}
|
|
2915
|
+
// noop_not_my_child: leave lifecycle_state as-is; the hub-side
|
|
2916
|
+
// sweeper / next reconciliation will pick it up. Don't transition.
|
|
2917
|
+
});
|
|
2918
|
+
} catch (e: any) {
|
|
2919
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "finalize_tx_failed", message: e?.message || String(e) }) }] };
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
|
|
2923
|
+
},
|
|
2924
|
+
);
|
|
2925
|
+
|
|
2926
|
+
// RFC-027 PR1.1 — list_my_children: daemon-only query. Returns the
|
|
2927
|
+
// {child_node_id, alias, lifecycle_state} tuples for nodes whose
|
|
2928
|
+
// active create-request has this daemon as daemon_node_id. Used at
|
|
2929
|
+
// daemon boot to rebuild the in-memory childrenMap (PR1 dropped
|
|
2930
|
+
// every entry on daemon restart → stop/delete silently no-op'd).
|
|
2931
|
+
//
|
|
2932
|
+
// Network-scope is the daemon's own (resolveCallerDaemonTokenBound
|
|
2933
|
+
// already binds tokenIsNetwork to a single network). No SEC-1 leak:
|
|
2934
|
+
// returns ONLY children whose request landed under this daemon's
|
|
2935
|
+
// token + network.
|
|
2936
|
+
server.tool(
|
|
2937
|
+
"list_my_children",
|
|
2938
|
+
"Daemon pulls the alias + node_id list of children it spawned (for childrenMap rebuild after restart). RFC-027 PR1.1.",
|
|
2939
|
+
{},
|
|
2940
|
+
async () => {
|
|
2941
|
+
const callerDaemon = resolveCallerDaemonTokenBound();
|
|
2942
|
+
if (!callerDaemon.ok) {
|
|
2943
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
2944
|
+
}
|
|
2945
|
+
// node_create_requests carries the canonical authoritative
|
|
2946
|
+
// child→daemon mapping (the row id IS the request, and the
|
|
2947
|
+
// child node_id derives from it deterministically via
|
|
2948
|
+
// `node_${request_id.replace(/^cr_/, "")}`). Filter to children
|
|
2949
|
+
// that completed registration (have a nodes row) so we don't
|
|
2950
|
+
// ask the daemon to pgrep for children that never came up.
|
|
2951
|
+
const rows = db.all<{ request_id: string; child_name: string; child_node_id: string; lifecycle_state: string | null }>(
|
|
2952
|
+
`SELECT ncr.request_id, ncr.child_name,
|
|
2953
|
+
('node_' || substr(ncr.request_id, 4)) AS child_node_id,
|
|
2954
|
+
n.lifecycle_state
|
|
2955
|
+
FROM node_create_requests ncr
|
|
2956
|
+
LEFT JOIN nodes n ON n.node_id = ('node_' || substr(ncr.request_id, 4))
|
|
2957
|
+
WHERE ncr.daemon_node_id = ?1
|
|
2958
|
+
AND ncr.network_id = ?2
|
|
2959
|
+
AND ncr.status IN ('succeeded', 'delivered')
|
|
2960
|
+
AND n.node_id IS NOT NULL
|
|
2961
|
+
AND COALESCE(n.lifecycle_state, 'active') NOT IN ('stopped', 'stop_failed')`,
|
|
2962
|
+
callerDaemon.daemonNodeId, callerDaemon.networkId,
|
|
2963
|
+
);
|
|
2964
|
+
const children = rows.map(r => ({
|
|
2965
|
+
child_node_id: r.child_node_id,
|
|
2966
|
+
alias: r.child_name,
|
|
2967
|
+
lifecycle_state: r.lifecycle_state ?? "active",
|
|
2968
|
+
}));
|
|
2969
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, count: children.length, children }) }] };
|
|
2970
|
+
},
|
|
2971
|
+
);
|
|
2972
|
+
|
|
2973
|
+
// ── RFC-028 P1 — Provider & Model Registry + connectivity probe ──
|
|
2974
|
+
// Background timers (probe GC + sweeper) idempotent; safe to call
|
|
2975
|
+
// per registerTools.
|
|
2976
|
+
startPendingProbeGcTimer();
|
|
2977
|
+
startProbeSweeperTimer();
|
|
2978
|
+
|
|
2979
|
+
// Helper: zod to JSON-RPC reply for ProbeValidationError + VaultError
|
|
2980
|
+
const probeFailReply = (e: unknown) => {
|
|
2981
|
+
if (e instanceof ProbeValidationError) {
|
|
2982
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: e.code, ...(e.detail || {}) }) }] };
|
|
2983
|
+
}
|
|
2984
|
+
if (e instanceof VaultError) {
|
|
2985
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: e.code, message: e.message }) }] };
|
|
2986
|
+
}
|
|
2987
|
+
throw e;
|
|
2988
|
+
};
|
|
2989
|
+
|
|
2990
|
+
// §2.3.4 — upsert_network_secret (OWNER-ONLY; vault write).
|
|
2991
|
+
server.tool(
|
|
2992
|
+
"upsert_network_secret",
|
|
2993
|
+
"Write or replace a secret value in the network's vault (AES-GCM encrypted at rest). OWNER-only. RFC-028.",
|
|
2994
|
+
{
|
|
2995
|
+
key: z.string().min(1).max(64).regex(/^[A-Z][A-Z0-9_]{0,63}$/),
|
|
2996
|
+
value: z.string().min(1).max(16 * 1024),
|
|
2997
|
+
network_id: z.string().max(200).optional(),
|
|
2998
|
+
},
|
|
2999
|
+
async ({ key, value, network_id: clientNetId }) => {
|
|
3000
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
3001
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
3002
|
+
const callerRole = enforceUserId && effectiveNetId
|
|
3003
|
+
? getUserNetworkRole(enforceUserId, effectiveNetId)
|
|
3004
|
+
: null;
|
|
3005
|
+
if (callerRole !== "owner") {
|
|
3006
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "secret_owner_only", required_role: "owner", caller_role: callerRole }) }] };
|
|
3007
|
+
}
|
|
3008
|
+
try {
|
|
3009
|
+
vaultUpsert(effectiveNetId || "default", key, value);
|
|
3010
|
+
auditCreateNode({
|
|
3011
|
+
action: "create_node_dispatched", // reusing audit_log shape (provider follow-up logs)
|
|
3012
|
+
user_id: enforceUserId, network_id: effectiveNetId, target_id: null,
|
|
3013
|
+
detail: { op: "vault_upsert", key, network_id: effectiveNetId },
|
|
3014
|
+
});
|
|
3015
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, key }) }] };
|
|
3016
|
+
} catch (e) {
|
|
3017
|
+
return probeFailReply(e);
|
|
3018
|
+
}
|
|
3019
|
+
},
|
|
3020
|
+
);
|
|
3021
|
+
|
|
3022
|
+
// §2.3.4b — list_network_secrets (key names only; viewer+).
|
|
3023
|
+
server.tool(
|
|
3024
|
+
"list_network_secrets",
|
|
3025
|
+
"List vault key NAMES (NEVER values) for a network. RFC-028.",
|
|
3026
|
+
{ network_id: z.string().max(200).optional() },
|
|
3027
|
+
async ({ network_id: clientNetId }) => {
|
|
3028
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
3029
|
+
if (!canWrite(effectiveNetId) && enforceUserId) {
|
|
3030
|
+
// viewer-level: allow if caller has any role in network
|
|
3031
|
+
const role = getUserNetworkRole(enforceUserId, effectiveNetId || "default");
|
|
3032
|
+
if (!role) return writeDeniedReply(effectiveNetId, "read");
|
|
3033
|
+
}
|
|
3034
|
+
const keys = vaultListKeys(effectiveNetId || "default");
|
|
3035
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, keys }) }] };
|
|
3036
|
+
},
|
|
3037
|
+
);
|
|
3038
|
+
|
|
3039
|
+
// §2.3.1 — upsert_provider (admin+).
|
|
3040
|
+
server.tool(
|
|
3041
|
+
"upsert_provider",
|
|
3042
|
+
"Create or update a provider (vendor + base_url + secret_key_ref + initial models). Admin+. RFC-028.",
|
|
3043
|
+
{
|
|
3044
|
+
name: z.string().min(1).max(100),
|
|
3045
|
+
vendor: z.string().min(1).max(64),
|
|
3046
|
+
base_url: z.string().min(1).max(500),
|
|
3047
|
+
secret_key_ref: z.string().min(1).max(64),
|
|
3048
|
+
models: z.array(z.object({
|
|
3049
|
+
model_name: z.string().min(1).max(100),
|
|
3050
|
+
display_name: z.string().max(100).optional(),
|
|
3051
|
+
context_window: z.number().int().min(0).optional(),
|
|
3052
|
+
supports_vision: z.boolean().optional(),
|
|
3053
|
+
})).optional(),
|
|
3054
|
+
network_id: z.string().max(200).optional(),
|
|
3055
|
+
},
|
|
3056
|
+
async ({ name, vendor, base_url, secret_key_ref, models, network_id: clientNetId }) => {
|
|
3057
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
3058
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
3059
|
+
const callerRole = enforceUserId && effectiveNetId
|
|
3060
|
+
? getUserNetworkRole(enforceUserId, effectiveNetId)
|
|
3061
|
+
: null;
|
|
3062
|
+
if (callerRole !== "admin" && callerRole !== "owner") {
|
|
3063
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_provider", required_role: "admin", caller_role: callerRole }) }] };
|
|
3064
|
+
}
|
|
3065
|
+
try {
|
|
3066
|
+
_validateBaseUrl(vendor, base_url);
|
|
3067
|
+
} catch (e) {
|
|
3068
|
+
return probeFailReply(e);
|
|
3069
|
+
}
|
|
3070
|
+
// Verify vault key exists (best-effort; just lists names)
|
|
3071
|
+
const vaultKeys = vaultListKeys(effectiveNetId || "default");
|
|
3072
|
+
if (!vaultKeys.includes(secret_key_ref)) {
|
|
3073
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "secret_not_in_vault", key: secret_key_ref, hint: "upsert_network_secret first" }) }] };
|
|
3074
|
+
}
|
|
3075
|
+
const providerId = `prov_${uuidv4()}`;
|
|
3076
|
+
try {
|
|
3077
|
+
db.run(
|
|
3078
|
+
`INSERT INTO providers (provider_id, network_id, name, vendor, base_url, secret_key_ref, created_at, created_by, enabled)
|
|
3079
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)`,
|
|
3080
|
+
[providerId, effectiveNetId || "default", name, vendor, base_url, secret_key_ref, Date.now(), enforceUserId || "unknown"],
|
|
3081
|
+
);
|
|
3082
|
+
} catch (e: any) {
|
|
3083
|
+
if (/UNIQUE constraint failed/.test(e?.message || "")) {
|
|
3084
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_name_conflict", name }) }] };
|
|
3085
|
+
}
|
|
3086
|
+
throw e;
|
|
3087
|
+
}
|
|
3088
|
+
const modelIds: string[] = [];
|
|
3089
|
+
if (models) {
|
|
3090
|
+
for (const m of models) {
|
|
3091
|
+
const mid = `pm_${uuidv4()}`;
|
|
3092
|
+
db.run(
|
|
3093
|
+
`INSERT INTO provider_models (model_id, provider_id, model_name, display_name, context_window, supports_vision, enabled, created_at)
|
|
3094
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)`,
|
|
3095
|
+
[mid, providerId, m.model_name, m.display_name ?? null, m.context_window ?? null, m.supports_vision ? 1 : 0, Date.now()],
|
|
3096
|
+
);
|
|
3097
|
+
modelIds.push(mid);
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id: providerId, model_ids: modelIds }) }] };
|
|
3101
|
+
},
|
|
3102
|
+
);
|
|
3103
|
+
|
|
3104
|
+
// §2.3.2 — update_provider (admin+; patch semantics). RFC-028 P1.5.
|
|
3105
|
+
// Single tool for editing existing providers — patch model: name/base_url/
|
|
3106
|
+
// models/enabled all optional, at least one required. Vendor and
|
|
3107
|
+
// secret_key_ref are IMMUTABLE via this tool (vendor change = create+delete;
|
|
3108
|
+
// secret change goes through upsert_network_secret first). network_id is
|
|
3109
|
+
// immutable (cross-tenant move forbidden).
|
|
3110
|
+
//
|
|
3111
|
+
// base_url change re-runs validateBaseUrl with vendor read from DB row
|
|
3112
|
+
// (NOT from patch) — defends against trying to widen host allowlist by
|
|
3113
|
+
// smuggling a new vendor name. zod .strict() on the wrapping object
|
|
3114
|
+
// surfaces extras as -32602 at MCP boundary (R3 lock, same as
|
|
3115
|
+
// ack_probe_request per #308 fold-in).
|
|
3116
|
+
//
|
|
3117
|
+
// Audit: every successful update writes audit_log with before/after diff
|
|
3118
|
+
// of the changed fields ONLY (no secret values — secret isn't a patch
|
|
3119
|
+
// field, no leak path).
|
|
3120
|
+
server.registerTool(
|
|
3121
|
+
"update_provider",
|
|
3122
|
+
{
|
|
3123
|
+
description: "Edit existing provider (name/base_url/models/enabled). Patch semantics — at least one field. Vendor/secret/network_id immutable. Admin+. RFC-028 P1.5.",
|
|
3124
|
+
inputSchema: z.object({
|
|
3125
|
+
provider_id: z.string().regex(/^prov_[a-zA-Z0-9_-]+$/).max(200),
|
|
3126
|
+
network_id: z.string().max(200).optional(),
|
|
3127
|
+
patch: z.object({
|
|
3128
|
+
name: z.string().min(1).max(100).optional(),
|
|
3129
|
+
base_url: z.string().min(1).max(500).optional(),
|
|
3130
|
+
models: z.array(z.object({
|
|
3131
|
+
model_name: z.string().min(1).max(100),
|
|
3132
|
+
display_name: z.string().max(100).optional(),
|
|
3133
|
+
context_window: z.number().int().min(0).optional(),
|
|
3134
|
+
supports_vision: z.boolean().optional(),
|
|
3135
|
+
})).optional(),
|
|
3136
|
+
enabled: z.boolean().optional(),
|
|
3137
|
+
}).strict(),
|
|
3138
|
+
}).strict() as any,
|
|
3139
|
+
},
|
|
3140
|
+
async (args: any) => {
|
|
3141
|
+
const { provider_id, network_id: clientNetId, patch } = args;
|
|
3142
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
3143
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
3144
|
+
const callerRole = enforceUserId && effectiveNetId
|
|
3145
|
+
? getUserNetworkRole(enforceUserId, effectiveNetId)
|
|
3146
|
+
: null;
|
|
3147
|
+
if (callerRole !== "admin" && callerRole !== "owner") {
|
|
3148
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_provider", required_role: "admin", caller_role: callerRole }) }] };
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
// Empty patch — surface noop_no_changes (don't silently succeed)
|
|
3152
|
+
const patchKeys = Object.keys(patch || {});
|
|
3153
|
+
if (patchKeys.length === 0) {
|
|
3154
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "noop_no_changes", hint: "patch must include at least one of: name, base_url, models, enabled" }) }] };
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
// SEC-1: row must exist within caller's network (SQL-level enforcement)
|
|
3158
|
+
const row = db.get<{ provider_id: string; vendor: string; name: string; base_url: string; enabled: number }>(
|
|
3159
|
+
`SELECT provider_id, vendor, name, base_url, enabled FROM providers WHERE provider_id = ?1 AND network_id = ?2`,
|
|
3160
|
+
provider_id, effectiveNetId || "default",
|
|
3161
|
+
);
|
|
3162
|
+
if (!row) {
|
|
3163
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_not_found", provider_id }) }] };
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
// base_url change → re-run validateBaseUrl with vendor from DB (not patch)
|
|
3167
|
+
if (patch.base_url !== undefined && patch.base_url !== row.base_url) {
|
|
3168
|
+
try {
|
|
3169
|
+
_validateBaseUrl(row.vendor, patch.base_url);
|
|
3170
|
+
} catch (e) {
|
|
3171
|
+
return probeFailReply(e);
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
// Diff staging — collect before/after for audit (NO secret values; secret
|
|
3176
|
+
// isn't a patch field). Skip fields that didn't actually change.
|
|
3177
|
+
const diff: Record<string, { before: unknown; after: unknown }> = {};
|
|
3178
|
+
const sets: string[] = [];
|
|
3179
|
+
const params: unknown[] = [];
|
|
3180
|
+
let paramIdx = 1;
|
|
3181
|
+
if (patch.name !== undefined && patch.name !== row.name) {
|
|
3182
|
+
sets.push(`name = ?${paramIdx++}`); params.push(patch.name);
|
|
3183
|
+
diff.name = { before: row.name, after: patch.name };
|
|
3184
|
+
}
|
|
3185
|
+
if (patch.base_url !== undefined && patch.base_url !== row.base_url) {
|
|
3186
|
+
sets.push(`base_url = ?${paramIdx++}`); params.push(patch.base_url);
|
|
3187
|
+
diff.base_url = { before: row.base_url, after: patch.base_url };
|
|
3188
|
+
}
|
|
3189
|
+
if (patch.enabled !== undefined) {
|
|
3190
|
+
const newEnabled = patch.enabled ? 1 : 0;
|
|
3191
|
+
if (newEnabled !== row.enabled) {
|
|
3192
|
+
sets.push(`enabled = ?${paramIdx++}`); params.push(newEnabled);
|
|
3193
|
+
diff.enabled = { before: row.enabled === 1, after: patch.enabled };
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
// Replace models list (if supplied) atomically with the providers UPDATE
|
|
3198
|
+
const willReplaceModels = patch.models !== undefined;
|
|
3199
|
+
const newModelIds: string[] = [];
|
|
3200
|
+
|
|
3201
|
+
// No-op early return (通信牛 nit): all patch fields matched the
|
|
3202
|
+
// existing row values + no models supplied → no real change to
|
|
3203
|
+
// persist. Returning here means we don't write an empty-diff
|
|
3204
|
+
// audit row (audit noise). Audit_log INSERT must only fire when
|
|
3205
|
+
// something actually changed.
|
|
3206
|
+
if (sets.length === 0 && !willReplaceModels) {
|
|
3207
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, provider_id, no_changes: true, hint: "patch fields matched existing row, no-op" }) }] };
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
try {
|
|
3211
|
+
db.exec("BEGIN");
|
|
3212
|
+
if (sets.length > 0) {
|
|
3213
|
+
params.push(provider_id);
|
|
3214
|
+
try {
|
|
3215
|
+
db.run(`UPDATE providers SET ${sets.join(", ")} WHERE provider_id = ?${paramIdx}`, params);
|
|
3216
|
+
} catch (e: any) {
|
|
3217
|
+
if (/UNIQUE constraint failed/.test(e?.message || "")) {
|
|
3218
|
+
db.exec("ROLLBACK");
|
|
3219
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_name_conflict", name: patch.name }) }] };
|
|
3220
|
+
}
|
|
3221
|
+
throw e;
|
|
3222
|
+
}
|
|
3223
|
+
}
|
|
3224
|
+
if (willReplaceModels) {
|
|
3225
|
+
db.run(`DELETE FROM provider_models WHERE provider_id = ?1`, [provider_id]);
|
|
3226
|
+
for (const m of patch.models) {
|
|
3227
|
+
const mid = `pm_${uuidv4()}`;
|
|
3228
|
+
db.run(
|
|
3229
|
+
`INSERT INTO provider_models (model_id, provider_id, model_name, display_name, context_window, supports_vision, enabled, created_at)
|
|
3230
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7)`,
|
|
3231
|
+
[mid, provider_id, m.model_name, m.display_name ?? null, m.context_window ?? null, m.supports_vision ? 1 : 0, Date.now()],
|
|
3232
|
+
);
|
|
3233
|
+
newModelIds.push(mid);
|
|
3234
|
+
}
|
|
3235
|
+
diff.models = { before: "(prior list)", after: `${patch.models.length} models replaced` };
|
|
3236
|
+
}
|
|
3237
|
+
db.run(
|
|
3238
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
3239
|
+
VALUES (?1, ?2, 'update_provider', 'provider', ?3, ?4, ?5)`,
|
|
3240
|
+
[enforceUserId || null, callerAlias || null, provider_id, JSON.stringify({ diff, fields_changed: Object.keys(diff) }), effectiveNetId || null],
|
|
3241
|
+
);
|
|
3242
|
+
db.exec("COMMIT");
|
|
3243
|
+
} catch (e: any) {
|
|
3244
|
+
try { db.exec("ROLLBACK"); } catch { /* ok */ }
|
|
3245
|
+
throw e;
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
3249
|
+
ok: true, provider_id,
|
|
3250
|
+
fields_changed: Object.keys(diff),
|
|
3251
|
+
models_replaced: willReplaceModels ? newModelIds.length : null,
|
|
3252
|
+
}) }] };
|
|
3253
|
+
},
|
|
3254
|
+
);
|
|
3255
|
+
|
|
3256
|
+
// §2.3.3 — list_providers (viewer+; never returns secret VALUES).
|
|
3257
|
+
server.tool(
|
|
3258
|
+
"list_providers",
|
|
3259
|
+
"List providers + models in the caller's network. Never returns secret VALUES. RFC-028.",
|
|
3260
|
+
{ network_id: z.string().max(200).optional() },
|
|
3261
|
+
async ({ network_id: clientNetId }) => {
|
|
3262
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
3263
|
+
// viewer access: caller must be member of network (read access)
|
|
3264
|
+
if (enforceUserId) {
|
|
3265
|
+
const role = getUserNetworkRole(enforceUserId, effectiveNetId || "default");
|
|
3266
|
+
if (!role) return writeDeniedReply(effectiveNetId, "read");
|
|
3267
|
+
}
|
|
3268
|
+
const providers = db.all<any>(
|
|
3269
|
+
`SELECT provider_id, name, vendor, base_url, secret_key_ref, enabled FROM providers WHERE network_id = ?1 AND enabled = 1 ORDER BY name`,
|
|
3270
|
+
effectiveNetId || "default",
|
|
3271
|
+
);
|
|
3272
|
+
const out = providers.map(p => {
|
|
3273
|
+
const models = db.all<any>(
|
|
3274
|
+
`SELECT model_id, model_name, display_name, context_window, supports_vision, enabled FROM provider_models WHERE provider_id = ?1 AND enabled = 1 ORDER BY model_name`,
|
|
3275
|
+
p.provider_id,
|
|
3276
|
+
);
|
|
3277
|
+
return { ...p, in_vault: true, models }; // in_vault: true since we required it on upsert
|
|
3278
|
+
});
|
|
3279
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, providers: out }) }] };
|
|
3280
|
+
},
|
|
3281
|
+
);
|
|
3282
|
+
|
|
3283
|
+
// §2.3.5 — probe_provider_model (admin+; dispatches to daemon).
|
|
3284
|
+
server.tool(
|
|
3285
|
+
"probe_provider_model",
|
|
3286
|
+
"Dispatch a connectivity probe to a daemon. Mints ephemeral secret blob; daemon pulls via get_probe_request. Admin+. RFC-028.",
|
|
3287
|
+
{
|
|
3288
|
+
provider_id: z.string().min(1).max(200),
|
|
3289
|
+
model_name: z.string().min(1).max(100),
|
|
3290
|
+
daemon_node_id: z.string().min(1).max(200),
|
|
3291
|
+
network_id: z.string().max(200).optional(),
|
|
3292
|
+
},
|
|
3293
|
+
async ({ provider_id, model_name, daemon_node_id, network_id: clientNetId }) => {
|
|
3294
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
3295
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
3296
|
+
const callerRole = enforceUserId && effectiveNetId
|
|
3297
|
+
? getUserNetworkRole(enforceUserId, effectiveNetId)
|
|
3298
|
+
: null;
|
|
3299
|
+
if (callerRole !== "admin" && callerRole !== "owner") {
|
|
3300
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "insufficient_role_for_probe", required_role: "admin", caller_role: callerRole }) }] };
|
|
3301
|
+
}
|
|
3302
|
+
// Resolve provider + model (network-scoped). Distinguish "not found"
|
|
3303
|
+
// from "disabled" so the dashboard can render the right error
|
|
3304
|
+
// (P1.5 dashboard编辑/停用 needs this to explain why probe rejected).
|
|
3305
|
+
const provider = db.get<any>(
|
|
3306
|
+
`SELECT provider_id, vendor, base_url, secret_key_ref, enabled FROM providers WHERE provider_id = ?1 AND network_id = ?2`,
|
|
3307
|
+
provider_id, effectiveNetId || "default",
|
|
3308
|
+
);
|
|
3309
|
+
if (!provider) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_not_found", provider_id }) }] };
|
|
3310
|
+
if (provider.enabled !== 1) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "provider_disabled", provider_id, hint: "update_provider with enabled:true to re-enable" }) }] };
|
|
3311
|
+
const model = db.get<any>(
|
|
3312
|
+
`SELECT model_id, model_name FROM provider_models WHERE provider_id = ?1 AND model_name = ?2 AND enabled = 1`,
|
|
3313
|
+
provider_id, model_name,
|
|
3314
|
+
);
|
|
3315
|
+
if (!model) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "model_not_found", model_name }) }] };
|
|
3316
|
+
// Resolve daemon (must be in caller network)
|
|
3317
|
+
const { row: daemon, sec1Ok } = resolveTargetNode(daemon_node_id, effectiveNetId);
|
|
3318
|
+
if (!daemon) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "daemon_not_found" }) }] };
|
|
3319
|
+
if (!sec1Ok) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
|
|
3320
|
+
// Vault decrypt the API key (may throw VaultError)
|
|
3321
|
+
let apiKey: string;
|
|
3322
|
+
try {
|
|
3323
|
+
const v = vaultGet(effectiveNetId || "default", provider.secret_key_ref);
|
|
3324
|
+
if (!v) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "secret_not_in_vault", key: provider.secret_key_ref }) }] };
|
|
3325
|
+
apiKey = v;
|
|
3326
|
+
} catch (e) { return probeFailReply(e); }
|
|
3327
|
+
// Mint probe row + stash ephemeral blob
|
|
3328
|
+
const probeId = newProbeId();
|
|
3329
|
+
db.run(
|
|
3330
|
+
`INSERT INTO probe_results (probe_id, provider_id, model_name, daemon_node_id, network_id, status, probed_at, probed_by_user)
|
|
3331
|
+
VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)`,
|
|
3332
|
+
[probeId, provider_id, model_name, daemon_node_id, effectiveNetId || "default", Date.now(), enforceUserId || null],
|
|
3333
|
+
);
|
|
3334
|
+
putPendingProbeSecret({
|
|
3335
|
+
probe_id: probeId,
|
|
3336
|
+
daemon_node_id,
|
|
3337
|
+
provider_id,
|
|
3338
|
+
vendor: provider.vendor,
|
|
3339
|
+
base_url: provider.base_url,
|
|
3340
|
+
model_name,
|
|
3341
|
+
api_key: apiKey,
|
|
3342
|
+
network_id: effectiveNetId || "default",
|
|
3343
|
+
});
|
|
3344
|
+
pushEvent(daemon.alias, { type: "probe_provider", probe_id: probeId }, daemon.network_id || effectiveNetId || "default");
|
|
3345
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, probe_id: probeId }) }] };
|
|
3346
|
+
},
|
|
3347
|
+
);
|
|
3348
|
+
|
|
3349
|
+
// §2.3.6 — get_probe_results (viewer+; matrix renderer source).
|
|
3350
|
+
server.tool(
|
|
3351
|
+
"get_probe_results",
|
|
3352
|
+
"Query probe history (optionally filtered by provider/model/daemon). Used by dashboard reachability matrix. RFC-028.",
|
|
3353
|
+
{
|
|
3354
|
+
provider_id: z.string().max(200).optional(),
|
|
3355
|
+
model_name: z.string().max(100).optional(),
|
|
3356
|
+
daemon_node_id: z.string().max(200).optional(),
|
|
3357
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
3358
|
+
network_id: z.string().max(200).optional(),
|
|
3359
|
+
},
|
|
3360
|
+
async ({ provider_id, model_name, daemon_node_id, limit, network_id: clientNetId }) => {
|
|
3361
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
3362
|
+
if (enforceUserId) {
|
|
3363
|
+
const role = getUserNetworkRole(enforceUserId, effectiveNetId || "default");
|
|
3364
|
+
if (!role) return writeDeniedReply(effectiveNetId, "read");
|
|
3365
|
+
}
|
|
3366
|
+
const where: string[] = ["network_id = ?1"];
|
|
3367
|
+
const params: any[] = [effectiveNetId || "default"];
|
|
3368
|
+
if (provider_id) { where.push(`provider_id = ?${params.length + 1}`); params.push(provider_id); }
|
|
3369
|
+
if (model_name) { where.push(`model_name = ?${params.length + 1}`); params.push(model_name); }
|
|
3370
|
+
if (daemon_node_id) { where.push(`daemon_node_id = ?${params.length + 1}`); params.push(daemon_node_id); }
|
|
3371
|
+
const sql = `SELECT probe_id, provider_id, model_name, daemon_node_id, status, latency_ms, error_label, probed_at, raw_status_code FROM probe_results WHERE ${where.join(" AND ")} ORDER BY probed_at DESC LIMIT ${limit ?? 100}`;
|
|
3372
|
+
const rows = db.all<any>(sql, ...params);
|
|
3373
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, results: rows }) }] };
|
|
3374
|
+
},
|
|
3375
|
+
);
|
|
3376
|
+
|
|
3377
|
+
// §2.3.7 daemon-facing — get_probe_request.
|
|
3378
|
+
server.tool(
|
|
3379
|
+
"get_probe_request",
|
|
3380
|
+
"Daemon pulls a pending probe request (called when SSE probe_provider doorbell arrives). RFC-028.",
|
|
3381
|
+
{ probe_id: z.string().min(1).max(200) },
|
|
3382
|
+
async ({ probe_id }) => {
|
|
3383
|
+
const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
|
|
3384
|
+
if (!callerDaemon.ok) {
|
|
3385
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
3386
|
+
}
|
|
3387
|
+
// takePendingProbeSecret enforces daemon binding + evicts
|
|
3388
|
+
const blob = (await import("./probe.js")).takePendingProbeSecret(probe_id, callerDaemon.daemonNodeId);
|
|
3389
|
+
if (!blob) {
|
|
3390
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "probe_request_unavailable", reason: "not_found_or_wrong_daemon_or_expired" }) }] };
|
|
3391
|
+
}
|
|
3392
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
3393
|
+
ok: true,
|
|
3394
|
+
probe_id: blob.probe_id,
|
|
3395
|
+
vendor: blob.vendor,
|
|
3396
|
+
base_url: blob.base_url,
|
|
3397
|
+
model_name: blob.model_name,
|
|
3398
|
+
api_key: blob.api_key, // ephemeral; daemon writes to .env.local or in-memory only
|
|
3399
|
+
}) }] };
|
|
3400
|
+
},
|
|
3401
|
+
);
|
|
3402
|
+
|
|
3403
|
+
// §2.3.8 daemon-facing — ack_probe_request (STRICT whitelist via zod).
|
|
3404
|
+
// R3 LOCK (RFC-028 v3): ack payload has EXACTLY 4 keys. We pass a
|
|
3405
|
+
// ZodObject with .strict() so the MCP SDK's validateToolInput surfaces
|
|
3406
|
+
// any extra field (e.g. an attacker smuggling `error_message` to leak a
|
|
3407
|
+
// secret) as a -32602 Invalid params error at the protocol boundary
|
|
3408
|
+
// BEFORE our handler runs. The SDK invokes z.object(shape) by default
|
|
3409
|
+
// which is strip-mode; passing a fully-constructed strict object
|
|
3410
|
+
// bypasses that and enforces unknownKeys='strict'.
|
|
3411
|
+
server.registerTool(
|
|
3412
|
+
"ack_probe_request",
|
|
3413
|
+
{
|
|
3414
|
+
description: "Daemon acks a probe. Schema is STRICT whitelist (no error_message; v3 R3 LOCK). RFC-028.",
|
|
3415
|
+
// Strict-mode ZodObject so the MCP SDK's validateToolInput surfaces
|
|
3416
|
+
// any extra field as a -32602 Invalid params error BEFORE the
|
|
3417
|
+
// handler runs. server.tool() accepts only ZodRawShape (default
|
|
3418
|
+
// strip-mode); registerTool() accepts a constructed ZodObject and
|
|
3419
|
+
// honors its unknownKeys policy.
|
|
3420
|
+
inputSchema: z.object({
|
|
3421
|
+
probe_id: z.string().min(1).max(200),
|
|
3422
|
+
status: z.enum(["ok", "auth_fail", "quota", "rate_limit", "network_error", "timeout", "redirect_forbidden", "vendor_5xx", "other_4xx", "tls_error", "probe_resolve_unsafe_ip", "probe_target_forbidden"]),
|
|
3423
|
+
raw_status_code: z.number().int().min(100).max(599).optional(),
|
|
3424
|
+
latency_ms: z.number().int().min(0).max(60_000),
|
|
3425
|
+
}).strict() as any,
|
|
3426
|
+
},
|
|
3427
|
+
async (args: any) => {
|
|
3428
|
+
const callerDaemon = _resolveCallerDaemonTokenBound({ callerTokenIsNetwork, callerTokenId, enforceNetworkId });
|
|
3429
|
+
if (!callerDaemon.ok) {
|
|
3430
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: callerDaemon.error }) }] };
|
|
3431
|
+
}
|
|
3432
|
+
try {
|
|
3433
|
+
const r = finalizeProbeAck(args, { network_id: callerDaemon.networkId, daemon_node_id: callerDaemon.daemonNodeId });
|
|
3434
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(r) }] };
|
|
3435
|
+
} catch (e) {
|
|
3436
|
+
return probeFailReply(e);
|
|
3437
|
+
}
|
|
3438
|
+
},
|
|
3439
|
+
);
|
|
1754
3440
|
}
|
|
1755
3441
|
|
|
1756
3442
|
// ────────────────────────────────────────────────────────────────────
|
|
@@ -1839,9 +3525,32 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
|
|
|
1839
3525
|
],
|
|
1840
3526
|
);
|
|
1841
3527
|
if (input.config_snapshot) {
|
|
3528
|
+
// RFC-026 §9.3 / #338 PR2+PR3 — promote daemon self-declare fields
|
|
3529
|
+
// to first-class indexable columns alongside the snapshot blob.
|
|
3530
|
+
// The snapshot stays the source of truth for non-list reads; the
|
|
3531
|
+
// columns exist so `list_host_supervisors` doesn't JSON.parse on
|
|
3532
|
+
// every call. typeof-narrow per
|
|
3533
|
+
// per team rule (typeof-narrow extracted JSON fields at the boundary) — zod narrowed but
|
|
3534
|
+
// input.config_snapshot is typed `unknown` here.
|
|
3535
|
+
//
|
|
3536
|
+
// PR3 nit ①: read from nested `daemon_capabilities.*` (canonical
|
|
3537
|
+
// per RFC §9.3 + matches existing hub create_node reads at
|
|
3538
|
+
// tools.ts:2010/2075). PR2 ate from top-level keys, which the
|
|
3539
|
+
// hub create_node path never read → max_concurrent_children
|
|
3540
|
+
// backpressure was dead config + allowlist enforcement bypassed.
|
|
3541
|
+
const snap = input.config_snapshot as Record<string, unknown> | null;
|
|
3542
|
+
const caps = (snap?.daemon_capabilities ?? null) as Record<string, unknown> | null;
|
|
3543
|
+
const runtimesRaw = caps?.runtimes_supported;
|
|
3544
|
+
const allowedRaw = caps?.allowed_secret_keys;
|
|
3545
|
+
const runtimesJson = Array.isArray(runtimesRaw) && runtimesRaw.every(s => typeof s === "string")
|
|
3546
|
+
? JSON.stringify(runtimesRaw)
|
|
3547
|
+
: null;
|
|
3548
|
+
const allowedJson = Array.isArray(allowedRaw) && allowedRaw.every(s => typeof s === "string")
|
|
3549
|
+
? JSON.stringify(allowedRaw)
|
|
3550
|
+
: null;
|
|
1842
3551
|
db.run(
|
|
1843
|
-
`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?
|
|
1844
|
-
[JSON.stringify(input.config_snapshot), input.node_id],
|
|
3552
|
+
`UPDATE nodes SET config_snapshot = ?1, runtimes_supported = ?2, allowed_secret_keys = ?3 WHERE node_id = ?4`,
|
|
3553
|
+
[JSON.stringify(input.config_snapshot), runtimesJson, allowedJson, input.node_id],
|
|
1845
3554
|
);
|
|
1846
3555
|
// RFC-024 — finalize any pending/restarting update whose target
|
|
1847
3556
|
// patch is now reflected in the snapshot. Closes the
|
|
@@ -1853,6 +3562,22 @@ export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): Up
|
|
|
1853
3562
|
// against the reported snapshot.
|
|
1854
3563
|
finalizePendingMatchingUpdates(input.node_id, input.config_snapshot);
|
|
1855
3564
|
}
|
|
3565
|
+
// RFC-026 §2.5 step 4 — on every register/report_status, opportunistically
|
|
3566
|
+
// close out any pending create_node request whose child_name matches
|
|
3567
|
+
// this incoming alias. Same content-match pattern as RFC-024's
|
|
3568
|
+
// finalizePendingMatchingUpdates; the new child doesn't have the
|
|
3569
|
+
// request_id, so hub does the matching on its behalf.
|
|
3570
|
+
try {
|
|
3571
|
+
finalizeCreateOnFirstRegister({
|
|
3572
|
+
node_id: input.node_id,
|
|
3573
|
+
alias: input.alias || input.node_name || "",
|
|
3574
|
+
network_id: input.callerNetworkId ?? null,
|
|
3575
|
+
});
|
|
3576
|
+
} catch (e: any) {
|
|
3577
|
+
// create-node finalize is best-effort — never block report_status
|
|
3578
|
+
// on it. Log + continue.
|
|
3579
|
+
console.warn(`[commhub] create-node finalize on report_status failed: ${e?.message || e}`);
|
|
3580
|
+
}
|
|
1856
3581
|
return { result: existing ? "updated" : "inserted", node_id: input.node_id };
|
|
1857
3582
|
}
|
|
1858
3583
|
|
|
@@ -1895,6 +3620,15 @@ export function finalizePendingMatchingUpdates(
|
|
|
1895
3620
|
|
|
1896
3621
|
const snapModel: string | null | undefined = snapshot?.model;
|
|
1897
3622
|
const snapFlags: Record<string, unknown> = (snapshot?.flags && typeof snapshot.flags === "object") ? snapshot.flags : {};
|
|
3623
|
+
// #260 P5 — snapshot.channels is the (sorted, bare-type) channel set
|
|
3624
|
+
// the node currently has forked. agent-node's buildConfigSnapshot
|
|
3625
|
+
// always emits it (even as []), so the "absent field" case here means
|
|
3626
|
+
// an older pre-#260-P5 agent-node — for those, don't finalize a
|
|
3627
|
+
// channels-carrying patch (would false-positive on the OTHER fields).
|
|
3628
|
+
const snapChannelsPresent = Array.isArray(snapshot?.channels);
|
|
3629
|
+
const snapChannels: string[] = snapChannelsPresent
|
|
3630
|
+
? (snapshot.channels as unknown[]).filter((c): c is string => typeof c === "string")
|
|
3631
|
+
: [];
|
|
1898
3632
|
|
|
1899
3633
|
const finalizedIds: string[] = [];
|
|
1900
3634
|
|
|
@@ -1919,6 +3653,35 @@ export function finalizePendingMatchingUpdates(
|
|
|
1919
3653
|
if (snapFlags[k] !== v) { matches = false; break; }
|
|
1920
3654
|
}
|
|
1921
3655
|
}
|
|
3656
|
+
// #260 P5 — channels field content-match. Without this the
|
|
3657
|
+
// node's very first startup report_status matches (patch.flags
|
|
3658
|
+
// is `{}` for a channels-only update, so the flags loop is
|
|
3659
|
+
// trivially satisfied) and hub prematurely marks the update
|
|
3660
|
+
// applied, deleting the pending row + bumping config_revision
|
|
3661
|
+
// BEFORE the doorbell reaches the child. Codex catch on PR #411.
|
|
3662
|
+
//
|
|
3663
|
+
// Set-equality is enough because the patch side comes from
|
|
3664
|
+
// narrowChannelsPatch → already deduped + case-folded + in
|
|
3665
|
+
// EDITABLE_CHANNELS iteration order; the snapshot side comes
|
|
3666
|
+
// from buildConfigSnapshot which mirrors the same shape.
|
|
3667
|
+
if (matches && Array.isArray(patch.channels)) {
|
|
3668
|
+
if (!snapChannelsPresent) {
|
|
3669
|
+
// Pre-#260-P5 agent-node — no channels field in snapshot,
|
|
3670
|
+
// so we can't prove the apply landed. Skip finalize; hub's
|
|
3671
|
+
// F-B reaper still supersedes this eventually.
|
|
3672
|
+
matches = false;
|
|
3673
|
+
} else {
|
|
3674
|
+
const wanted = new Set<string>(patch.channels);
|
|
3675
|
+
const have = new Set<string>(snapChannels);
|
|
3676
|
+
if (wanted.size !== have.size) {
|
|
3677
|
+
matches = false;
|
|
3678
|
+
} else {
|
|
3679
|
+
for (const w of wanted) {
|
|
3680
|
+
if (!have.has(w)) { matches = false; break; }
|
|
3681
|
+
}
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
}
|
|
1922
3685
|
}
|
|
1923
3686
|
|
|
1924
3687
|
if (!matches) continue;
|