@sleep2agi/commhub-server
Advanced tools
| // RFC-027 §2.3 race-free invariant — shared helper for the inbox- | ||
| // enqueue lifecycle guard. | ||
| // | ||
| // PR1.1 (#346) added an `assertNodeActive` helper inside the | ||
| // registerTools() closure and applied it at the 6 INSERT INTO inbox | ||
| // sites in server/src/tools.ts. PR1.1 review (通信龙 #346 ack) caught | ||
| // that the closure scope made the helper unreachable from REST | ||
| // handlers in server/src/index.ts — POST /api/task (:1631) and | ||
| // POST /api/broadcast (:1706) are dashboard's "Dispatch" entry | ||
| // points and bypassed the guard, leaving the §2.3 race open on the | ||
| // REST path. | ||
| // | ||
| // PR1.2a (#346 follow-up) extracts the helper to this module so both | ||
| // the MCP tools and the REST handlers can import the same code path, | ||
| // per [[feedback_grep_all_sites_before_apply_guard]]: any SQL-level | ||
| // guard's helper lives at module scope so every write site | ||
| // (MCP + REST + internal db.ts helpers) can use it. | ||
| // | ||
| // Behaviour: | ||
| // - no nodes row for the alias → ok:true (brand-new alias allowed; | ||
| // could be the first INSERT for a child about to register) | ||
| // - lifecycle_state = 'active' OR NULL (pre-RFC-027 row) → ok:true | ||
| // - anything else (stopping / stopped / stop_failed / deleting) → | ||
| // ok:false + structured `node_not_active` reply | ||
| // | ||
| // COALESCE on the WHERE keeps NULL-network inbox rows / legacy nodes | ||
| // rows correctly scoped (PR1 SF-5 lineage). | ||
| import { db } from "./db.js"; | ||
| export type LifecycleGuardResult = | ||
| | { ok: true } | ||
| | { ok: false; error: "node_not_active"; lifecycle_state: string; alias: string }; | ||
| export function assertNodeActive( | ||
| sessionAlias: string, | ||
| networkId: string | null, | ||
| ): LifecycleGuardResult { | ||
| if (!sessionAlias) return { ok: true }; | ||
| const row = db.get<{ lifecycle_state: string | null }>( | ||
| networkId | ||
| ? `SELECT lifecycle_state FROM nodes WHERE alias = ?1 AND COALESCE(network_id, ?2) = ?2 LIMIT 1` | ||
| : `SELECT lifecycle_state FROM nodes WHERE alias = ?1 LIMIT 1`, | ||
| ...(networkId ? [sessionAlias, networkId] : [sessionAlias]), | ||
| ); | ||
| if (!row) return { ok: true }; | ||
| const st = row.lifecycle_state ?? "active"; | ||
| if (st === "active") return { ok: true }; | ||
| return { ok: false, error: "node_not_active", lifecycle_state: st, alias: sessionAlias }; | ||
| } |
| import { afterAll, beforeEach, describe, expect, test } from "bun:test"; | ||
| import { db } from "./db.js"; | ||
| import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import { registerTools } from "./tools.js"; | ||
| // RFC-027 PR1 — handler-driven tests for stop_node, delete_node, | ||
| // get_stop_request, ack_stop_request. Drives the MCP handlers via | ||
| // registerTools() so the wired auth context is real (not the | ||
| // mirror-SQL pattern that PR2 v1 used and which let SEC-1 leak slip | ||
| // through, per [[feedback_parallel_fork_review_catches_wired_wrong]]). | ||
| // | ||
| // Covers RFC-027 P1 e2e plan §5.2 scenarios A/B/C/D/E/F at the | ||
| // handler layer: | ||
| // A — stop happy path | ||
| // B — stop + in-flight (default refuse) | ||
| // C — stop + in-flight + force=true → audit row written | ||
| // D — delete happy path | ||
| // E — cross-tenant SEC-1 refusal | ||
| // F — delete daemon via delete_node refused (D6 gate) | ||
| // Plus state machine + confirm_alias + ack finalize transitions. | ||
| const NET_A = "net_sd_alpha"; | ||
| const NET_B = "net_sd_beta"; | ||
| const USER_A_ID = "u_sd_alice"; | ||
| const USER_B_ID = "u_sd_bob"; | ||
| const DAEMON_A_ID = "node_sd_daemon_a"; | ||
| const DAEMON_A_ALIAS = "sd-daemon-a"; | ||
| const DAEMON_A_TOK = "tok_sd_daemon_a"; | ||
| const CHILD_A_ID = "node_sd_child_a"; | ||
| const CHILD_A_ALIAS = "sd-child-a"; | ||
| const CHILD_TOK_A_ID = "tok_sd_child_a"; | ||
| interface ToolHandler { (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>; } | ||
| interface Reply { ok?: boolean; error?: string; [k: string]: unknown; } | ||
| function cleanup() { | ||
| for (const n of [NET_A, NET_B]) { | ||
| try { db.run("DELETE FROM node_stop_requests WHERE network_id = ?1", [n]); } catch {} | ||
| try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [n]); } catch {} | ||
| try { db.run("DELETE FROM inbox WHERE network_id = ?1", [n]); } catch {} | ||
| // PR1.2a restart_node tests leave node_config_updates rows; clear | ||
| // so the next test's restart_node doesn't see them as in_flight. | ||
| try { db.run("DELETE FROM node_config_updates WHERE network_id = ?1", [n]); } catch {} | ||
| try { db.run("DELETE FROM nodes WHERE network_id = ?1", [n]); } catch {} | ||
| try { db.run("DELETE FROM sessions WHERE network_id = ?1", [n]); } catch {} | ||
| try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [n]); } catch {} | ||
| try { db.run("DELETE FROM network_members WHERE network_id = ?1", [n]); } catch {} | ||
| try { db.run("DELETE FROM networks WHERE network_id = ?1", [n]); } catch {} | ||
| } | ||
| // SF-5 test plants a NULL-network inbox row keyed by alias — clean | ||
| // by alias too so it doesn't pollute later tests that use the same | ||
| // CHILD_A_ALIAS. | ||
| for (const alias of [CHILD_A_ALIAS, DAEMON_A_ALIAS]) { | ||
| try { db.run("DELETE FROM inbox WHERE session_name = ?1", [alias]); } catch {} | ||
| } | ||
| try { db.run("DELETE FROM users WHERE user_id IN (?1, ?2)", [USER_A_ID, USER_B_ID]); } catch {} | ||
| } | ||
| beforeEach(cleanup); | ||
| afterAll(cleanup); | ||
| function seedUser(user_id: string) { | ||
| db.run( | ||
| `INSERT INTO users (user_id, username, password_hash, role, created_at) | ||
| VALUES (?1, ?2, 'x', 'user', datetime('now'))`, | ||
| [user_id, user_id], | ||
| ); | ||
| } | ||
| function seedNetwork(net_id: string, owner_id: string) { | ||
| db.run( | ||
| `INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at) | ||
| VALUES (?1, ?2, ?3, datetime('now'))`, | ||
| [net_id, net_id, owner_id], | ||
| ); | ||
| } | ||
| function seedMembership(user_id: string, net_id: string, role: "owner" | "admin" | "member" | "viewer" = "admin") { | ||
| db.run( | ||
| `INSERT INTO network_members (user_id, network_id, role, joined_at) | ||
| VALUES (?1, ?2, ?3, datetime('now'))`, | ||
| [user_id, net_id, role], | ||
| ); | ||
| } | ||
| function seedDaemon(net: string, node_id: string, alias: string, user_id: string, tokenId: string, role = "host_supervisor") { | ||
| db.run( | ||
| `INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state) | ||
| VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now'), datetime('now'), 'active')`, | ||
| [node_id, alias, alias, net, JSON.stringify({ role, daemon_capabilities: { runtimes_supported: ["claude-agent-sdk"] } }), `host-${alias}`], | ||
| ); | ||
| db.run( | ||
| `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at) | ||
| VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`, | ||
| [tokenId, user_id, net, `node:${alias}`, `hash_${tokenId}`], | ||
| ); | ||
| } | ||
| function seedChild(net: string, node_id: string, alias: string, user_id: string, tokenId: string, opts: { lifecycle_state?: string; role?: string } = {}) { | ||
| db.run( | ||
| `INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state) | ||
| VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now'), datetime('now'), ?7)`, | ||
| [node_id, alias, alias, net, JSON.stringify({ role: opts.role ?? "member" }), `host-${alias}`, opts.lifecycle_state ?? "active"], | ||
| ); | ||
| db.run( | ||
| `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at) | ||
| VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`, | ||
| [tokenId, user_id, net, `node:${alias}`, `hash_${tokenId}`], | ||
| ); | ||
| } | ||
| function seedInbox(net: string, alias: string, n: number) { | ||
| for (let i = 0; i < n; i++) { | ||
| db.run( | ||
| `INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id, acked) | ||
| VALUES (?1, ?2, 'task', 'normal', ?3, 'caller', ?4, 0)`, | ||
| [`inbox_${alias}_${i}_${Date.now()}`, alias, `task ${i}`, net], | ||
| ); | ||
| } | ||
| } | ||
| function buildHandlers( | ||
| enforceUserId: string | null, | ||
| daemonTokenBound = false, | ||
| callerTokenId: string | null = null, | ||
| daemonNetId: string | null = null, | ||
| ): Record<string, ToolHandler> { | ||
| const server = new McpServer({ name: "test", version: "0" }) as any; | ||
| const tools: Record<string, ToolHandler> = {}; | ||
| const origTool = server.tool.bind(server); | ||
| server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => { | ||
| tools[name] = handler; | ||
| return origTool(name, _desc, _schema, handler); | ||
| }; | ||
| const origRegisterTool = server.registerTool?.bind(server); | ||
| if (origRegisterTool) { | ||
| server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => { | ||
| tools[name] = handler; | ||
| return origRegisterTool(name, _cfg, handler); | ||
| }; | ||
| } | ||
| registerTools( | ||
| server, undefined, | ||
| /* enforceNetworkId */ daemonTokenBound ? daemonNetId : null, | ||
| /* enforceUserId */ enforceUserId, | ||
| /* callerAlias */ null, | ||
| /* callerTokenIsNetwork */ daemonTokenBound, | ||
| /* callerTokenId */ callerTokenId, | ||
| ); | ||
| return tools; | ||
| } | ||
| async function call(handler: ToolHandler, args: any): Promise<Reply> { | ||
| const r = await handler(args); | ||
| return JSON.parse(r.content[0].text) as Reply; | ||
| } | ||
| function readNode(node_id: string) { | ||
| return db.get<{ lifecycle_state: string | null; alias: string }>( | ||
| `SELECT lifecycle_state, alias FROM nodes WHERE node_id = ?1`, node_id, | ||
| ); | ||
| } | ||
| function readToken(tokenId: string) { | ||
| return db.get<{ revoked_at: string | null }>( | ||
| `SELECT revoked_at FROM api_tokens WHERE token_id = ?1`, tokenId, | ||
| ); | ||
| } | ||
| function readRequest(request_id: string) { | ||
| return db.get<{ status: string; action: string; backup_path: string | null; exit_signal: string | null }>( | ||
| `SELECT status, action, backup_path, exit_signal FROM node_stop_requests WHERE request_id = ?1`, | ||
| request_id, | ||
| ); | ||
| } | ||
| function readAudit(action: string, network_id: string) { | ||
| return db.all<{ action: string; target_id: string; detail: string }>( | ||
| `SELECT action, target_id, detail FROM audit_log WHERE action = ?1 AND network_id = ?2 ORDER BY id DESC`, | ||
| [action, network_id], | ||
| ); | ||
| } | ||
| function setupAlphaNetwork() { | ||
| seedUser(USER_A_ID); | ||
| seedNetwork(NET_A, USER_A_ID); | ||
| seedMembership(USER_A_ID, NET_A, "admin"); | ||
| seedDaemon(NET_A, DAEMON_A_ID, DAEMON_A_ALIAS, USER_A_ID, DAEMON_A_TOK); | ||
| seedChild(NET_A, CHILD_A_ID, CHILD_A_ALIAS, USER_A_ID, CHILD_TOK_A_ID); | ||
| } | ||
| // ── stop_node ────────────────────────────────────────────────────── | ||
| describe("stop_node — happy path (RFC-027 §5.2 A)", () => { | ||
| test("active child + no in-flight → dispatch + state→stopping", async () => { | ||
| setupAlphaNetwork(); | ||
| const tools = buildHandlers(USER_A_ID); | ||
| const r = await call(tools.stop_node, { | ||
| child_node_id: CHILD_A_ID, | ||
| daemon_node_id: DAEMON_A_ID, | ||
| network_id: NET_A, | ||
| }); | ||
| expect(r.ok).toBe(true); | ||
| expect(r.action).toBe("stop"); | ||
| expect(r.lifecycle_state).toBe("stopping"); | ||
| expect(r.in_flight_at_dispatch).toBe(0); | ||
| expect(typeof r.request_id).toBe("string"); | ||
| expect((r.request_id as string).startsWith("sr_")).toBe(true); | ||
| expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopping"); | ||
| expect(readAudit("stop_node_dispatched", NET_A).length).toBe(1); | ||
| }); | ||
| }); | ||
| describe("stop_node — in-flight default-refuse (RFC-027 §5.2 B)", () => { | ||
| test("inbox has 2 unacked tasks → error=node_busy_in_flight, count surfaced", async () => { | ||
| setupAlphaNetwork(); | ||
| seedInbox(NET_A, CHILD_A_ALIAS, 2); | ||
| const tools = buildHandlers(USER_A_ID); | ||
| const r = await call(tools.stop_node, { | ||
| child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, | ||
| }); | ||
| expect(r.ok).toBe(false); | ||
| expect(r.error).toBe("node_busy_in_flight"); | ||
| expect(r.in_flight_count).toBe(2); | ||
| // state untouched | ||
| expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active"); | ||
| expect(readAudit("stop_node_dispatched", NET_A).length).toBe(0); | ||
| }); | ||
| }); | ||
| describe("stop_node — force=true with in-flight (RFC-027 §5.2 C)", () => { | ||
| test("force overrides + forced_stop_with_in_flight audit row written", async () => { | ||
| setupAlphaNetwork(); | ||
| seedInbox(NET_A, CHILD_A_ALIAS, 3); | ||
| const tools = buildHandlers(USER_A_ID); | ||
| const r = await call(tools.stop_node, { | ||
| child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, | ||
| force: true, | ||
| }); | ||
| expect(r.ok).toBe(true); | ||
| expect(r.in_flight_at_dispatch).toBe(3); | ||
| const audits = readAudit("forced_stop_with_in_flight", NET_A); | ||
| expect(audits.length).toBe(1); | ||
| expect(JSON.parse(audits[0].detail).in_flight_count).toBe(3); | ||
| }); | ||
| }); | ||
| describe("stop_node — state machine guards", () => { | ||
| test("stop on already-stopped node → node_not_active", async () => { | ||
| setupAlphaNetwork(); | ||
| db.run(`UPDATE nodes SET lifecycle_state = 'stopped' WHERE node_id = ?1`, [CHILD_A_ID]); | ||
| const tools = buildHandlers(USER_A_ID); | ||
| const r = await call(tools.stop_node, { | ||
| child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, | ||
| }); | ||
| expect(r.ok).toBe(false); | ||
| expect(r.error).toBe("node_not_active"); | ||
| expect(r.current_state).toBe("stopped"); | ||
| }); | ||
| test("stop on already-stopping node → node_already_stopping", async () => { | ||
| setupAlphaNetwork(); | ||
| db.run(`UPDATE nodes SET lifecycle_state = 'stopping' WHERE node_id = ?1`, [CHILD_A_ID]); | ||
| const tools = buildHandlers(USER_A_ID); | ||
| const r = await call(tools.stop_node, { | ||
| child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, | ||
| }); | ||
| expect(r.ok).toBe(false); | ||
| expect(r.error).toBe("node_already_stopping"); | ||
| }); | ||
| }); | ||
| // ── delete_node ──────────────────────────────────────────────────── | ||
| describe("delete_node — happy path (RFC-027 §5.2 D)", () => { | ||
| test("active child + correct confirm_alias → dispatch + state→deleting", async () => { | ||
| setupAlphaNetwork(); | ||
| const tools = buildHandlers(USER_A_ID); | ||
| const r = await call(tools.delete_node, { | ||
| child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, | ||
| confirm_alias: CHILD_A_ALIAS, | ||
| }); | ||
| expect(r.ok).toBe(true); | ||
| expect(r.action).toBe("delete"); | ||
| expect(r.lifecycle_state).toBe("deleting"); | ||
| expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("deleting"); | ||
| expect(readAudit("delete_node_dispatched", NET_A).length).toBe(1); | ||
| }); | ||
| }); | ||
| describe("delete_node — confirm_alias gate", () => { | ||
| test("wrong alias input → confirm_alias_mismatch", async () => { | ||
| setupAlphaNetwork(); | ||
| const tools = buildHandlers(USER_A_ID); | ||
| const r = await call(tools.delete_node, { | ||
| child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, | ||
| confirm_alias: "wrong-alias", | ||
| }); | ||
| expect(r.ok).toBe(false); | ||
| expect(r.error).toBe("confirm_alias_mismatch"); | ||
| expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active"); | ||
| }); | ||
| }); | ||
| describe("delete_node — D6 daemon-role-gate (RFC-027 §5.2 F)", () => { | ||
| test("delete targeting host_supervisor daemon → cannot_delete_daemon_via_delete_node", async () => { | ||
| setupAlphaNetwork(); | ||
| const tools = buildHandlers(USER_A_ID); | ||
| const r = await call(tools.delete_node, { | ||
| child_node_id: DAEMON_A_ID, // targeting the daemon itself | ||
| daemon_node_id: DAEMON_A_ID, | ||
| network_id: NET_A, | ||
| confirm_alias: DAEMON_A_ALIAS, | ||
| }); | ||
| expect(r.ok).toBe(false); | ||
| expect(r.error).toBe("cannot_delete_daemon_via_delete_node"); | ||
| // Daemon row untouched. | ||
| expect(readNode(DAEMON_A_ID)?.lifecycle_state).toBe("active"); | ||
| }); | ||
| }); | ||
| // ── SEC-1 cross-tenant ───────────────────────────────────────────── | ||
| describe("stop_node / delete_node — SEC-1 cross-tenant (RFC-027 §5.2 E)", () => { | ||
| test("user-B targeting user-A's child → forbidden_cross_tenant; netA node untouched", async () => { | ||
| setupAlphaNetwork(); | ||
| // also seed user B in own network | ||
| seedUser(USER_B_ID); | ||
| seedNetwork(NET_B, USER_B_ID); | ||
| seedMembership(USER_B_ID, NET_B, "admin"); | ||
| const tools = buildHandlers(USER_B_ID); | ||
| const r = await call(tools.stop_node, { | ||
| child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, | ||
| network_id: NET_A, // user-B claiming netA scope | ||
| }); | ||
| expect(r.ok).toBe(false); | ||
| expect(r.error).toMatch(/forbidden_cross_tenant|access denied/i); | ||
| // netA child unaffected | ||
| expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active"); | ||
| expect(readAudit("stop_node_dispatched", NET_A).length).toBe(0); | ||
| }); | ||
| test("user-B with no network_id arg still can't see/touch netA child", async () => { | ||
| setupAlphaNetwork(); | ||
| seedUser(USER_B_ID); | ||
| seedNetwork(NET_B, USER_B_ID); | ||
| seedMembership(USER_B_ID, NET_B, "admin"); | ||
| const tools = buildHandlers(USER_B_ID); | ||
| const r = await call(tools.delete_node, { | ||
| child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, | ||
| confirm_alias: CHILD_A_ALIAS, | ||
| }); | ||
| expect(r.ok).toBe(false); | ||
| expect(r.error).toMatch(/forbidden_cross_tenant/i); | ||
| expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active"); | ||
| }); | ||
| }); | ||
| // ── get_stop_request + ack_stop_request (daemon-side flow) ───────── | ||
| describe("get_stop_request + ack_stop_request — daemon flow", () => { | ||
| test("daemon pulls request → ack 'stopped' → state machine finalizes (stop branch)", async () => { | ||
| setupAlphaNetwork(); | ||
| const userTools = buildHandlers(USER_A_ID); | ||
| const dispatch = await call(userTools.stop_node, { | ||
| child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, | ||
| }); | ||
| expect(dispatch.ok).toBe(true); | ||
| const requestId = dispatch.request_id as string; | ||
| // daemon perspective: token-bound to its own ntok | ||
| const daemonTools = buildHandlers(USER_A_ID, /*tokenBound*/ true, DAEMON_A_TOK, NET_A); | ||
| const got = await call(daemonTools.get_stop_request, { request_id: requestId }); | ||
| expect(got.ok).toBe(true); | ||
| expect(got.action).toBe("stop"); | ||
| expect(got.child_node_id).toBe(CHILD_A_ID); | ||
| expect(got.child_alias).toBe(CHILD_A_ALIAS); | ||
| expect(readRequest(requestId)?.status).toBe("delivered"); | ||
| const acked = await call(daemonTools.ack_stop_request, { | ||
| request_id: requestId, status: "stopped", exit_signal: "SIGTERM", | ||
| }); | ||
| expect(acked.ok).toBe(true); | ||
| expect(readRequest(requestId)?.status).toBe("stopped"); | ||
| expect(readRequest(requestId)?.exit_signal).toBe("SIGTERM"); | ||
| expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopped"); | ||
| expect(readAudit("stop_node_completed", NET_A).length).toBe(1); | ||
| // ntok must NOT be revoked on stop (reversible). | ||
| expect(readToken(CHILD_TOK_A_ID)?.revoked_at).toBeNull(); | ||
| }); | ||
| test("daemon ack 'stopped' on delete branch → ntok revoked + node row DELETEd", async () => { | ||
| setupAlphaNetwork(); | ||
| const userTools = buildHandlers(USER_A_ID); | ||
| const dispatch = await call(userTools.delete_node, { | ||
| child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, | ||
| confirm_alias: CHILD_A_ALIAS, | ||
| }); | ||
| expect(dispatch.ok).toBe(true); | ||
| const requestId = dispatch.request_id as string; | ||
| const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A); | ||
| const acked = await call(daemonTools.ack_stop_request, { | ||
| request_id: requestId, status: "stopped", exit_signal: "SIGTERM", | ||
| backup_path: "/home/u/.anet/deleted/1719647200000-sd-child-a", | ||
| }); | ||
| expect(acked.ok).toBe(true); | ||
| // Row gone, ntok revoked, audit row written | ||
| expect(readNode(CHILD_A_ID) ?? null).toBeNull(); | ||
| expect(readToken(CHILD_TOK_A_ID)?.revoked_at).not.toBeNull(); | ||
| const audits = readAudit("delete_node_completed", NET_A); | ||
| expect(audits.length).toBe(1); | ||
| expect(JSON.parse(audits[0].detail).backup_path).toBe("/home/u/.anet/deleted/1719647200000-sd-child-a"); | ||
| }); | ||
| test("daemon ack 'stop_failed' → lifecycle_state=stop_failed, request error captured", async () => { | ||
| setupAlphaNetwork(); | ||
| const userTools = buildHandlers(USER_A_ID); | ||
| const dispatch = await call(userTools.stop_node, { | ||
| child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, | ||
| }); | ||
| const requestId = dispatch.request_id as string; | ||
| const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A); | ||
| await call(daemonTools.ack_stop_request, { | ||
| request_id: requestId, status: "stop_failed", error: "SIGKILL also failed", | ||
| }); | ||
| expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stop_failed"); | ||
| expect(readRequest(requestId)?.status).toBe("stop_failed"); | ||
| }); | ||
| test("daemon B can't ack daemon A's request → not_your_request", async () => { | ||
| setupAlphaNetwork(); | ||
| const userTools = buildHandlers(USER_A_ID); | ||
| const dispatch = await call(userTools.stop_node, { | ||
| child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, | ||
| }); | ||
| const requestId = dispatch.request_id as string; | ||
| // Seed an unrelated daemon B in netA with its own token. | ||
| db.run( | ||
| `INSERT INTO nodes (node_id, node_name, alias, network_id, config_snapshot, hostname, created_at, updated_at, lifecycle_state) | ||
| VALUES ('node_other_daemon', 'other-daemon', 'other-daemon', ?1, '{}', 'h', datetime('now'), datetime('now'), 'active')`, | ||
| [NET_A], | ||
| ); | ||
| db.run( | ||
| `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at) | ||
| VALUES ('tok_other_daemon', ?1, ?2, 'network', 'node:other-daemon', 'hash_other', NULL, NULL)`, | ||
| [USER_A_ID, NET_A], | ||
| ); | ||
| const otherDaemonTools = buildHandlers(USER_A_ID, true, "tok_other_daemon", NET_A); | ||
| const r = await call(otherDaemonTools.ack_stop_request, { | ||
| request_id: requestId, status: "stopped", | ||
| }); | ||
| expect(r.ok).toBe(false); | ||
| expect(r.error).toBe("not_your_request"); | ||
| }); | ||
| }); | ||
| // ── PR1 v3: 通信龙 #345 deep-review fixes ────────────────────────── | ||
| describe("SF-2 — audit in tx propagates failure (D8 atomicity真)", () => { | ||
| test("forced audit row written alongside dispatch in same tx (rollback if either fails)", async () => { | ||
| // We can't easily simulate audit_log INSERT failure without | ||
| // schema munging; the atomicity guarantee is structural (BEGIN.. | ||
| // COMMIT + auditCreateNodeStrict re-throws). Pin the invariant | ||
| // observable to ops: the dispatch + forced audit both appear OR | ||
| // both are absent. Happy path here; a true failure-injection | ||
| // belongs in PR1.1's docker e2e where we can drop audit_log | ||
| // permissions / corrupt the row. | ||
| setupAlphaNetwork(); | ||
| seedInbox(NET_A, CHILD_A_ALIAS, 1); | ||
| const tools = buildHandlers(USER_A_ID); | ||
| const r = await call(tools.stop_node, { | ||
| child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, | ||
| force: true, | ||
| }); | ||
| expect(r.ok).toBe(true); | ||
| // Both audit rows present. | ||
| expect(readAudit("stop_node_dispatched", NET_A).length).toBe(1); | ||
| expect(readAudit("forced_stop_with_in_flight", NET_A).length).toBe(1); | ||
| // node state moved | ||
| expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopping"); | ||
| }); | ||
| }); | ||
| describe("SF-4 — D6 fail-CLOSED on ambiguous role", () => { | ||
| test("corrupt config_snapshot + node referenced as daemon → still refused", async () => { | ||
| setupAlphaNetwork(); | ||
| // Wreck the daemon's snapshot but leave the row + create-request | ||
| // history. delete_node MUST still refuse — corrupt snapshot was the | ||
| // SF-4 fail-open path. | ||
| db.run( | ||
| `UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`, | ||
| ["not-valid-json{{{", DAEMON_A_ID], | ||
| ); | ||
| // Plant a node_create_request that points to DAEMON_A as daemon, | ||
| // simulating that this daemon has handed out children before. | ||
| db.run( | ||
| `INSERT INTO node_create_requests | ||
| (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token) | ||
| VALUES ('cr_sf4_evidence', ?1, 'past-child', ?2, 'claude-agent-sdk', 'x', '{}', '[]', 'succeeded', NULL, ?3, 'tok_test')`, | ||
| [DAEMON_A_ID, NET_A, Date.now()], | ||
| ); | ||
| const tools = buildHandlers(USER_A_ID); | ||
| const r = await call(tools.delete_node, { | ||
| child_node_id: DAEMON_A_ID, | ||
| daemon_node_id: DAEMON_A_ID, | ||
| network_id: NET_A, | ||
| confirm_alias: DAEMON_A_ALIAS, | ||
| }); | ||
| expect(r.ok).toBe(false); | ||
| expect(r.error).toBe("cannot_delete_daemon_via_delete_node"); | ||
| expect(readNode(DAEMON_A_ID)?.lifecycle_state).toBe("active"); | ||
| }); | ||
| test("corrupt snapshot + NO daemon evidence → can be deleted (no FP)", async () => { | ||
| // A plain child whose snapshot got mangled mustn't be refused. | ||
| // Simulates corruption on a regular member node. | ||
| setupAlphaNetwork(); | ||
| db.run( | ||
| `UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`, | ||
| ["{broken", CHILD_A_ID], | ||
| ); | ||
| const tools = buildHandlers(USER_A_ID); | ||
| const r = await call(tools.delete_node, { | ||
| child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, | ||
| confirm_alias: CHILD_A_ALIAS, | ||
| }); | ||
| expect(r.ok).toBe(true); | ||
| expect(r.action).toBe("delete"); | ||
| }); | ||
| }); | ||
| describe("SF-5 — in-flight COALESCE for legacy inbox rows", () => { | ||
| test("inbox row with network_id=NULL but session_name matches → counted", async () => { | ||
| setupAlphaNetwork(); | ||
| // Legacy-shape inbox row: session_name set, network_id NULL. | ||
| db.run( | ||
| `INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id, acked) | ||
| VALUES (?1, ?2, 'task', 'normal', 'legacy-task', 'caller', NULL, 0)`, | ||
| [`inbox_legacy_${Date.now()}`, CHILD_A_ALIAS], | ||
| ); | ||
| const tools = buildHandlers(USER_A_ID); | ||
| const r = await call(tools.stop_node, { | ||
| child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, | ||
| }); | ||
| expect(r.ok).toBe(false); | ||
| expect(r.error).toBe("node_busy_in_flight"); | ||
| expect(r.in_flight_count).toBeGreaterThanOrEqual(1); | ||
| }); | ||
| }); | ||
| // ─── RFC-027 PR1.1 — D8 atomicity failure-injection (real teeth) ── | ||
| // | ||
| // PR1's SF-2 fix introduced auditCreateNodeStrict (re-throws inside tx | ||
| // callback). The PR1 v3 test only verified happy path: both rows | ||
| // present after success. 通信龙's #345 ack flagged that as a shape-pin | ||
| // — could pass even if the strict variant were silently reverted. This | ||
| // PR1.1 test injects a real failure (monkey-patch db.run to throw at | ||
| // the audit_log INSERT moment) and asserts BOTH rollback halves: | ||
| // 1. nodes.lifecycle_state UNCHANGED (didn't transition to 'stopping') | ||
| // 2. node_stop_requests row absent (INSERT rolled back too) | ||
| describe("SF-2 D8 atomicity — REAL failure injection forces ROLLBACK", () => { | ||
| test("audit_log table dropped → audit INSERT throws inside tx → ROLLBACK leaves nodes + request untouched", async () => { | ||
| setupAlphaNetwork(); | ||
| const tools = buildHandlers(USER_A_ID); | ||
| // Real failure injection: drop audit_log so the strict audit | ||
| // INSERT inside the tx throws a real SQLite "no such table" | ||
| // error. The whole tx must roll back per §4.5 D8. (Patching | ||
| // adapter.db.run via property overwrite proved unreliable under | ||
| // bun:sqlite's transaction wrapper in earlier draft; dropping | ||
| // the table is the simplest way to make the failure REAL — the | ||
| // error originates inside the SQL engine, not a JS mock.) | ||
| db.exec("DROP TABLE IF EXISTS audit_log_BACKUP"); | ||
| db.exec("ALTER TABLE audit_log RENAME TO audit_log_BACKUP"); | ||
| let r: Reply; | ||
| try { | ||
| r = await call(tools.stop_node, { | ||
| child_node_id: CHILD_A_ID, daemon_node_id: DAEMON_A_ID, network_id: NET_A, | ||
| }); | ||
| } finally { | ||
| // Restore so subsequent tests + cleanup work. | ||
| db.exec("DROP TABLE IF EXISTS audit_log"); | ||
| db.exec("ALTER TABLE audit_log_BACKUP RENAME TO audit_log"); | ||
| } | ||
| expect(r.ok).toBe(false); | ||
| // dispatch_tx_failed is the expected error. Print the actual reply | ||
| // if we got something else so the failure mode is diagnosable. | ||
| if (r.error !== "dispatch_tx_failed") { | ||
| console.error("[SF-2 unexpected reply]", JSON.stringify(r)); | ||
| } | ||
| expect(r.error).toBe("dispatch_tx_failed"); | ||
| // ROLLBACK observable: | ||
| // (a) lifecycle_state did NOT transition out of 'active' | ||
| expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active"); | ||
| // (b) no node_stop_requests row exists for this child | ||
| const reqs = db.all<{ request_id: string }>( | ||
| `SELECT request_id FROM node_stop_requests WHERE child_node_id = ?1`, [CHILD_A_ID], | ||
| ); | ||
| expect(reqs.length).toBe(0); | ||
| // (c) no audit row was written | ||
| expect(readAudit("stop_node_dispatched", NET_A).length).toBe(0); | ||
| }); | ||
| }); | ||
| // ─── RFC-027 PR1.1 — inbox-enqueue lifecycle guard at routing layer ── | ||
| // | ||
| // 6 inbox INSERT sites in tools.ts (send_task / send_message / reply / | ||
| // retry_task / reassign_task / broadcast) now pre-check the target | ||
| // alias's nodes.lifecycle_state. Anything other than `active` refuses | ||
| // with `node_not_active`. Sample the highest-traffic path (send_task) | ||
| // for a stopping target — this catches the routing-after-SIGTERM | ||
| // window that RFC §2.3 explicitly closes. | ||
| describe("inbox-enqueue lifecycle_state guard (RFC-027 §2.3 race-free)", () => { | ||
| test("send_task to a stopping node → refused with node_not_active", async () => { | ||
| setupAlphaNetwork(); | ||
| // Caller + target sessions rows (send_task's resolveDeliveryTarget | ||
| // looks up the target in sessions, not just nodes). | ||
| db.run( | ||
| `INSERT INTO sessions (resume_id, alias, network_id, last_seen_at, status, cpu_cores, mem_total_gb, ip) | ||
| VALUES (?1, ?2, ?3, datetime('now'), 'idle', 4, 8, '10.0.0.99')`, | ||
| [`s_caller_${Date.now()}`, "sender-x", NET_A], | ||
| ); | ||
| db.run( | ||
| `INSERT INTO sessions (resume_id, alias, network_id, last_seen_at, status, cpu_cores, mem_total_gb, ip) | ||
| VALUES (?1, ?2, ?3, datetime('now'), 'idle', 4, 8, '10.0.0.5')`, | ||
| [`s_child_${Date.now()}`, CHILD_A_ALIAS, NET_A], | ||
| ); | ||
| // Move target into stopping (mid-flight stop_node race window). | ||
| db.run(`UPDATE nodes SET lifecycle_state = 'stopping' WHERE node_id = ?1`, [CHILD_A_ID]); | ||
| const tools = buildHandlers(USER_A_ID); | ||
| const r = await call(tools.send_task, { | ||
| alias: CHILD_A_ALIAS, | ||
| task: "racey post-SIGTERM task", | ||
| from_session: "sender-x", | ||
| network_id: NET_A, | ||
| }); | ||
| expect(r.ok).toBe(false); | ||
| expect(r.error).toBe("node_not_active"); | ||
| expect(r.lifecycle_state).toBe("stopping"); | ||
| // Inbox unchanged (no row inserted for this alias post-stopping). | ||
| const inboxRows = db.all<{ id: string }>( | ||
| `SELECT id FROM inbox WHERE session_name = ?1 AND network_id = ?2`, | ||
| [CHILD_A_ALIAS, NET_A], | ||
| ); | ||
| expect(inboxRows.length).toBe(0); | ||
| }); | ||
| }); | ||
| // ─── list_my_children (PR1.1 hub support for rebuild) ──────────── | ||
| describe("list_my_children HANDLER (RFC-027 PR1.1)", () => { | ||
| test("daemon-bound caller gets its children list", async () => { | ||
| setupAlphaNetwork(); | ||
| // Plant a create-request row simulating that DAEMON_A has spawned | ||
| // CHILD_A previously (the daemon's child must be in this table). | ||
| db.run( | ||
| `INSERT INTO node_create_requests | ||
| (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token) | ||
| VALUES ('cr_lmc_test', ?1, ?2, ?3, 'claude-agent-sdk', 'x', '{}', '[]', 'succeeded', NULL, ?4, 'tok_test')`, | ||
| [DAEMON_A_ID, CHILD_A_ALIAS, NET_A, Date.now()], | ||
| ); | ||
| // The child's nodes row must exist (setupAlphaNetwork seeds it), | ||
| // and the canonical child_node_id needs to match `node_${request_id.slice(3)}` | ||
| // We update the existing child row's id so the LEFT JOIN matches. | ||
| db.run(`UPDATE nodes SET node_id = 'node_lmc_test' WHERE alias = ?1 AND network_id = ?2`, [CHILD_A_ALIAS, NET_A]); | ||
| const daemonTools = buildHandlers(USER_A_ID, true, DAEMON_A_TOK, NET_A); | ||
| const r = await call(daemonTools.list_my_children, {}); | ||
| expect(r.ok).toBe(true); | ||
| expect(Array.isArray(r.children)).toBe(true); | ||
| const aliases = (r.children as Array<{ alias: string }>).map(c => c.alias); | ||
| expect(aliases).toContain(CHILD_A_ALIAS); | ||
| }); | ||
| test("non-daemon caller (utok) refused", async () => { | ||
| setupAlphaNetwork(); | ||
| const userTools = buildHandlers(USER_A_ID); // utok, not daemon-bound | ||
| const r = await call(userTools.list_my_children, {}); | ||
| expect(r.ok).toBe(false); | ||
| }); | ||
| }); | ||
| // ─── PR1.2a — restart_node resets lifecycle_state ────────────────── | ||
| // | ||
| // 通信龙 #346 ack latent: without this, a stopped node restart-ed via | ||
| // restart_node would still have lifecycle_state='stopped' in the | ||
| // nodes table → the 6 MCP + 2 REST inbox guards would refuse all | ||
| // routing → node silently unreachable until manual DB fix. Asserts | ||
| // the dispatch + state flip happen atomically (tx). | ||
| describe("RFC-027 PR1.2a — restart_node resets lifecycle_state to 'active'", () => { | ||
| test("restart_node on a stopped node → lifecycle_state flips back to 'active' + dispatch row created", async () => { | ||
| setupAlphaNetwork(); | ||
| // Move the child to 'stopped' (simulating prior stop_node completion). | ||
| db.run(`UPDATE nodes SET lifecycle_state = 'stopped' WHERE node_id = ?1`, [CHILD_A_ID]); | ||
| expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("stopped"); | ||
| const tools = buildHandlers(USER_A_ID); | ||
| const r = await call(tools.restart_node, { | ||
| node_id: CHILD_A_ID, | ||
| network_id: NET_A, | ||
| }); | ||
| expect(r.ok).toBe(true); | ||
| expect(r.apply_mode).toBe("restart_only"); | ||
| // State flipped back. | ||
| expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active"); | ||
| // node_config_updates row exists (the dispatch). | ||
| const rows = db.all<{ update_id: string; status: string }>( | ||
| `SELECT update_id, status FROM node_config_updates WHERE node_id = ?1 ORDER BY created_at DESC LIMIT 1`, | ||
| [CHILD_A_ID], | ||
| ); | ||
| expect(rows.length).toBe(1); | ||
| expect(rows[0].status).toBe("pending"); | ||
| }); | ||
| test("restart_node on an active node → still resets to active (idempotent, no-op state-wise)", async () => { | ||
| setupAlphaNetwork(); | ||
| expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active"); | ||
| const tools = buildHandlers(USER_A_ID); | ||
| const r = await call(tools.restart_node, { node_id: CHILD_A_ID, network_id: NET_A }); | ||
| expect(r.ok).toBe(true); | ||
| expect(readNode(CHILD_A_ID)?.lifecycle_state).toBe("active"); | ||
| }); | ||
| }); |
+1
-1
| { | ||
| "name": "@sleep2agi/commhub-server", | ||
| "version": "0.9.0-preview.8", | ||
| "version": "0.9.0-preview.12", | ||
| "description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+43
-1
@@ -275,2 +275,33 @@ // RFC-026 v4 hub-side runtime — pendingEnvBlobs Map (F1 mint-stream- | ||
| // continue even if audit insert fails for any reason). | ||
| /** RFC-027 §4.5 D8 — tx-aware variant. Identical SQL to auditCreateNode | ||
| * but RE-THROWS on failure so a BEGIN..COMMIT around lifecycle UPDATE + | ||
| * audit INSERT can ROLLBACK if the audit row would have been lost. | ||
| * Required for the "no audit-but state changed" window that §4.5 / D8 | ||
| * explicitly closes (PR1 SF-2 review catch — auditCreateNode's | ||
| * swallow-and-warn defeats atomicity). | ||
| * | ||
| * Callers in a non-transaction context should keep using auditCreateNode | ||
| * (best-effort, never throws). */ | ||
| export function auditCreateNodeStrict(input: { | ||
| action: Parameters<typeof auditCreateNode>[0]["action"]; | ||
| user_id?: string | null; | ||
| username?: string | null; | ||
| network_id?: string | null; | ||
| target_id?: string | null; | ||
| detail: Record<string, unknown>; | ||
| }): void { | ||
| db.run( | ||
| `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id) | ||
| VALUES (?1, ?2, ?3, 'node_create_request', ?4, ?5, ?6)`, | ||
| [ | ||
| input.user_id || null, | ||
| input.username || null, | ||
| input.action, | ||
| input.target_id || null, | ||
| JSON.stringify(input.detail), | ||
| input.network_id || null, | ||
| ], | ||
| ); | ||
| } | ||
| export function auditCreateNode(input: { | ||
@@ -282,3 +313,14 @@ action: | ||
| | "create_node_sweeper_revoked" | ||
| | "daemon_capability_lied"; // RFC-026 §9.3 D2 — daemon declared runtime support, child died before serving | ||
| | "daemon_capability_lied" // RFC-026 §9.3 D2 — daemon declared runtime support, child died before serving | ||
| // RFC-027 §4.5 — stop/delete lifecycle audit action enum extension. | ||
| // Re-using auditCreateNode is fine: the action column carries the | ||
| // discriminator; target_type stays 'node_create_request' which is a | ||
| // misnomer for stop/delete rows but updating the schema would force a | ||
| // wider migration (see P1.1 issue). | ||
| | "stop_node_dispatched" | ||
| | "stop_node_completed" | ||
| | "delete_node_dispatched" | ||
| | "delete_node_completed" | ||
| | "forced_stop_with_in_flight" | ||
| | "backup_purged"; | ||
| user_id?: string | null; | ||
@@ -285,0 +327,0 @@ username?: string | null; |
+43
-0
@@ -327,2 +327,45 @@ import { createAdapter, type DbAdapter } from "./db-adapter"; | ||
| // RFC-027 §2.3 — state machine for stop/delete (D5). 'active' default | ||
| // matches pre-RFC behavior for every existing row. Transitions: | ||
| // active → stopping → stopped → (back to active via restart_node) | ||
| // active → deleting → (row gone) | ||
| // stopped → deleting → (row gone) | ||
| // Used as the inbox-enqueue gate per §2.3 race-free invariant: | ||
| // pushEvent / INSERT INTO inbox must refuse a non-active target so the | ||
| // SIGTERM-in-flight window never gets a new task. | ||
| try { | ||
| db.exec(`ALTER TABLE nodes ADD COLUMN lifecycle_state TEXT DEFAULT 'active'`); | ||
| } catch (e: any) { | ||
| if (!/duplicate column|already exists/i.test(e?.message || "")) throw e; | ||
| } | ||
| // RFC-027 §2 — stop/delete request envelope. Mirrors | ||
| // node_create_requests structure so the daemon-side pull/ack pattern | ||
| // is symmetric with create_node. action='stop'|'delete' picks which | ||
| // branch (delete adds backup_path + revokes ntok on ack). | ||
| db.exec(` | ||
| CREATE TABLE IF NOT EXISTS node_stop_requests ( | ||
| request_id TEXT PRIMARY KEY, | ||
| network_id TEXT NOT NULL, | ||
| daemon_node_id TEXT NOT NULL, | ||
| child_node_id TEXT NOT NULL, | ||
| child_alias TEXT NOT NULL, | ||
| action TEXT NOT NULL CHECK (action IN ('stop', 'delete')), | ||
| delete_config INTEGER NOT NULL DEFAULT 1, | ||
| grace_seconds INTEGER NOT NULL DEFAULT 10, | ||
| force INTEGER NOT NULL DEFAULT 0, | ||
| in_flight_at_dispatch INTEGER NOT NULL DEFAULT 0, | ||
| created_by_token TEXT NOT NULL, | ||
| status TEXT NOT NULL DEFAULT 'pending', | ||
| error TEXT, | ||
| backup_path TEXT, | ||
| exit_signal TEXT, | ||
| created_at INTEGER NOT NULL, | ||
| delivered_at INTEGER, | ||
| acked_at INTEGER | ||
| ) | ||
| `); | ||
| db.exec(`CREATE INDEX IF NOT EXISTS idx_stop_req_daemon ON node_stop_requests(daemon_node_id, status)`); | ||
| db.exec(`CREATE INDEX IF NOT EXISTS idx_stop_req_child ON node_stop_requests(child_node_id)`); | ||
| // `node_config_updates` — pending + history. One row per dashboard write. | ||
@@ -329,0 +372,0 @@ // At most one row per node may be in a non-terminal state at a time |
@@ -169,2 +169,67 @@ import { afterAll, beforeEach, describe, expect, test } from "bun:test"; | ||
| }); | ||
| test("no-op same-value patch: NO audit_log row written (通信牛 nit)", () => { | ||
| // Regression for the post-merge fix: when patch fields all match the | ||
| // existing row (e.g. dashboard saves an unchanged form), the handler | ||
| // must early-return WITHOUT inserting an empty-diff audit row. | ||
| // Empty audit rows pollute the trail and make `forced_*` / sensitive | ||
| // actions harder to find. | ||
| seedProvider(PROV_A, NET_A, { name: "same-name", enabled: 1 }); | ||
| const auditBefore = db.get<{n:number}>( | ||
| "SELECT COUNT(*) AS n FROM audit_log WHERE target_id = ?1 AND action = 'update_provider'", | ||
| PROV_A, | ||
| )!.n; | ||
| // Mirror the handler's diff-check logic on a same-value patch | ||
| const row = db.get<{name:string, enabled:number, base_url:string}>( | ||
| "SELECT name, enabled, base_url FROM providers WHERE provider_id = ?1", PROV_A, | ||
| )!; | ||
| const patch = { name: "same-name", enabled: true }; | ||
| const sets: string[] = []; | ||
| if (patch.name !== undefined && patch.name !== row.name) sets.push("name"); | ||
| if (patch.enabled !== undefined && (patch.enabled ? 1 : 0) !== row.enabled) sets.push("enabled"); | ||
| const willReplaceModels = false; // no patch.models | ||
| // sets MUST be empty for a same-value patch | ||
| expect(sets.length).toBe(0); | ||
| expect(willReplaceModels).toBe(false); | ||
| // Handler returns here WITHOUT writing audit_log → count unchanged | ||
| const auditAfter = db.get<{n:number}>( | ||
| "SELECT COUNT(*) AS n FROM audit_log WHERE target_id = ?1 AND action = 'update_provider'", | ||
| PROV_A, | ||
| )!.n; | ||
| expect(auditAfter).toBe(auditBefore); | ||
| expect(auditAfter).toBe(0); | ||
| }); | ||
| test("real change DOES write audit_log row (positive guard for the early-return)", () => { | ||
| // Belt-and-suspenders: no-op early-return MUST NOT trigger when even | ||
| // one field actually changes. Otherwise we'd silently lose audit | ||
| // coverage for real updates. | ||
| seedProvider(PROV_A, NET_A, { name: "old-name", enabled: 1 }); | ||
| const row = db.get<{name:string, enabled:number}>( | ||
| "SELECT name, enabled FROM providers WHERE provider_id = ?1", PROV_A, | ||
| )!; | ||
| const patch = { name: "new-name", enabled: true }; | ||
| const sets: string[] = []; | ||
| if (patch.name !== undefined && patch.name !== row.name) sets.push("name"); | ||
| if (patch.enabled !== undefined && (patch.enabled ? 1 : 0) !== row.enabled) sets.push("enabled"); | ||
| expect(sets).toEqual(["name"]); | ||
| db.exec("BEGIN"); | ||
| db.run("UPDATE providers SET name = ?1 WHERE provider_id = ?2", ["new-name", PROV_A]); | ||
| db.run( | ||
| `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id) | ||
| VALUES (?1, ?2, 'update_provider', 'provider', ?3, ?4, ?5)`, | ||
| ["u_test", "test", PROV_A, JSON.stringify({ diff: { name: { before: "old-name", after: "new-name" } }, fields_changed: ["name"] }), NET_A], | ||
| ); | ||
| db.exec("COMMIT"); | ||
| const audit = db.get<any>( | ||
| "SELECT detail FROM audit_log WHERE target_id = ?1 AND action = 'update_provider' ORDER BY id DESC LIMIT 1", | ||
| PROV_A, | ||
| )!; | ||
| expect(JSON.parse(audit.detail).fields_changed).toEqual(["name"]); | ||
| }); | ||
| }); |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
827630
9.47%54
3.85%16660
9.3%