@sleep2agi/commhub-server 0.8.8 → 0.9.0-preview.1
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/auth-kdf-migration.test.ts +378 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth.ts +92 -6
- package/src/config-apply-sec1.test.ts +561 -0
- package/src/config-apply-validate.test.ts +260 -0
- package/src/config-apply-validate.ts +171 -0
- package/src/cross-tenant-injection.test.ts +335 -0
- package/src/db-adapter.ts +2 -2
- package/src/db.ts +356 -7
- package/src/index.ts +61 -17
- package/src/push.test.ts +383 -2
- package/src/push.ts +291 -55
- package/src/retention.test.ts +450 -0
- package/src/retention.ts +269 -0
- package/src/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -0
- package/src/tools.ts +597 -64
package/src/tools.ts
CHANGED
|
@@ -4,6 +4,13 @@ import { db, uuidv4, logTaskEvent, chainReplyToParent } from "./db.js";
|
|
|
4
4
|
import { pushEvent } from "./push.js";
|
|
5
5
|
import { getUserNetworkRole } from "./auth.js";
|
|
6
6
|
import { canonicalAliasExists, cleanupRenamedAliasSession, resolveCanonicalAlias } from "./rename.js";
|
|
7
|
+
import {
|
|
8
|
+
ALLOWED_FLAGS,
|
|
9
|
+
SECURITY_SENSITIVE_FLAGS,
|
|
10
|
+
computeApplyMode,
|
|
11
|
+
validatePatch,
|
|
12
|
+
isAllowedToChangeFlag,
|
|
13
|
+
} from "./config-apply-validate.js";
|
|
7
14
|
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
8
15
|
|
|
9
16
|
function ts(): string {
|
|
@@ -237,8 +244,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
237
244
|
uptime_seconds: z.number().nullable().optional(),
|
|
238
245
|
in_flight_count: z.number().nullable().optional(),
|
|
239
246
|
}).optional().describe("Per-agent process telemetry reported by agent-node"),
|
|
247
|
+
// RFC-024 B6 — masked snapshot of the node's effective config
|
|
248
|
+
// (model + 6 dashboard-editable flags). Secrets ARE NOT in this
|
|
249
|
+
// shape (env._envRef stays on host); the dashboard reads this
|
|
250
|
+
// verbatim for the snapshot path without touching node files.
|
|
251
|
+
// config_update_capable signals whether the node runs under a
|
|
252
|
+
// supervisor wrapper that honours the sentinel-75 restart path
|
|
253
|
+
// (W1) — bare-spawned agent-nodes set this to false so dashboard
|
|
254
|
+
// can grey out remote-restart for them.
|
|
255
|
+
config_snapshot: z.object({
|
|
256
|
+
model: z.string().max(200).optional().nullable(),
|
|
257
|
+
flags: z.record(z.unknown()).optional(),
|
|
258
|
+
config_revision: z.number().int().min(0).optional(),
|
|
259
|
+
config_update_capable: z.boolean().optional(),
|
|
260
|
+
}).optional().describe("RFC-024 — masked node config snapshot"),
|
|
240
261
|
},
|
|
241
|
-
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 }) => {
|
|
262
|
+
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 }) => {
|
|
242
263
|
const effectiveNetId = getNetworkId(netId);
|
|
243
264
|
const sessionNetId = effectiveNetId ?? "default";
|
|
244
265
|
if (!callerTokenIsNetwork || !enforceNetworkId) {
|
|
@@ -391,27 +412,26 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
391
412
|
} catch {}
|
|
392
413
|
}
|
|
393
414
|
|
|
394
|
-
// V2: upsert nodes table for persistent node identity
|
|
415
|
+
// V2: upsert nodes table for persistent node identity. SEC-1
|
|
416
|
+
// gate (PR A #287 follow-up, 通信牛 catch 2026-06-28): delegate
|
|
417
|
+
// to upsertNodeWithSec1Guard so production + test exercise the
|
|
418
|
+
// exact same code path. See helper below registerTools.
|
|
395
419
|
if (node_id) {
|
|
396
420
|
try {
|
|
397
|
-
// Extract runtime from agent field (e.g., "agent-node:codex" → "codex-sdk")
|
|
398
421
|
const nodeRuntime = ag?.includes(":") ? ag.split(":")[1] + "-sdk" : ag ?? null;
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
updated_at = datetime('now')`,
|
|
413
|
-
[node_id, nn || effectiveAlias, effectiveAlias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null, effectiveNetId ?? null]
|
|
414
|
-
);
|
|
422
|
+
upsertNodeWithSec1Guard({
|
|
423
|
+
node_id,
|
|
424
|
+
callerNetworkId: effectiveNetId ?? null,
|
|
425
|
+
node_name: nn || effectiveAlias,
|
|
426
|
+
alias: effectiveAlias,
|
|
427
|
+
runtime: nodeRuntime,
|
|
428
|
+
model: mdl ?? null,
|
|
429
|
+
config_path: config_path ?? null,
|
|
430
|
+
channels: channels ?? null,
|
|
431
|
+
server: srv ?? null,
|
|
432
|
+
hostname: hn ?? null,
|
|
433
|
+
config_snapshot: cfgSnap ?? null,
|
|
434
|
+
});
|
|
415
435
|
} catch {}
|
|
416
436
|
}
|
|
417
437
|
|
|
@@ -498,20 +518,32 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
498
518
|
if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
|
|
499
519
|
|
|
500
520
|
// Auto-chain to parent lineage (mirror of send_reply path).
|
|
521
|
+
// round5 F2: pass caller's effectiveNetId so the chain refuses
|
|
522
|
+
// to write across tenants if some upstream parent links to a
|
|
523
|
+
// foreign network.
|
|
524
|
+
//
|
|
525
|
+
// round5 follow-up (通信牛 SSE leak catch): gate the SSE push on
|
|
526
|
+
// `result.chained` — if the chain refused (cross-network), the
|
|
527
|
+
// subsequent SELECT of `parent.from_name` + `pushEvent(...,
|
|
528
|
+
// parent.task_id)` would leak the foreign parent's task_id into
|
|
529
|
+
// the caller's network via the SSE payload. Skip the push when
|
|
530
|
+
// the chain didn't actually write.
|
|
501
531
|
if (updatedTaskId) {
|
|
502
532
|
try {
|
|
503
|
-
chainReplyToParent(updatedTaskId, result, "replied");
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
if (parentChain?.parent_task_id) {
|
|
509
|
-
const parent = db.get<{ from_name: string; task_id: string }>(
|
|
510
|
-
"SELECT from_name, task_id FROM tasks WHERE task_id = ?1",
|
|
511
|
-
[parentChain.parent_task_id]
|
|
533
|
+
const chainResult = chainReplyToParent(updatedTaskId, result, "replied", 5, effectiveNetId);
|
|
534
|
+
if (chainResult.chained) {
|
|
535
|
+
const parentChain = db.get<{ parent_task_id: string | null }>(
|
|
536
|
+
"SELECT parent_task_id FROM tasks WHERE task_id = ?1",
|
|
537
|
+
[updatedTaskId]
|
|
512
538
|
);
|
|
513
|
-
if (
|
|
514
|
-
|
|
539
|
+
if (parentChain?.parent_task_id) {
|
|
540
|
+
const parent = db.get<{ from_name: string; task_id: string }>(
|
|
541
|
+
"SELECT from_name, task_id FROM tasks WHERE task_id = ?1",
|
|
542
|
+
[parentChain.parent_task_id]
|
|
543
|
+
);
|
|
544
|
+
if (parent?.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
|
|
545
|
+
pushEvent(parent.from_name, { type: "chained_reply", parent_task_id: parent.task_id, child_task_id: updatedTaskId, child_alias: alias }, effectiveNetId);
|
|
546
|
+
}
|
|
515
547
|
}
|
|
516
548
|
}
|
|
517
549
|
} catch (e: any) {
|
|
@@ -610,21 +642,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
610
642
|
if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
|
|
611
643
|
console.log(`[${ts()}] hub → get_all_status${filter_status ? ": filter=" + filter_status : ""}${readScope.networkId ? " net=" + readScope.networkId.slice(0, 12) : ""}`);
|
|
612
644
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
sql = addReadScope(sql, params, readScope);
|
|
623
|
-
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
624
|
-
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
625
|
-
sql += " ORDER BY updated_at DESC";
|
|
626
|
-
return db.all(sql, ...params);
|
|
627
|
-
});
|
|
645
|
+
// Round-2/4 review ③: stale-marking moved to startStaleSessionSweeper()
|
|
646
|
+
// (background timer, ~60s cadence). Read path no longer fires UPDATE.
|
|
647
|
+
let sql = "SELECT * FROM sessions WHERE 1=1";
|
|
648
|
+
const params: any[] = [];
|
|
649
|
+
sql = addReadScope(sql, params, readScope);
|
|
650
|
+
if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
|
|
651
|
+
if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
|
|
652
|
+
sql += " ORDER BY updated_at DESC";
|
|
653
|
+
const sessions = db.all(sql, ...params);
|
|
628
654
|
|
|
629
655
|
const summaryParams: any[] = [];
|
|
630
656
|
let summarySql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
|
|
@@ -695,23 +721,61 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
695
721
|
async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId, parent_task_id: parentIn, meta }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
696
722
|
const effectiveNetId = getNetworkId(netId);
|
|
697
723
|
const metaJson = normalizeMetaJson(meta);
|
|
724
|
+
|
|
725
|
+
// Role check FIRST — round5 follow-up (通信牛 oracle catch):
|
|
726
|
+
// the explicit-parent verification below distinguishes
|
|
727
|
+
// `cross_network_parent` from `permission_denied`. If we ran the
|
|
728
|
+
// parent lookup before canWrite, a viewer role could probe parent
|
|
729
|
+
// existence + ownership of foreign parents via the difference in
|
|
730
|
+
// error codes. Run canWrite first so a viewer ALWAYS gets the
|
|
731
|
+
// same `permission_denied` regardless of parent state.
|
|
732
|
+
if (!canWrite(effectiveNetId)) {
|
|
733
|
+
return writeDeniedReply(effectiveNetId, "send_task");
|
|
734
|
+
}
|
|
735
|
+
|
|
698
736
|
// Resolve parent_task_id: explicit > inferred (caller's most recent
|
|
699
737
|
// delivered/started inbox task that's still open). Inference is the
|
|
700
738
|
// safety net for when the LLM forgets to pass parent_task_id.
|
|
739
|
+
//
|
|
740
|
+
// round5 F1 fix: the inference SELECT MUST be network-scoped. Without
|
|
741
|
+
// it, a caller in network B can pick up the parent_task_id of a
|
|
742
|
+
// network A dispatch and chain-reply into the wrong tenant. The
|
|
743
|
+
// explicit-parent path is verified below in F2.
|
|
701
744
|
let parentTaskId: string | null = parentIn ?? null;
|
|
702
745
|
if (!parentTaskId && from_session && from_session !== "hub" && from_session !== "api") {
|
|
703
746
|
try {
|
|
704
|
-
const
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
747
|
+
const recentParams: any[] = [from_session];
|
|
748
|
+
let recentSql = "SELECT task_id FROM tasks WHERE to_name = ?1 AND status IN ('delivered','started')";
|
|
749
|
+
recentSql = addScope(recentSql, recentParams, effectiveNetId);
|
|
750
|
+
recentSql += " ORDER BY created_at DESC LIMIT 1";
|
|
751
|
+
const recent = db.get<{ task_id: string }>(recentSql, ...recentParams);
|
|
708
752
|
if (recent?.task_id) parentTaskId = recent.task_id;
|
|
709
753
|
} catch {}
|
|
710
754
|
}
|
|
711
755
|
|
|
712
|
-
//
|
|
713
|
-
|
|
714
|
-
|
|
756
|
+
// round5 F2 fix: an explicit parent_task_id must belong to the
|
|
757
|
+
// caller's network. Otherwise a malicious caller in network B can
|
|
758
|
+
// hand us a parent id from network A and have chainReplyToParent
|
|
759
|
+
// (db.ts) write back into A's task result + inbox — cross-tenant
|
|
760
|
+
// write. Verify ownership, reject on mismatch.
|
|
761
|
+
if (parentIn) {
|
|
762
|
+
const parentRow = db.get<{ network_id: string | null }>(
|
|
763
|
+
"SELECT network_id FROM tasks WHERE task_id = ?1",
|
|
764
|
+
[parentIn]
|
|
765
|
+
);
|
|
766
|
+
if (!parentRow) {
|
|
767
|
+
// Parent doesn't exist (LLM hallucination, race with retention
|
|
768
|
+
// sweep, etc.). Drop the link silently so the dispatch can
|
|
769
|
+
// still proceed; the LLM may have meant to dispatch fresh.
|
|
770
|
+
console.log(`[${ts()}] ⚠ send_task: parent_task_id=${parentIn.slice(0, 8)} not found, dropping parent link`);
|
|
771
|
+
parentTaskId = null;
|
|
772
|
+
} else if ((parentRow.network_id ?? null) !== (effectiveNetId ?? null)) {
|
|
773
|
+
console.log(`[${ts()}] 🚫 send_task: cross-network parent rejected, parent=${parentIn.slice(0, 8)} parent-net=${parentRow.network_id ?? "null"} caller-net=${effectiveNetId ?? "null"}`);
|
|
774
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
775
|
+
ok: false, error: "cross_network_parent",
|
|
776
|
+
message: "parent_task_id belongs to a different network",
|
|
777
|
+
}) }] };
|
|
778
|
+
}
|
|
715
779
|
}
|
|
716
780
|
|
|
717
781
|
// License check
|
|
@@ -980,21 +1044,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
980
1044
|
|
|
981
1045
|
// Auto-chain reply up to parent task lineage so admin sees the final
|
|
982
1046
|
// answer even if the intermediate session has died.
|
|
1047
|
+
// round5 F2: pass caller's effectiveNetId so the chain refuses
|
|
1048
|
+
// to write across tenants if some upstream parent links to a
|
|
1049
|
+
// foreign network.
|
|
1050
|
+
//
|
|
1051
|
+
// round5 follow-up (通信牛 SSE leak catch): gate the SSE push on
|
|
1052
|
+
// `result.chained`. See report_completion path above for the
|
|
1053
|
+
// full reasoning — same leak, same gate.
|
|
983
1054
|
if (replyLogged && in_reply_to) {
|
|
984
1055
|
try {
|
|
985
|
-
chainReplyToParent(in_reply_to, text, replyStatus);
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
);
|
|
991
|
-
if (parentChain?.parent_task_id) {
|
|
992
|
-
const parent = db.get<{ from_name: string; task_id: string }>(
|
|
993
|
-
"SELECT from_name, task_id FROM tasks WHERE task_id = ?1",
|
|
994
|
-
[parentChain.parent_task_id]
|
|
1056
|
+
const chainResult = chainReplyToParent(in_reply_to, text, replyStatus, 5, effectiveNetId);
|
|
1057
|
+
if (chainResult.chained) {
|
|
1058
|
+
const parentChain = db.get<{ parent_task_id: string | null; from_name: string }>(
|
|
1059
|
+
"SELECT parent_task_id, from_name FROM tasks WHERE task_id = ?1",
|
|
1060
|
+
[in_reply_to]
|
|
995
1061
|
);
|
|
996
|
-
if (
|
|
997
|
-
|
|
1062
|
+
if (parentChain?.parent_task_id) {
|
|
1063
|
+
const parent = db.get<{ from_name: string; task_id: string }>(
|
|
1064
|
+
"SELECT from_name, task_id FROM tasks WHERE task_id = ?1",
|
|
1065
|
+
[parentChain.parent_task_id]
|
|
1066
|
+
);
|
|
1067
|
+
if (parent?.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
|
|
1068
|
+
pushEvent(parent.from_name, { type: "chained_reply", parent_task_id: parent.task_id, child_task_id: in_reply_to, child_alias: alias }, effectiveNetId);
|
|
1069
|
+
}
|
|
998
1070
|
}
|
|
999
1071
|
}
|
|
1000
1072
|
} catch (e: any) {
|
|
@@ -1312,4 +1384,465 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1312
1384
|
};
|
|
1313
1385
|
}
|
|
1314
1386
|
);
|
|
1387
|
+
|
|
1388
|
+
// ──────────────────────────────────────────────────────────────────
|
|
1389
|
+
// RFC-024 (2026-06-28) — node config-apply MCP tools.
|
|
1390
|
+
//
|
|
1391
|
+
// Three contract tools (update / get / ack) + one lifecycle tool
|
|
1392
|
+
// (restart_node, Vincent 2026-06-28 increment). All four enforce
|
|
1393
|
+
// SEC-1 (network-scoped, never trust upstream dashboard routing —
|
|
1394
|
+
// every tool re-checks caller→token→network. Mirrors the #275
|
|
1395
|
+
// cross-tenant write防护带 pattern). update_node_config additionally
|
|
1396
|
+
// enforces SEC-2 — security-sensitive flags (permissionMode,
|
|
1397
|
+
// dangerouslySkipPermissions, teammateMode) are fail-CLOSED pending
|
|
1398
|
+
// Vincent's policy decision (see SECURITY_SENSITIVE_FLAGS below).
|
|
1399
|
+
//
|
|
1400
|
+
// See docs/rfcs/RFC-024-dashboard-node-config-apply.md for the
|
|
1401
|
+
// contract + sequence diagrams + guards.
|
|
1402
|
+
// ──────────────────────────────────────────────────────────────────
|
|
1403
|
+
|
|
1404
|
+
// Helpers — ALLOWED_FLAGS, SECURITY_SENSITIVE_FLAGS, computeApplyMode,
|
|
1405
|
+
// validatePatch, isAllowedToChangeFlag live in
|
|
1406
|
+
// ./config-apply-validate.ts so the contract is unit-testable without
|
|
1407
|
+
// standing up an MCP server.
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* Resolve the node row that update_node_config / restart_node targets.
|
|
1411
|
+
* Returns the row + the SEC-1 verdict (network match against caller).
|
|
1412
|
+
* Network mismatch is the cross-tenant write防护带 from #275 — every
|
|
1413
|
+
* tool re-checks this even if dashboard already did, because curl
|
|
1414
|
+
* can talk directly to /mcp.
|
|
1415
|
+
*/
|
|
1416
|
+
const resolveTargetNode = (
|
|
1417
|
+
nodeId: string,
|
|
1418
|
+
callerNetworkId: string | null,
|
|
1419
|
+
): { row: any | null; sec1Ok: boolean } => {
|
|
1420
|
+
const row = db.get<any>(
|
|
1421
|
+
"SELECT node_id, alias, network_id, config_revision FROM nodes WHERE node_id = ?1",
|
|
1422
|
+
nodeId,
|
|
1423
|
+
);
|
|
1424
|
+
if (!row) return { row: null, sec1Ok: false };
|
|
1425
|
+
// Use the same null/undefined → "default" normalization as the
|
|
1426
|
+
// report_status upsert guard (norm() helper) — `||` would also
|
|
1427
|
+
// coerce `""` to "default", which is unreachable in the V3 model
|
|
1428
|
+
// today but better aligned to avoid drift if any future migration
|
|
1429
|
+
// ever introduces empty-string network_ids. Single source of
|
|
1430
|
+
// truth: nullish-only.
|
|
1431
|
+
const nodeNet = row.network_id === null || row.network_id === undefined ? "default" : row.network_id;
|
|
1432
|
+
const callerNet = callerNetworkId === null || callerNetworkId === undefined ? "default" : callerNetworkId;
|
|
1433
|
+
return { row, sec1Ok: nodeNet === callerNet };
|
|
1434
|
+
};
|
|
1435
|
+
|
|
1436
|
+
server.tool(
|
|
1437
|
+
"update_node_config",
|
|
1438
|
+
"Set the desired per-node config (model + flags) and push a doorbell to the node. The node pulls + validates + applies (hot or restart per field tier). RFC-024.",
|
|
1439
|
+
{
|
|
1440
|
+
node_id: z.string().min(1).max(200).describe("Target node ID (not alias). Must be in caller's network."),
|
|
1441
|
+
base_revision: z.number().int().min(0).describe("Current revision per the dashboard's last GET — 409 if hub's current revision differs."),
|
|
1442
|
+
patch: z.object({
|
|
1443
|
+
model: z.string().max(200).optional(),
|
|
1444
|
+
flags: z.record(z.unknown()).optional(),
|
|
1445
|
+
}).describe("Fields to update. Empty patch → no-op (use restart_node for that)."),
|
|
1446
|
+
network_id: z.string().max(200).optional(),
|
|
1447
|
+
},
|
|
1448
|
+
async ({ node_id: nodeId, base_revision: baseRev, patch, network_id: clientNetId }) => {
|
|
1449
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
1450
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
1451
|
+
|
|
1452
|
+
const { row: node, sec1Ok } = resolveTargetNode(nodeId, effectiveNetId);
|
|
1453
|
+
if (!node) {
|
|
1454
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: nodeId }) }] };
|
|
1455
|
+
}
|
|
1456
|
+
if (!sec1Ok) {
|
|
1457
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node", message: "node belongs to another network" }) }] };
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
const model = typeof patch.model === "string" ? patch.model : undefined;
|
|
1461
|
+
const flags = (patch.flags && typeof patch.flags === "object") ? patch.flags as Record<string, unknown> : {};
|
|
1462
|
+
|
|
1463
|
+
// SEC-2 (final policy 2026-06-28) — security-sensitive flags
|
|
1464
|
+
// (permissionMode / dangerouslySkipPermissions / teammateMode)
|
|
1465
|
+
// require admin role on this network. Other flags fall through
|
|
1466
|
+
// to per-field validation. hub-side enforced (dashboard's UI
|
|
1467
|
+
// gate is not trusted; curl direct to /mcp is the attack
|
|
1468
|
+
// vector). See isAllowedToChangeFlag for the policy details.
|
|
1469
|
+
const callerRole = enforceUserId && effectiveNetId
|
|
1470
|
+
? getUserNetworkRole(enforceUserId, effectiveNetId)
|
|
1471
|
+
: null;
|
|
1472
|
+
const secCheck = isAllowedToChangeFlag(callerRole, flags);
|
|
1473
|
+
if (secCheck) {
|
|
1474
|
+
return {
|
|
1475
|
+
content: [{
|
|
1476
|
+
type: "text" as const,
|
|
1477
|
+
text: JSON.stringify({
|
|
1478
|
+
ok: false,
|
|
1479
|
+
error: "insufficient_role_for_security_flag",
|
|
1480
|
+
field: secCheck.field,
|
|
1481
|
+
required_role: "admin", // or owner — both satisfy
|
|
1482
|
+
message: secCheck.reason,
|
|
1483
|
+
}),
|
|
1484
|
+
}],
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
const validationFail = validatePatch(model, flags);
|
|
1489
|
+
if (validationFail) {
|
|
1490
|
+
return {
|
|
1491
|
+
content: [{
|
|
1492
|
+
type: "text" as const,
|
|
1493
|
+
text: JSON.stringify({
|
|
1494
|
+
ok: false,
|
|
1495
|
+
error: "invalid_patch",
|
|
1496
|
+
field: validationFail.field,
|
|
1497
|
+
reason: validationFail.reason,
|
|
1498
|
+
}),
|
|
1499
|
+
}],
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
// Revision conflict.
|
|
1504
|
+
if ((node.config_revision || 0) !== baseRev) {
|
|
1505
|
+
return {
|
|
1506
|
+
content: [{
|
|
1507
|
+
type: "text" as const,
|
|
1508
|
+
text: JSON.stringify({
|
|
1509
|
+
ok: false,
|
|
1510
|
+
error: "revision_conflict",
|
|
1511
|
+
current_revision: node.config_revision || 0,
|
|
1512
|
+
base_revision: baseRev,
|
|
1513
|
+
}),
|
|
1514
|
+
}],
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// F-B (CHANGE_REQ): single-flight with stale-update reaper.
|
|
1519
|
+
// Without TTL, a node that ack'd "restarting" then crashed (lost
|
|
1520
|
+
// power, OOM, killed) leaves a non-terminal row forever — every
|
|
1521
|
+
// subsequent update_node_config / restart_node returns
|
|
1522
|
+
// update_in_flight and the node is admin-bricked.
|
|
1523
|
+
//
|
|
1524
|
+
// Stale threshold = 60_000 ms (2× the §8-confirmed 30s apply ceiling,
|
|
1525
|
+
// chosen so a slow-but-alive node within its own deadline never
|
|
1526
|
+
// false-positives as stale). Stale rows are marked timeout +
|
|
1527
|
+
// superseded by the new update.
|
|
1528
|
+
//
|
|
1529
|
+
// Age anchor = COALESCE(acked_at, created_at) per 通信龙 polish:
|
|
1530
|
+
// a healthy-but-slow restart (drain 60s + respawn time) could
|
|
1531
|
+
// exceed the threshold if anchored on created_at alone (drain
|
|
1532
|
+
// cap and reaper threshold are both 60s — overlap). Anchoring
|
|
1533
|
+
// on acked_at means a node that ack'd "restarting" refreshes
|
|
1534
|
+
// the liveness clock, so an in-progress restart isn't falsely
|
|
1535
|
+
// reaped.
|
|
1536
|
+
const STALE_THRESHOLD_MS = 60_000;
|
|
1537
|
+
const inFlight = db.get<{ update_id: string; created_at: number; acked_at: number | null }>(
|
|
1538
|
+
"SELECT update_id, created_at, acked_at FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting') ORDER BY created_at DESC LIMIT 1",
|
|
1539
|
+
nodeId,
|
|
1540
|
+
);
|
|
1541
|
+
if (inFlight) {
|
|
1542
|
+
const ageAnchor = inFlight.acked_at ?? inFlight.created_at;
|
|
1543
|
+
const age = Date.now() - ageAnchor;
|
|
1544
|
+
if (age <= STALE_THRESHOLD_MS) {
|
|
1545
|
+
return {
|
|
1546
|
+
content: [{
|
|
1547
|
+
type: "text" as const,
|
|
1548
|
+
text: JSON.stringify({
|
|
1549
|
+
ok: false,
|
|
1550
|
+
error: "update_in_flight",
|
|
1551
|
+
existing_update_id: inFlight.update_id,
|
|
1552
|
+
age_ms: age,
|
|
1553
|
+
}),
|
|
1554
|
+
}],
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
// Stale — supersede.
|
|
1558
|
+
db.run(
|
|
1559
|
+
"UPDATE node_config_updates SET status = 'timeout', acked_at = ?1, error = ?2 WHERE update_id = ?3",
|
|
1560
|
+
[Date.now(), `superseded by new update after ${age}ms stale (> ${STALE_THRESHOLD_MS}ms threshold)`, inFlight.update_id],
|
|
1561
|
+
);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// Compute apply_mode + persist + push doorbell.
|
|
1565
|
+
const updateId = `cu_${uuidv4()}`;
|
|
1566
|
+
const applyMode = computeApplyMode(model, flags);
|
|
1567
|
+
const patchJson = JSON.stringify({ ...(model !== undefined ? { model } : {}), flags });
|
|
1568
|
+
const networkId = node.network_id || "default";
|
|
1569
|
+
db.run(
|
|
1570
|
+
`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)`,
|
|
1571
|
+
[updateId, nodeId, networkId, patchJson, applyMode, baseRev, Date.now(), callerTokenId || "unknown"],
|
|
1572
|
+
);
|
|
1573
|
+
|
|
1574
|
+
pushEvent(node.alias, { type: "config_update", update_id: updateId }, networkId);
|
|
1575
|
+
|
|
1576
|
+
return {
|
|
1577
|
+
content: [{
|
|
1578
|
+
type: "text" as const,
|
|
1579
|
+
text: JSON.stringify({ ok: true, update_id: updateId, apply_mode: applyMode }),
|
|
1580
|
+
}],
|
|
1581
|
+
};
|
|
1582
|
+
},
|
|
1583
|
+
);
|
|
1584
|
+
|
|
1585
|
+
server.tool(
|
|
1586
|
+
"get_config_update",
|
|
1587
|
+
"Node pulls its pending config update (called from agent-node when SSE config_update doorbell arrives). RFC-024.",
|
|
1588
|
+
{},
|
|
1589
|
+
async () => {
|
|
1590
|
+
// F-A (CHANGE_REQ): require ntok_ + non-null enforceNetworkId.
|
|
1591
|
+
// Mirror report_status's guard (tools.ts:251-253) — utok_ has
|
|
1592
|
+
// enforceNetworkId=null and callerAlias=username, so without
|
|
1593
|
+
// this gate a utok_ whose username happens to match a node alias
|
|
1594
|
+
// could pull that node's pending update across network scope
|
|
1595
|
+
// (network filter would be silently dropped, since old code had
|
|
1596
|
+
// a conditional WHERE). Hub doesn't trust upstream gates — every
|
|
1597
|
+
// node-private tool must independently require a network-bound
|
|
1598
|
+
// token.
|
|
1599
|
+
if (!callerTokenIsNetwork || !enforceNetworkId) {
|
|
1600
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
|
|
1601
|
+
}
|
|
1602
|
+
if (!callerAlias) {
|
|
1603
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
|
|
1604
|
+
}
|
|
1605
|
+
// Unconditional network_id filter (was previously conditional on
|
|
1606
|
+
// enforceNetworkId being set; the new ntok guard above guarantees
|
|
1607
|
+
// it's non-null so the filter is always applied).
|
|
1608
|
+
const node = db.get<any>(
|
|
1609
|
+
"SELECT node_id, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
1610
|
+
callerAlias,
|
|
1611
|
+
enforceNetworkId,
|
|
1612
|
+
);
|
|
1613
|
+
if (!node) {
|
|
1614
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update: null }) }] };
|
|
1615
|
+
}
|
|
1616
|
+
const update = db.get<any>(
|
|
1617
|
+
"SELECT update_id, patch_json, apply_mode, base_revision FROM node_config_updates WHERE node_id = ?1 AND status = 'pending' ORDER BY created_at DESC LIMIT 1",
|
|
1618
|
+
node.node_id,
|
|
1619
|
+
);
|
|
1620
|
+
if (!update) {
|
|
1621
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update: null }) }] };
|
|
1622
|
+
}
|
|
1623
|
+
let patch: any = {};
|
|
1624
|
+
try { patch = JSON.parse(update.patch_json); } catch { patch = {}; }
|
|
1625
|
+
return {
|
|
1626
|
+
content: [{
|
|
1627
|
+
type: "text" as const,
|
|
1628
|
+
text: JSON.stringify({
|
|
1629
|
+
ok: true,
|
|
1630
|
+
update: {
|
|
1631
|
+
update_id: update.update_id,
|
|
1632
|
+
patch,
|
|
1633
|
+
apply_mode: update.apply_mode,
|
|
1634
|
+
base_revision: update.base_revision,
|
|
1635
|
+
},
|
|
1636
|
+
}),
|
|
1637
|
+
}],
|
|
1638
|
+
};
|
|
1639
|
+
},
|
|
1640
|
+
);
|
|
1641
|
+
|
|
1642
|
+
server.tool(
|
|
1643
|
+
"ack_config_update",
|
|
1644
|
+
"Node acknowledges a config update — applied / rejected / restarting / timeout. RFC-024.",
|
|
1645
|
+
{
|
|
1646
|
+
update_id: z.string().min(1).max(200),
|
|
1647
|
+
status: z.enum(["applied", "rejected", "restarting", "timeout"]),
|
|
1648
|
+
new_revision: z.number().int().min(0).optional(),
|
|
1649
|
+
error: z.string().max(2000).optional(),
|
|
1650
|
+
},
|
|
1651
|
+
async ({ update_id: updateId, status, new_revision: newRev, error: ackError }) => {
|
|
1652
|
+
// F-A (CHANGE_REQ): same ntok_ guard as get_config_update. Without
|
|
1653
|
+
// this, a utok_ whose username matches a node alias could ack
|
|
1654
|
+
// arbitrary updates within the alias-collision; the new ntok guard
|
|
1655
|
+
// closes that.
|
|
1656
|
+
if (!callerTokenIsNetwork || !enforceNetworkId) {
|
|
1657
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
|
|
1658
|
+
}
|
|
1659
|
+
if (!callerAlias) {
|
|
1660
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
|
|
1661
|
+
}
|
|
1662
|
+
// Cross-tenant guard: the ack-er must own the update being acked.
|
|
1663
|
+
// Resolve node by caller's alias under the enforced network.
|
|
1664
|
+
// Network filter is unconditional (guard above guarantees non-null).
|
|
1665
|
+
const node = db.get<any>(
|
|
1666
|
+
"SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
|
|
1667
|
+
callerAlias,
|
|
1668
|
+
enforceNetworkId,
|
|
1669
|
+
);
|
|
1670
|
+
if (!node) {
|
|
1671
|
+
// Silently ignore stale ack — return ok so the node doesn't retry forever.
|
|
1672
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "alias_unknown" }) }] };
|
|
1673
|
+
}
|
|
1674
|
+
const update = db.get<any>(
|
|
1675
|
+
"SELECT update_id, node_id, status FROM node_config_updates WHERE update_id = ?1",
|
|
1676
|
+
updateId,
|
|
1677
|
+
);
|
|
1678
|
+
if (!update || update.node_id !== node.node_id) {
|
|
1679
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "unknown_or_foreign_update" }) }] };
|
|
1680
|
+
}
|
|
1681
|
+
// Reject ack for already-terminal updates (idempotency).
|
|
1682
|
+
if (update.status === "applied" || update.status === "rejected" || update.status === "timeout") {
|
|
1683
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "already_terminal", current_status: update.status }) }] };
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
const ackedAt = Date.now();
|
|
1687
|
+
if (status === "applied") {
|
|
1688
|
+
// Promote the node's config_revision to the new revision, atomically.
|
|
1689
|
+
const nextRev = (typeof newRev === "number" && newRev > 0) ? newRev : ((db.get<{ config_revision: number }>("SELECT config_revision FROM nodes WHERE node_id = ?1", node.node_id)?.config_revision || 0) + 1);
|
|
1690
|
+
db.run(
|
|
1691
|
+
`UPDATE node_config_updates SET status = 'applied', acked_at = ?1, new_revision = ?2 WHERE update_id = ?3`,
|
|
1692
|
+
[ackedAt, nextRev, updateId],
|
|
1693
|
+
);
|
|
1694
|
+
db.run(`UPDATE nodes SET config_revision = ?1 WHERE node_id = ?2`, [nextRev, node.node_id]);
|
|
1695
|
+
} else {
|
|
1696
|
+
db.run(
|
|
1697
|
+
`UPDATE node_config_updates SET status = ?1, acked_at = ?2, error = ?3 WHERE update_id = ?4`,
|
|
1698
|
+
[status, ackedAt, ackError || null, updateId],
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, status }) }] };
|
|
1702
|
+
},
|
|
1703
|
+
);
|
|
1704
|
+
|
|
1705
|
+
server.tool(
|
|
1706
|
+
"restart_node",
|
|
1707
|
+
"Trigger a node restart without changing config. RFC-024 Vincent 2026-06-28 increment. Network-scoped (SEC-1); member+ role suffices (lifecycle ops are not privilege elevation).",
|
|
1708
|
+
{
|
|
1709
|
+
node_id: z.string().min(1).max(200),
|
|
1710
|
+
network_id: z.string().max(200).optional(),
|
|
1711
|
+
},
|
|
1712
|
+
async ({ node_id: nodeId, network_id: clientNetId }) => {
|
|
1713
|
+
const effectiveNetId = getNetworkId(clientNetId);
|
|
1714
|
+
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
|
|
1715
|
+
|
|
1716
|
+
const { row: node, sec1Ok } = resolveTargetNode(nodeId, effectiveNetId);
|
|
1717
|
+
if (!node) {
|
|
1718
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "node_not_found", node_id: nodeId }) }] };
|
|
1719
|
+
}
|
|
1720
|
+
if (!sec1Ok) {
|
|
1721
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
|
|
1722
|
+
}
|
|
1723
|
+
// F-B reaper: same stale-supersede semantics as update_node_config,
|
|
1724
|
+
// with same acked_at-anchored liveness clock (see update_node_config
|
|
1725
|
+
// for the 通信龙 polish reasoning).
|
|
1726
|
+
const STALE_THRESHOLD_MS_R = 60_000;
|
|
1727
|
+
const inFlight = db.get<{ update_id: string; created_at: number; acked_at: number | null }>(
|
|
1728
|
+
"SELECT update_id, created_at, acked_at FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting') ORDER BY created_at DESC LIMIT 1",
|
|
1729
|
+
nodeId,
|
|
1730
|
+
);
|
|
1731
|
+
if (inFlight) {
|
|
1732
|
+
const ageAnchor = inFlight.acked_at ?? inFlight.created_at;
|
|
1733
|
+
const age = Date.now() - ageAnchor;
|
|
1734
|
+
if (age <= STALE_THRESHOLD_MS_R) {
|
|
1735
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "update_in_flight", existing_update_id: inFlight.update_id, age_ms: age }) }] };
|
|
1736
|
+
}
|
|
1737
|
+
db.run(
|
|
1738
|
+
"UPDATE node_config_updates SET status = 'timeout', acked_at = ?1, error = ?2 WHERE update_id = ?3",
|
|
1739
|
+
[Date.now(), `superseded by restart_node after ${age}ms stale`, inFlight.update_id],
|
|
1740
|
+
);
|
|
1741
|
+
}
|
|
1742
|
+
const updateId = `cu_${uuidv4()}`;
|
|
1743
|
+
const networkId = node.network_id || "default";
|
|
1744
|
+
db.run(
|
|
1745
|
+
`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)`,
|
|
1746
|
+
[updateId, nodeId, networkId, node.config_revision || 0, Date.now(), callerTokenId || "unknown"],
|
|
1747
|
+
);
|
|
1748
|
+
pushEvent(node.alias, { type: "restart", update_id: updateId }, networkId);
|
|
1749
|
+
return {
|
|
1750
|
+
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update_id: updateId, apply_mode: "restart_only" }) }],
|
|
1751
|
+
};
|
|
1752
|
+
},
|
|
1753
|
+
);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1757
|
+
// PR A SEC follow-up (#287 cross-tenant trust-root catch, 通信牛
|
|
1758
|
+
// 2026-06-28) — exported so the production report_status path AND
|
|
1759
|
+
// the regression test in config-apply-sec1.test.ts exercise the SAME
|
|
1760
|
+
// code. Per 通信龙 test-quality finding: an inline-mirror test that
|
|
1761
|
+
// re-implements the gate inside the test body provides zero
|
|
1762
|
+
// protection against guard drift. By forcing both code paths through
|
|
1763
|
+
// this single helper, deleting / weakening the gate fails the test.
|
|
1764
|
+
//
|
|
1765
|
+
// Returns a discriminated outcome so callers can log/route the refused
|
|
1766
|
+
// case (production: silent skip + console.warn; tests: assertion).
|
|
1767
|
+
// ────────────────────────────────────────────────────────────────────
|
|
1768
|
+
export interface UpsertNodeWithSec1GuardInput {
|
|
1769
|
+
node_id: string;
|
|
1770
|
+
callerNetworkId: string | null;
|
|
1771
|
+
node_name?: string | null;
|
|
1772
|
+
alias?: string | null;
|
|
1773
|
+
runtime?: string | null;
|
|
1774
|
+
model?: string | null;
|
|
1775
|
+
config_path?: string | null;
|
|
1776
|
+
channels?: string | null;
|
|
1777
|
+
server?: string | null;
|
|
1778
|
+
hostname?: string | null;
|
|
1779
|
+
config_snapshot?: unknown | null;
|
|
1780
|
+
}
|
|
1781
|
+
export type UpsertNodeOutcome =
|
|
1782
|
+
| { result: "inserted" | "updated"; node_id: string }
|
|
1783
|
+
| { result: "refused"; reason: "cross_network"; existingNet: string | null; callerNet: string | null }
|
|
1784
|
+
| { result: "skipped"; reason: "missing_node_id" };
|
|
1785
|
+
|
|
1786
|
+
const _norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x);
|
|
1787
|
+
|
|
1788
|
+
export function upsertNodeWithSec1Guard(input: UpsertNodeWithSec1GuardInput): UpsertNodeOutcome {
|
|
1789
|
+
if (!input.node_id) return { result: "skipped", reason: "missing_node_id" };
|
|
1790
|
+
const existing = db.get<{ network_id: string | null }>(
|
|
1791
|
+
"SELECT network_id FROM nodes WHERE node_id = ?1",
|
|
1792
|
+
input.node_id,
|
|
1793
|
+
);
|
|
1794
|
+
const callerNet = input.callerNetworkId;
|
|
1795
|
+
|
|
1796
|
+
// Legacy / first-write paths: row missing OR network_id NULL → claim.
|
|
1797
|
+
const isLegacy = !existing
|
|
1798
|
+
|| existing.network_id === null
|
|
1799
|
+
|| existing.network_id === undefined;
|
|
1800
|
+
const sec1Ok = isLegacy || _norm(existing.network_id) === _norm(callerNet);
|
|
1801
|
+
|
|
1802
|
+
if (!sec1Ok) {
|
|
1803
|
+
console.warn(
|
|
1804
|
+
`[commhub] 🚫 report_status cross-network node upsert refused: caller-net=${callerNet ?? "default"} existing-net=${existing!.network_id} node_id=${input.node_id}`,
|
|
1805
|
+
);
|
|
1806
|
+
return {
|
|
1807
|
+
result: "refused",
|
|
1808
|
+
reason: "cross_network",
|
|
1809
|
+
existingNet: existing!.network_id,
|
|
1810
|
+
callerNet,
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
db.run(
|
|
1815
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, network_id, updated_at)
|
|
1816
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, datetime('now'))
|
|
1817
|
+
ON CONFLICT(node_id) DO UPDATE SET
|
|
1818
|
+
node_name = COALESCE(?2, nodes.node_name),
|
|
1819
|
+
alias = COALESCE(?3, nodes.alias),
|
|
1820
|
+
runtime = COALESCE(?4, nodes.runtime),
|
|
1821
|
+
model = COALESCE(?5, nodes.model),
|
|
1822
|
+
config_path = COALESCE(?6, nodes.config_path),
|
|
1823
|
+
channels = COALESCE(?7, nodes.channels),
|
|
1824
|
+
server = COALESCE(?8, nodes.server),
|
|
1825
|
+
hostname = COALESCE(?9, nodes.hostname),
|
|
1826
|
+
network_id = COALESCE(?10, nodes.network_id),
|
|
1827
|
+
updated_at = datetime('now')`,
|
|
1828
|
+
[
|
|
1829
|
+
input.node_id,
|
|
1830
|
+
input.node_name ?? input.alias ?? null,
|
|
1831
|
+
input.alias ?? null,
|
|
1832
|
+
input.runtime ?? null,
|
|
1833
|
+
input.model ?? null,
|
|
1834
|
+
input.config_path ?? null,
|
|
1835
|
+
input.channels ?? null,
|
|
1836
|
+
input.server ?? null,
|
|
1837
|
+
input.hostname ?? null,
|
|
1838
|
+
callerNet ?? null,
|
|
1839
|
+
],
|
|
1840
|
+
);
|
|
1841
|
+
if (input.config_snapshot) {
|
|
1842
|
+
db.run(
|
|
1843
|
+
`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
|
|
1844
|
+
[JSON.stringify(input.config_snapshot), input.node_id],
|
|
1845
|
+
);
|
|
1846
|
+
}
|
|
1847
|
+
return { result: existing ? "updated" : "inserted", node_id: input.node_id };
|
|
1315
1848
|
}
|