// SEC-1 cross-tenant write防护带 for RFC-024 config-apply. // // Mirrors src/cross-tenant-injection.test.ts pattern from PR #275 — drives // the SQL queries the tool handlers run, against a real in-process SQLite, // to prove the network-scope guard rejects cross-tenant writes against // persisted rows. Pure SQL-level regression guard; the MCP tool-handler // layer is the same logic wrapped in JSON-RPC. // // What's gated here: // - update_node_config / restart_node: hub-side SELECT node.network_id // check before INSERT into node_config_updates // - get_config_update: caller-alias-to-node-network match // - ack_config_update: same match on ack path // // Run with: COMMHUB_DB=/tmp/sec1-test.db bun test src/config-apply-sec1.test.ts import { describe, expect, test, beforeEach } from "bun:test"; import { db, uuidv4 } from "./db.js"; import { upsertNodeWithSec1Guard, finalizePendingMatchingUpdates } from "./tools.js"; function insertNode(opts: { node_id?: string; alias: string; network_id: string | null }): string { const id = opts.node_id ?? uuidv4(); db.run( `INSERT INTO nodes (node_id, node_name, alias, network_id, config_revision) VALUES (?1, ?2, ?3, ?4, 0)`, [id, opts.alias, opts.alias, opts.network_id], ); return id; } function insertUpdate(opts: { update_id?: string; node_id: string; network_id: string; status?: string; patch?: any; }): string { const id = opts.update_id ?? `cu_${uuidv4()}`; db.run( `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, 'hot', 0, ?5, ?6, 'test')`, [id, opts.node_id, opts.network_id, JSON.stringify(opts.patch ?? {}), opts.status ?? "pending", Date.now()], ); return id; } beforeEach(() => { db.run("DELETE FROM node_config_updates"); db.run("DELETE FROM nodes"); }); describe("SEC-1 — resolveTargetNode-equivalent SELECT respects network_id", () => { // This is the exact query update_node_config / restart_node run // to fetch the target node + check network match. Pinning the SQL // behaviour so a future "convenience" change can't drop the network // scope (the way the pre-#275 inferred-parent SELECT did). test("node in netA: SELECT by node_id returns network_id=netA", () => { const nid = insertNode({ alias: "test-node-A", network_id: "netA" }); const row = db.get( "SELECT node_id, alias, network_id, config_revision FROM nodes WHERE node_id = ?1", nid, ); expect(row?.network_id).toBe("netA"); }); test("netB caller checking a netA node: handler decides cross_network_node", () => { const nid = insertNode({ alias: "node-A-only", network_id: "netA" }); const row = db.get( "SELECT node_id, network_id FROM nodes WHERE node_id = ?1", nid, ); const callerNet = "netB"; const nodeNet = row.network_id || "default"; const sec1Ok = nodeNet === (callerNet || "default"); expect(sec1Ok).toBe(false); // SEC-1 fires, cross-tenant write would reject }); test("same-network caller (netA → netA node): SEC-1 passes", () => { const nid = insertNode({ alias: "node-A", network_id: "netA" }); const row = db.get("SELECT network_id FROM nodes WHERE node_id = ?1", nid); const callerNet = "netA"; const sec1Ok = (row.network_id || "default") === (callerNet || "default"); expect(sec1Ok).toBe(true); }); test("default-network caller cannot touch named-network node", () => { const nid = insertNode({ alias: "node-net1", network_id: "net1" }); const row = db.get("SELECT network_id FROM nodes WHERE node_id = ?1", nid); const callerNet = null; // default const sec1Ok = (row.network_id || "default") === (callerNet || "default"); expect(sec1Ok).toBe(false); }); test("named-network caller cannot touch default-network node", () => { const nid = insertNode({ alias: "node-default", network_id: null }); const row = db.get("SELECT network_id FROM nodes WHERE node_id = ?1", nid); const callerNet = "netZ"; const sec1Ok = (row.network_id || "default") === (callerNet || "default"); expect(sec1Ok).toBe(false); }); }); describe("SEC-1 — get_config_update / ack_config_update by-alias filter respects network_id", () => { // The node-pull path resolves the node by alias + network. Pinning // that the WHERE clause excludes other networks' nodes even if the // alias happens to collide (rare but possible — nodes table allows // same alias across networks per the V3 schema). test("two nodes with same alias in two networks → query with network_id returns only the matching one", () => { insertNode({ alias: "same-alias", network_id: "netA" }); insertNode({ alias: "same-alias", network_id: "netB" }); const rowA = db.get( "SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2", "same-alias", "netA", ); const rowB = db.get( "SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2", "same-alias", "netB", ); expect(rowA?.node_id).not.toBe(rowB?.node_id); }); test("node-pull get_config_update: netA node ntok_ cannot fetch netB node's pending update", () => { const nodeA = insertNode({ alias: "puller-A", network_id: "netA" }); const nodeB = insertNode({ alias: "puller-B", network_id: "netB" }); insertUpdate({ node_id: nodeB, network_id: "netB", status: "pending", patch: { flags: { maxTurns: 100 } } }); // Simulate: ntok_ resolved to callerAlias="puller-A", enforceNetworkId="netA" // get_config_update SELECTs node by (alias, network_id), then SELECTs update by node.node_id const callerNode = db.get( "SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2", "puller-A", "netA", ); expect(callerNode?.node_id).toBe(nodeA); const update = db.get( "SELECT update_id FROM node_config_updates WHERE node_id = ?1 AND status = 'pending' ORDER BY created_at DESC LIMIT 1", callerNode.node_id, ); // No update for nodeA (the one in netB is not visible) expect(update == null).toBe(true); }); test("ack_config_update: netA node cannot ack netB node's update", () => { const nodeA = insertNode({ alias: "acker-A", network_id: "netA" }); const nodeB = insertNode({ alias: "acker-B", network_id: "netB" }); const updId = insertUpdate({ node_id: nodeB, network_id: "netB", status: "pending" }); // Simulate netA node trying to ack updId. Handler resolves caller alias // -> node row by (alias, network_id), then verifies update.node_id matches. const callerNode = db.get( "SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2", "acker-A", "netA", ); expect(callerNode?.node_id).toBe(nodeA); const update = db.get( "SELECT update_id, node_id FROM node_config_updates WHERE update_id = ?1", updId, ); const ownershipMatch = update?.node_id === callerNode?.node_id; expect(ownershipMatch).toBe(false); // would surface as ignored "unknown_or_foreign_update" }); test("ack: same-node ack passes ownership check", () => { const nid = insertNode({ alias: "self-ack", network_id: "netA" }); const updId = insertUpdate({ node_id: nid, network_id: "netA", status: "pending" }); const callerNode = db.get( "SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2", "self-ack", "netA", ); const update = db.get( "SELECT update_id, node_id FROM node_config_updates WHERE update_id = ?1", updId, ); expect(update?.node_id).toBe(callerNode?.node_id); }); }); describe("F-B (stale-update reaper) — single-flight TTL prevents permanent lock-out", () => { // Pinning the stale-supersede semantics: an in-flight row older than // 60_000 ms (2× apply ceiling) is treated as stale and a new update // can supersede it (marks old as timeout). Without this, a crashed // node ack'd "restarting" would brick remote restart forever. test("fresh in-flight (< 60s old) → blocks new update (single-flight)", () => { const nid = insertNode({ alias: "fresh", network_id: "netA" }); // Insert with a created_at recent (< 60s). const fresh = `cu_${uuidv4()}`; db.run( `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', 0, 'restarting', ?4, 'test')`, [fresh, nid, "netA", Date.now() - 10_000], ); const STALE = 60_000; const row = db.get( "SELECT update_id, created_at FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting') ORDER BY created_at DESC LIMIT 1", nid, ); const age = Date.now() - row.created_at; expect(age).toBeLessThan(STALE); }); test("stale in-flight (> 60s old) → can be superseded (marked timeout)", () => { const nid = insertNode({ alias: "stale", network_id: "netA" }); const stale = `cu_${uuidv4()}`; db.run( `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', 0, 'restarting', ?4, 'test')`, [stale, nid, "netA", Date.now() - 120_000], // 2 min ago ); const STALE = 60_000; const row = db.get( "SELECT update_id, created_at FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting') ORDER BY created_at DESC LIMIT 1", nid, ); const age = Date.now() - row.created_at; expect(age).toBeGreaterThan(STALE); // Simulate the supersede operation done by the tool handler. db.run( "UPDATE node_config_updates SET status = 'timeout', acked_at = ?1, error = 'superseded' WHERE update_id = ?2", [Date.now(), stale], ); // After supersede, no in-flight remains. const remaining = db.get( "SELECT update_id FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting')", nid, ); expect(remaining == null).toBe(true); }); }); describe("SEC trust-root — report_status node upsert cannot re-home cross-tenant (#287 通信牛 catch)", () => { // 通信牛 catch on #287: report_status is a heartbeat called by every // ntok_'d node, and it upserts the nodes table with caller-supplied // node_id + caller's network_id. The old `ON CONFLICT DO UPDATE` blob // unconditionally set `network_id = COALESCE(?, nodes.network_id)`, // which would re-home an existing row if the caller's network differed. // Since resolveTargetNode (in config-apply tools) reads nodes.network_id // to authorize writes, that re-home would let a foreign network become // "authorized" to flip the row's config — defeating the cross-tenant // 防护带 from PR #275. // // Fix: SELECT-then-decide. If existing.network_id is set AND differs // from caller's effectiveNetId, skip the upsert entirely. These tests // pin the SELECT semantics at the SQL layer. test("netA caller checking a netB-owned node_id: SEC-1 verdict = NOT-OK (refuse upsert)", () => { const nid = insertNode({ alias: "victim-net-b", network_id: "netB" }); const callerNet = "netA"; const existing = db.get<{ network_id: string | null }>( "SELECT network_id FROM nodes WHERE node_id = ?1", nid, ); const norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x); const sec1Ok = !existing || existing.network_id === null || existing.network_id === undefined || norm(existing.network_id) === norm(callerNet); expect(sec1Ok).toBe(false); }); test("netB caller on its own node_id: SEC-1 verdict = OK", () => { const nid = insertNode({ alias: "owner-net-b", network_id: "netB" }); const callerNet = "netB"; const existing = db.get<{ network_id: string | null }>( "SELECT network_id FROM nodes WHERE node_id = ?1", nid, ); const norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x); const sec1Ok = norm(existing?.network_id) === norm(callerNet); expect(sec1Ok).toBe(true); }); test("legacy row (network_id NULL) — first network to claim it wins (bootstrap)", () => { const nid = insertNode({ alias: "legacy", network_id: null }); const callerNet = "netA"; const existing = db.get<{ network_id: string | null }>( "SELECT network_id FROM nodes WHERE node_id = ?1", nid, ); // Legacy row with NULL → caller's report_status would set it to netA. const sec1Ok = !existing || existing.network_id === null || existing.network_id === undefined; expect(sec1Ok).toBe(true); }); test("default-network caller against named-network node_id → refused (no implicit promotion)", () => { const nid = insertNode({ alias: "named-only", network_id: "netA" }); const callerNet: string | null = null; // default const existing = db.get<{ network_id: string | null }>( "SELECT network_id FROM nodes WHERE node_id = ?1", nid, ); const norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x); const sec1Ok = !existing || existing.network_id === null || norm(existing.network_id) === norm(callerNet); expect(sec1Ok).toBe(false); }); test("named-network caller against default-network node_id → refused", () => { const nid = insertNode({ alias: "default-only", network_id: null }); // After bootstrap (caller netA claimed), set the row to a third // network so this test isolates the refuse path. db.run("UPDATE nodes SET network_id = ?1 WHERE node_id = ?2", ["netZ", nid]); const callerNet = "netA"; const existing = db.get<{ network_id: string | null }>( "SELECT network_id FROM nodes WHERE node_id = ?1", nid, ); const norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x); const sec1Ok = norm(existing?.network_id) === norm(callerNet); expect(sec1Ok).toBe(false); }); test("after cross-tenant attempt is refused, victim row's network_id is unchanged", () => { const nid = insertNode({ alias: "stable-net-b", network_id: "netB" }); const callerNet = "netA"; const existing = db.get<{ network_id: string | null }>( "SELECT network_id FROM nodes WHERE node_id = ?1", nid, ); const norm = (x: string | null | undefined) => (x === null || x === undefined ? "default" : x); const sec1Ok = norm(existing?.network_id) === norm(callerNet); expect(sec1Ok).toBe(false); // Re-read to confirm no mutation happened (we never wrote in this // helper; the production guard would have refused the UPDATE). const after = db.get<{ network_id: string | null }>( "SELECT network_id FROM nodes WHERE node_id = ?1", nid, ); expect(after?.network_id).toBe("netB"); }); }); // ───────────────────────────────────────────────────────────────────── // Real-driven regression test (per 通信龙 test-quality finding 2026-06-28): // the 6 SEC trust-root tests above mirror the gate inline (cheap to write, // zero protection against guard drift). This block calls the SAME helper // that production report_status delegates to — if anyone weakens or // deletes the gate, this fails. // ───────────────────────────────────────────────────────────────────── describe("SEC trust-root — REAL driver via upsertNodeWithSec1Guard (catches guard drift)", () => { test("netA caller cannot mutate netB-owned node row (production path)", () => { const nid = insertNode({ alias: "victim", network_id: "netB" }); // Pre-state: row owned by netB. const pre = db.get( "SELECT alias, network_id, config_snapshot FROM nodes WHERE node_id = ?1", nid, ); expect(pre.network_id).toBe("netB"); expect(pre.alias).toBe("victim"); expect(pre.config_snapshot).toBeNull(); // Attack: netA caller (effectiveNetId="netA") tries to upsert with // a different alias + a forged config snapshot. const outcome = upsertNodeWithSec1Guard({ node_id: nid, callerNetworkId: "netA", alias: "attacker-alias", model: "attacker-model", config_snapshot: { model: "attacker", flags: { dangerouslySkipPermissions: true } }, }); // Production path returns refused (NOT inserted/updated). expect(outcome.result).toBe("refused"); if (outcome.result === "refused") { expect(outcome.reason).toBe("cross_network"); expect(outcome.existingNet).toBe("netB"); expect(outcome.callerNet).toBe("netA"); } // End-state: row IS UNCHANGED — same alias, same network, no // forged snapshot leaked in. If the gate were removed, alias would // be "attacker-alias", model would be "attacker-model", and // config_snapshot would be the forged payload. This is the // assertion that drift would break. const post = db.get( "SELECT alias, network_id, model, config_snapshot FROM nodes WHERE node_id = ?1", nid, ); expect(post.network_id).toBe("netB"); expect(post.alias).toBe("victim"); expect(post.model).toBeNull(); expect(post.config_snapshot).toBeNull(); }); test("netB caller updating its own node row → production path returns updated", () => { const nid = insertNode({ alias: "owner-self", network_id: "netB" }); const outcome = upsertNodeWithSec1Guard({ node_id: nid, callerNetworkId: "netB", alias: "owner-self", model: "new-model", config_snapshot: { model: "new-model", flags: { maxTurns: 99 } }, }); expect(outcome.result).toBe("updated"); const post = db.get( "SELECT alias, network_id, model, config_snapshot FROM nodes WHERE node_id = ?1", nid, ); expect(post.network_id).toBe("netB"); expect(post.model).toBe("new-model"); expect(post.config_snapshot).toContain("maxTurns"); }); test("legacy NULL row → first caller claims (bootstrap, production path)", () => { const nid = insertNode({ alias: "legacy-row", network_id: null }); const outcome = upsertNodeWithSec1Guard({ node_id: nid, callerNetworkId: "netA", alias: "legacy-row", }); // sec1Ok is true (existing.network_id === null) → updated. expect(outcome.result).toBe("updated"); const post = db.get<{ network_id: string | null }>( "SELECT network_id FROM nodes WHERE node_id = ?1", nid, ); expect(post?.network_id).toBe("netA"); }); test("first write of a new node_id → inserted (callerNet becomes owner)", () => { const nid = `node_${uuidv4()}`; const outcome = upsertNodeWithSec1Guard({ node_id: nid, callerNetworkId: "netC", alias: "freshman", }); expect(outcome.result).toBe("inserted"); const post = db.get( "SELECT network_id, alias FROM nodes WHERE node_id = ?1", nid, ); expect(post.network_id).toBe("netC"); expect(post.alias).toBe("freshman"); }); test("default-network caller against named-network node → refused (no implicit promotion)", () => { const nid = insertNode({ alias: "named-only", network_id: "netA" }); const outcome = upsertNodeWithSec1Guard({ node_id: nid, callerNetworkId: null, // default alias: "implicit-promote-attempt", }); expect(outcome.result).toBe("refused"); const post = db.get("SELECT network_id, alias FROM nodes WHERE node_id = ?1", nid); expect(post.network_id).toBe("netA"); expect(post.alias).toBe("named-only"); }); }); // ───────────────────────────────────────────────────────────────────── // finalizePendingMatchingUpdates — Option A restart-finalize matcher // (通信牛 #290 final CHANGE_REQ 2026-06-28). Real-driver tests: drive // the helper that production report_status delegates to + assert on // the persisted state. Same single-source-of-truth pattern as // upsertNodeWithSec1Guard (prevents inline-mirror test drift). // ───────────────────────────────────────────────────────────────────── describe("finalizePendingMatchingUpdates — Option A restart-finalize via content match", () => { function insertPendingUpdate(opts: { update_id?: string; node_id: string; network_id: string; patch: any; apply_mode: "hot" | "restart" | "restart_only"; status?: "pending" | "restarting"; }): string { const id = opts.update_id ?? `cu_${uuidv4()}`; db.run( `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, 0, ?6, ?7, 'test')`, [id, opts.node_id, opts.network_id, JSON.stringify(opts.patch), opts.apply_mode, opts.status ?? "pending", Date.now()], ); return id; } test("empty patch (restart_only) → finalizes on ANY snapshot (restart itself = goal)", () => { const nid = insertNode({ alias: "n1", network_id: "netA" }); const uid = insertPendingUpdate({ node_id: nid, network_id: "netA", patch: {}, apply_mode: "restart_only", status: "restarting", }); const r = finalizePendingMatchingUpdates(nid, { model: "any", flags: {} }); expect(r.finalizedCount).toBe(1); expect(r.finalizedIds).toContain(uid); const row = db.get("SELECT status, new_revision FROM node_config_updates WHERE update_id = ?1", uid); expect(row.status).toBe("applied"); expect(row.new_revision).toBe(1); const node = db.get("SELECT config_revision FROM nodes WHERE node_id = ?1", nid); expect(node.config_revision).toBe(1); }); test("model patch + snapshot.model matches → finalized", () => { const nid = insertNode({ alias: "n2", network_id: "netA" }); const uid = insertPendingUpdate({ node_id: nid, network_id: "netA", patch: { model: "claude-opus-4-new", flags: {} }, apply_mode: "restart", }); const r = finalizePendingMatchingUpdates(nid, { model: "claude-opus-4-new", flags: { maxTurns: 50 }, }); expect(r.finalizedCount).toBe(1); expect(db.get("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("applied"); }); test("model patch + snapshot.model DIFFERENT → NOT finalized (left pending)", () => { const nid = insertNode({ alias: "n3", network_id: "netA" }); const uid = insertPendingUpdate({ node_id: nid, network_id: "netA", patch: { model: "claude-opus-4-new", flags: {} }, apply_mode: "restart", }); const r = finalizePendingMatchingUpdates(nid, { model: "claude-sonnet-3-old", flags: {}, }); expect(r.finalizedCount).toBe(0); expect(db.get("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("pending"); }); test("flags patch + every flag matches snapshot → finalized", () => { const nid = insertNode({ alias: "n4", network_id: "netA" }); const uid = insertPendingUpdate({ node_id: nid, network_id: "netA", patch: { flags: { maxTurns: 99, budget: 500 } }, apply_mode: "hot", }); const r = finalizePendingMatchingUpdates(nid, { flags: { maxTurns: 99, budget: 500, dangerouslySkipPermissions: true }, }); expect(r.finalizedCount).toBe(1); expect(db.get("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("applied"); }); test("flags patch + ONE flag mismatch → NOT finalized (any field absent = no match)", () => { const nid = insertNode({ alias: "n5", network_id: "netA" }); const uid = insertPendingUpdate({ node_id: nid, network_id: "netA", patch: { flags: { maxTurns: 99, budget: 500 } }, apply_mode: "hot", }); const r = finalizePendingMatchingUpdates(nid, { flags: { maxTurns: 99, budget: 999 }, // budget wrong }); expect(r.finalizedCount).toBe(0); expect(db.get("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("pending"); }); test("flags patch + flag absent from snapshot → NOT finalized", () => { const nid = insertNode({ alias: "n6", network_id: "netA" }); const uid = insertPendingUpdate({ node_id: nid, network_id: "netA", patch: { flags: { permissionMode: "auto" } }, apply_mode: "restart", }); const r = finalizePendingMatchingUpdates(nid, { flags: { maxTurns: 50 }, // permissionMode missing entirely }); expect(r.finalizedCount).toBe(0); expect(db.get("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("pending"); }); test("no pending update → no-op (idempotent on routine heartbeats)", () => { const nid = insertNode({ alias: "n7", network_id: "netA" }); const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: { maxTurns: 50 } }); expect(r.finalizedCount).toBe(0); }); test("restarting status finalizes too (not just pending)", () => { const nid = insertNode({ alias: "n8", network_id: "netA" }); const uid = insertPendingUpdate({ node_id: nid, network_id: "netA", patch: { model: "new" }, apply_mode: "restart", status: "restarting", }); const r = finalizePendingMatchingUpdates(nid, { model: "new", flags: {} }); expect(r.finalizedCount).toBe(1); expect(db.get("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("applied"); }); test("two nodes with separate pending updates → finalize only matching one", () => { const nidA = insertNode({ alias: "match-this", network_id: "netA" }); const nidB = insertNode({ alias: "leave-alone", network_id: "netA" }); const uidA = insertPendingUpdate({ node_id: nidA, network_id: "netA", patch: { model: "matched-model" }, apply_mode: "restart", }); const uidB = insertPendingUpdate({ node_id: nidB, network_id: "netA", patch: { model: "different-model" }, apply_mode: "restart", }); finalizePendingMatchingUpdates(nidA, { model: "matched-model", flags: {} }); expect(db.get("SELECT status FROM node_config_updates WHERE update_id = ?1", uidA).status).toBe("applied"); expect(db.get("SELECT status FROM node_config_updates WHERE update_id = ?1", uidB).status).toBe("pending"); }); test("config_revision bumps to base+1 for single finalize (multi-pending blocked by F-C index)", () => { // F-C partial unique index (db.ts) prevents two non-terminal updates // for the same node, so the multi-finalize scenario is unreachable // at the DB layer. This test pins that ONE pending finalize → revision = base + 1. const nid = insertNode({ alias: "n10", network_id: "netA" }); insertPendingUpdate({ node_id: nid, network_id: "netA", patch: { model: "v1" }, apply_mode: "restart", }); const r = finalizePendingMatchingUpdates(nid, { model: "v1", flags: {} }); expect(r.finalizedCount).toBe(1); expect(db.get("SELECT config_revision FROM nodes WHERE node_id = ?1", nid).config_revision).toBe(1); }); }); describe("F-B polish — reaper anchors on COALESCE(acked_at, created_at), not created_at", () => { // 通信龙 catch: reaper threshold = 60s and drain hard-cap = 60s overlap. // A healthy-but-slow restart (drain 60s + respawn time) could exceed // the threshold if anchored on created_at alone. Anchoring on acked_at // means "node ack'd restarting → liveness clock refreshes" so an // in-progress restart isn't falsely reaped. test("row with old created_at + recent acked_at → fresh (age from acked_at)", () => { const nid = insertNode({ alias: "slow-restart", network_id: "netA" }); const uid = `cu_${uuidv4()}`; const ancientCreatedAt = Date.now() - 120_000; // 2 min ago const recentAckedAt = Date.now() - 30_000; // 30s ago db.run( `INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token, acked_at) VALUES (?1, ?2, ?3, '{}', 'restart_only', 0, 'restarting', ?4, 'test', ?5)`, [uid, nid, "netA", ancientCreatedAt, recentAckedAt], ); const STALE = 60_000; const row = db.get( "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", nid, ); const anchor = row.acked_at ?? row.created_at; const age = Date.now() - anchor; // age is from acked_at (~30s), NOT from created_at (~120s). expect(age).toBeLessThan(STALE); expect(age).toBeGreaterThan(20_000); // sanity: roughly 30s }); test("row with no acked_at + old created_at → stale (falls back to created_at)", () => { const nid = insertNode({ alias: "never-acked", network_id: "netA" }); const uid = `cu_${uuidv4()}`; db.run( `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', 0, 'pending', ?4, 'test')`, [uid, nid, "netA", Date.now() - 120_000], ); const STALE = 60_000; const row = db.get( "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", nid, ); const anchor = row.acked_at ?? row.created_at; const age = Date.now() - anchor; expect(age).toBeGreaterThan(STALE); // no ack → falls back to created_at }); test("row with old acked_at (node ack'd then crashed) → stale", () => { const nid = insertNode({ alias: "crashed-after-ack", network_id: "netA" }); const uid = `cu_${uuidv4()}`; db.run( `INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token, acked_at) VALUES (?1, ?2, ?3, '{}', 'restart_only', 0, 'restarting', ?4, 'test', ?5)`, [uid, nid, "netA", Date.now() - 200_000, Date.now() - 90_000], ); const STALE = 60_000; const row = db.get( "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", nid, ); const anchor = row.acked_at ?? row.created_at; const age = Date.now() - anchor; expect(age).toBeGreaterThan(STALE); // ack'd 90s ago > 60s threshold }); }); describe("F-C (partial unique index) — DB-layer single-flight regardless of process count", () => { test("INSERT a second pending row for same node fails with UNIQUE constraint", () => { const nid = insertNode({ alias: "double", network_id: "netA" }); insertUpdate({ node_id: nid, network_id: "netA", status: "pending" }); let err: any = null; try { insertUpdate({ node_id: nid, network_id: "netA", status: "pending" }); } catch (e: any) { err = e; } expect(err).not.toBeNull(); expect(String(err?.message || "")).toMatch(/UNIQUE|constraint/i); }); test("INSERT a new pending after a TERMINAL one succeeds (partial index excludes terminal)", () => { const nid = insertNode({ alias: "after-applied", network_id: "netA" }); insertUpdate({ node_id: nid, network_id: "netA", status: "applied" }); // Should succeed — terminal rows don't occupy the partial index slot. expect(() => insertUpdate({ node_id: nid, network_id: "netA", status: "pending" })).not.toThrow(); }); }); describe("SEC-1 — update lifecycle: single-flight + cross-network pending lookup", () => { test("netA pending update + netA query for same-node in-flight → finds it", () => { const nid = insertNode({ alias: "n1", network_id: "netA" }); const uid = insertUpdate({ node_id: nid, network_id: "netA", status: "pending" }); const inFlight = db.get( "SELECT update_id FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting')", nid, ); expect(inFlight?.update_id).toBe(uid); }); test("two updates same node — terminal one does NOT block new one (single-flight only on non-terminal)", () => { const nid = insertNode({ alias: "n2", network_id: "netA" }); insertUpdate({ node_id: nid, network_id: "netA", status: "applied" }); const inFlight = db.get( "SELECT update_id FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting')", nid, ); expect(inFlight == null).toBe(true); // no in-flight, write would be allowed }); test("restarting status counts as in-flight (single-flight gate)", () => { const nid = insertNode({ alias: "n3", network_id: "netA" }); insertUpdate({ node_id: nid, network_id: "netA", status: "restarting" }); const inFlight = db.get( "SELECT update_id FROM node_config_updates WHERE node_id = ?1 AND status IN ('pending', 'restarting')", nid, ); expect(inFlight).toBeDefined(); }); }); // ── #260 P5 codex-catch regression: channels finalize ───────────── describe("finalizePendingMatchingUpdates — #260 P5 channels content-match", () => { function insertPendingUpdate(opts: { update_id?: string; node_id: string; network_id: string; patch: any; apply_mode: "hot" | "restart" | "restart_only"; status?: "pending" | "restarting"; }): string { const id = opts.update_id ?? `cu_${uuidv4()}`; db.run( `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, 0, ?6, ?7, 'test')`, [id, opts.node_id, opts.network_id, JSON.stringify(opts.patch), opts.apply_mode, opts.status ?? "pending", Date.now()], ); return id; } test("channels-only patch: snapshot BEFORE apply (first boot) must NOT finalize", () => { const nid = insertNode({ alias: "n-chan-1", network_id: "netA" }); const uid = insertPendingUpdate({ node_id: nid, network_id: "netA", // Note the empty flags object — the pre-fix bug was that this // trivially matched the flags loop and hub falsely finalized. patch: { flags: {}, channels: ["feishu"] }, apply_mode: "restart", }); // Node's startup snapshot BEFORE it consumed the update — no // channels forked yet, so buildConfigSnapshot emits channels=[]. const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: [] }); expect(r.finalizedCount).toBe(0); expect(db.get("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("pending"); }); test("channels-only patch: snapshot AFTER apply (restart complete) finalizes", () => { const nid = insertNode({ alias: "n-chan-2", network_id: "netA" }); const uid = insertPendingUpdate({ node_id: nid, network_id: "netA", patch: { flags: {}, channels: ["feishu"] }, apply_mode: "restart", }); const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: ["feishu"] }); expect(r.finalizedCount).toBe(1); expect(r.finalizedIds).toContain(uid); expect(db.get("SELECT status FROM node_config_updates WHERE update_id = ?1", uid).status).toBe("applied"); }); test("channels set-equality: order doesn't matter", () => { const nid = insertNode({ alias: "n-chan-3", network_id: "netA" }); insertPendingUpdate({ node_id: nid, network_id: "netA", patch: { flags: {}, channels: ["telegram", "feishu"] }, apply_mode: "restart", }); // Snapshot order swapped — still matches. const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: ["feishu", "telegram"] }); expect(r.finalizedCount).toBe(1); }); test("channels: [] disable-all — finalize when snapshot also [] (post-restart)", () => { const nid = insertNode({ alias: "n-chan-4", network_id: "netA" }); insertPendingUpdate({ node_id: nid, network_id: "netA", patch: { flags: {}, channels: [] }, apply_mode: "restart", }); const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: [] }); expect(r.finalizedCount).toBe(1); }); test("channels: [] disable-all — do NOT finalize when snapshot still has channels", () => { const nid = insertNode({ alias: "n-chan-5", network_id: "netA" }); insertPendingUpdate({ node_id: nid, network_id: "netA", patch: { flags: {}, channels: [] }, apply_mode: "restart", }); // Node hasn't restarted yet — old telegram worker still forked. const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {}, channels: ["telegram"] }); expect(r.finalizedCount).toBe(0); }); test("snapshot omits channels field entirely (pre-P5 agent-node) → NOT finalized on channels-carrying patch", () => { const nid = insertNode({ alias: "n-chan-6", network_id: "netA" }); insertPendingUpdate({ node_id: nid, network_id: "netA", patch: { flags: {}, channels: ["feishu"] }, apply_mode: "restart", }); const r = finalizePendingMatchingUpdates(nid, { model: "x", flags: {} }); // no channels key expect(r.finalizedCount).toBe(0); }); test("model + channels compound match: both must satisfy", () => { const nid = insertNode({ alias: "n-chan-7", network_id: "netA" }); insertPendingUpdate({ node_id: nid, network_id: "netA", patch: { model: "opus-new", flags: {}, channels: ["telegram"] }, apply_mode: "restart", }); // Wrong channels → not finalized even though model matches. expect(finalizePendingMatchingUpdates(nid, { model: "opus-new", flags: {}, channels: [] }).finalizedCount).toBe(0); // Both match → finalized. expect(finalizePendingMatchingUpdates(nid, { model: "opus-new", flags: {}, channels: ["telegram"] }).finalizedCount).toBe(1); }); });