Address review feedback: System panel hardening (#2028)
Correctness/reliability: - engine restart-all: compensating pause on resume failure so a project is never left marked active with a dead engine - desktop restart: app.quit() (runs before-quit teardown) not app.exit(0) - supervisor: SIGINT/SIGTERM during crash-backoff exit immediately; stopping-latch prevents respawn after intentional shutdown - coalesce concurrent /system/restart requests (restartScheduled guard) - rebuild job completion chain given a .catch (no stranded activeJob) Security: - same-origin CSRF guard on all mutating /system/* POSTs (safe under --no-auth / desktop) - redact secrets in host-process log history (reuse core redactSecrets) - report-bug: confirm before including server logs + escape ``` fences - sanitize restart reason; Object.hasOwn scope-guard (prototype-key 500) Frontend: - log tail dedup (drop redundant REST backfill; SSE heartbeat stops 45s reconnect churn) + X-Accel-Buffering:no on both SSE routes - restart-wait 90s timeout re-enables controls - hydrate buffered rebuild lines on mount (mid-build panel open) Maintainability + tests: - engineAvailable reflects centralCore; hoisted systemLogs option; typed desktop systemControl DTO - add tests: SSE routes, exit-86 respawn, compiled-binary respawn, engine-restart recovery, log redaction Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resolveSupervisorRespawnCommand, shouldSuperviseDashboard } from "../dashboard.js";
|
||||
|
||||
/*
|
||||
@@ -33,6 +33,16 @@ describe("shouldSuperviseDashboard", () => {
|
||||
});
|
||||
|
||||
describe("resolveSupervisorRespawnCommand", () => {
|
||||
const originalBun = (globalThis as { Bun?: unknown }).Bun;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalBun === undefined) {
|
||||
delete (globalThis as { Bun?: unknown }).Bun;
|
||||
} else {
|
||||
(globalThis as { Bun?: unknown }).Bun = originalBun;
|
||||
}
|
||||
});
|
||||
|
||||
it("re-execs the node entry script with execArgv preserved outside a compiled binary", () => {
|
||||
const respawn = resolveSupervisorRespawnCommand();
|
||||
expect(respawn).not.toBeNull();
|
||||
@@ -42,4 +52,13 @@ describe("resolveSupervisorRespawnCommand", () => {
|
||||
expect(respawn!.args[respawn!.args.length - 1]).toBe(process.argv[1]);
|
||||
expect(respawn!.args.slice(0, -1)).toEqual(process.execArgv);
|
||||
});
|
||||
|
||||
it("re-execs the compiled binary itself (no args) under a bun-compiled build", () => {
|
||||
// A bun-compiled single-file `fn` binary exposes `Bun.embeddedFiles`; argv[1]
|
||||
// is then a virtual embedded path, so the binary must re-exec process.execPath
|
||||
// alone (empty args) rather than passing a bogus entry script.
|
||||
(globalThis as { Bun?: { embeddedFiles: unknown[] } }).Bun = { embeddedFiles: [{}] };
|
||||
const respawn = resolveSupervisorRespawnCommand();
|
||||
expect(respawn).toEqual({ command: process.execPath, args: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,22 +49,35 @@ plain node:child_process spawn (foreground, TUI-safe) instead of the detached
|
||||
superviseSpawn, so tests mock spawn and complete the loop by emitting a clean
|
||||
SIGINT close on a microtask (after the supervisor wires its close listener).
|
||||
*/
|
||||
const { mockSupervisorSpawn } = vi.hoisted(() => ({
|
||||
mockSupervisorSpawn: vi.fn(() => {
|
||||
const listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
|
||||
const child = {
|
||||
on(event: string, cb: (...args: unknown[]) => void) {
|
||||
(listeners[event] ??= []).push(cb);
|
||||
return child;
|
||||
},
|
||||
kill: () => true,
|
||||
};
|
||||
queueMicrotask(() => {
|
||||
for (const cb of listeners["close"] ?? []) cb(null, "SIGINT");
|
||||
});
|
||||
return child;
|
||||
}),
|
||||
}));
|
||||
/*
|
||||
FNXC:SystemPanel 2026-07-12-15:10:
|
||||
supervisorCloseQueue lets a test script successive child exits (e.g. exit-86
|
||||
intentional restart followed by a clean exit-0) so the respawn loop can be
|
||||
driven deterministically. Each spawn pops one queued close result; when the
|
||||
queue is empty it defaults to a clean `{ code: 0, signal: null }` exit so the
|
||||
existing single-spawn supervision tests still terminate the loop.
|
||||
*/
|
||||
const { mockSupervisorSpawn, supervisorCloseQueue } = vi.hoisted(() => {
|
||||
const supervisorCloseQueue: Array<{ code: number | null; signal: NodeJS.Signals | null }> = [];
|
||||
return {
|
||||
supervisorCloseQueue,
|
||||
mockSupervisorSpawn: vi.fn(() => {
|
||||
const listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
|
||||
const child = {
|
||||
on(event: string, cb: (...args: unknown[]) => void) {
|
||||
(listeners[event] ??= []).push(cb);
|
||||
return child;
|
||||
},
|
||||
kill: () => true,
|
||||
};
|
||||
const result = supervisorCloseQueue.shift() ?? { code: 0, signal: null };
|
||||
queueMicrotask(() => {
|
||||
for (const cb of listeners["close"] ?? []) cb(result.code, result.signal);
|
||||
});
|
||||
return child;
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("../startup-model-sync.js", () => ({
|
||||
syncStartupModels: mockSyncStartupModels,
|
||||
}));
|
||||
@@ -3532,6 +3545,32 @@ describe("runDashboard update check wiring", () => {
|
||||
describe("runDashboardSupervised — bounded restart behavior", () => {
|
||||
beforeEach(() => {
|
||||
mockSupervisorSpawn.mockClear();
|
||||
supervisorCloseQueue.length = 0;
|
||||
});
|
||||
|
||||
it("respawns on the intentional restart exit code (86) then exits cleanly", async () => {
|
||||
const mod = await import("../dashboard.js");
|
||||
const originalArgv = process.argv;
|
||||
process.argv = [
|
||||
originalArgv[0] ?? process.execPath,
|
||||
"/tmp/fn-entry.mjs",
|
||||
"dashboard",
|
||||
"--supervise",
|
||||
];
|
||||
|
||||
// First child exits 86 (System-panel restart request → respawn without
|
||||
// consuming the crash budget); the respawned child exits 0 (clean stop).
|
||||
supervisorCloseQueue.push({ code: 86, signal: null }, { code: 0, signal: null });
|
||||
|
||||
try {
|
||||
await mod.runDashboardSupervised(0);
|
||||
} finally {
|
||||
process.argv = originalArgv;
|
||||
}
|
||||
|
||||
// Two spawns proves the exit-86 respawn happened and the loop then returned
|
||||
// cleanly (no crash-budget exhaustion / process.exit).
|
||||
expect(mockSupervisorSpawn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("spawns an attached child without the supervision flags and advertises the restart contract", async () => {
|
||||
|
||||
@@ -350,4 +350,20 @@ describe("DashboardLogSink system-log history", () => {
|
||||
|
||||
expect(seen).toEqual(["first"]);
|
||||
});
|
||||
|
||||
// FNXC:SystemPanel 2026-07-12-14:40: The history is served over /system/logs
|
||||
// and fed into diagnostics/bug reports, so secrets must be redacted before an
|
||||
// entry enters the ring buffer or reaches a live subscriber.
|
||||
it("redacts secrets before storing and before notifying subscribers", () => {
|
||||
const sink = new DashboardLogSink();
|
||||
const seen: string[] = [];
|
||||
sink.subscribeEntries((entry) => seen.push(entry.message));
|
||||
|
||||
sink.log("auth failed Authorization: Bearer sk-abcdef0123456789abcdef");
|
||||
|
||||
const stored = sink.getRecentEntries()[0].message;
|
||||
expect(stored).toContain("[REDACTED]");
|
||||
expect(stored).not.toContain("sk-abcdef0123456789abcdef");
|
||||
expect(seen[0]).toBe(stored);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { redactSecrets } from "@fusion/core";
|
||||
import { LogRingBuffer, type LogEntry } from "./log-ring-buffer.js";
|
||||
|
||||
// ── formatConsoleArgs ─────────────────────────────────────────────────────────
|
||||
@@ -118,7 +119,11 @@ export class DashboardLogSink {
|
||||
}
|
||||
|
||||
private record(level: LogEntry["level"], message: string, prefix?: string): void {
|
||||
const entry: LogEntry = { timestamp: new Date(), level, message, prefix };
|
||||
// Redact before storing/broadcasting: the System panel serves this history
|
||||
// over /system/logs + /system/logs/stream and into diagnostics/bug reports,
|
||||
// so any secret that reaches a log line would otherwise be resurfaceable to
|
||||
// a dashboard client. Mask before it enters the ring buffer or listeners.
|
||||
const entry: LogEntry = { timestamp: new Date(), level, message: redactSecrets(message), prefix };
|
||||
this.history.push(entry);
|
||||
for (const listener of this.entryListeners) {
|
||||
try {
|
||||
|
||||
@@ -1115,12 +1115,23 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
before the shutdown closure exists.
|
||||
*/
|
||||
let shutdownExitCode = 0;
|
||||
// Coalesce concurrent restart requests: without this, a second /system/restart
|
||||
// arriving inside the 300ms flush delay would return success and schedule a
|
||||
// second shutdown() whose timer hits the shutdownInProgress fast-path and
|
||||
// process.exit(86)s before the first (graceful) teardown finishes.
|
||||
let restartScheduled = false;
|
||||
let requestSelfRestart: ((reason: string) => boolean) | null = null;
|
||||
const systemControlForServer = {
|
||||
supervised: process.env.FUSION_RESTART_SUPERVISED === "1",
|
||||
requestRestart: (reason: string) => (requestSelfRestart ? requestSelfRestart(reason) : false),
|
||||
sourceWorkspaceRoot: resolveFusionSourceWorkspaceRoot(),
|
||||
};
|
||||
// Built once and spread into both createServer() call sites (engine-mode and
|
||||
// UI-only) so the System panel log surface stays a single definition.
|
||||
const systemLogsForServer = {
|
||||
getRecent: (limit?: number) => logSink.getRecentEntries(limit),
|
||||
subscribe: (listener: (entry: import("./dashboard-tui/log-ring-buffer.js").LogEntry) => void) => logSink.subscribeEntries(listener),
|
||||
};
|
||||
|
||||
/*
|
||||
* FNXC:DashboardShutdown 2026-06-27-10:32:
|
||||
@@ -2100,10 +2111,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
noAuth: opts.noAuth,
|
||||
runtimeLogger,
|
||||
systemControl: systemControlForServer,
|
||||
systemLogs: {
|
||||
getRecent: (limit?: number) => logSink.getRecentEntries(limit),
|
||||
subscribe: (listener) => logSink.subscribeEntries(listener),
|
||||
},
|
||||
systemLogs: systemLogsForServer,
|
||||
});
|
||||
|
||||
const shutdown = async (signal: NodeJS.Signals) => {
|
||||
@@ -2176,7 +2184,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
Restart is only honored when a supervising parent will respawn us.
|
||||
*/
|
||||
requestSelfRestart = (reason: string) => {
|
||||
if (!systemControlForServer.supervised || shutdownInProgress) return false;
|
||||
if (!systemControlForServer.supervised || shutdownInProgress || restartScheduled) return false;
|
||||
restartScheduled = true;
|
||||
logSink.log(`restart requested (${reason}) — shutting down for supervised respawn`, "dashboard");
|
||||
shutdownExitCode = FUSION_RESTART_EXIT_CODE;
|
||||
setTimeout(() => {
|
||||
@@ -2436,10 +2445,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
noAuth: opts.noAuth,
|
||||
runtimeLogger,
|
||||
systemControl: systemControlForServer,
|
||||
systemLogs: {
|
||||
getRecent: (limit?: number) => logSink.getRecentEntries(limit),
|
||||
subscribe: (listener) => logSink.subscribeEntries(listener),
|
||||
},
|
||||
systemLogs: systemLogsForServer,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2507,7 +2513,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// FNXC:SystemPanel 2026-07-12-11:00: System panel restart binding for
|
||||
// UI-only mode — same contract as the engine-mode shutdown above.
|
||||
requestSelfRestart = (reason: string) => {
|
||||
if (!systemControlForServer.supervised || shutdownInProgress) return false;
|
||||
if (!systemControlForServer.supervised || shutdownInProgress || restartScheduled) return false;
|
||||
restartScheduled = true;
|
||||
logSink.log(`restart requested (${reason}) — shutting down for supervised respawn`, "dashboard");
|
||||
shutdownExitCode = FUSION_RESTART_EXIT_CODE;
|
||||
setTimeout(() => {
|
||||
@@ -3325,8 +3332,10 @@ export function shouldSuperviseDashboard(
|
||||
* interactive TUI must own the terminal: a background-process-group child
|
||||
* reading a TTY gets SIGTTIN/SIGTTOU-stopped, which is why detached
|
||||
* supervision was headless-only. Attached means terminal Ctrl+C reaches the
|
||||
* child directly; the parent ignores SIGINT (waiting for the child's graceful
|
||||
* exit) and forwards direct SIGTERM kills to the child. Exit code
|
||||
* child directly; when a child is alive the parent waits for its graceful exit
|
||||
* (and exits immediately on SIGINT during crash-backoff, when no child is alive
|
||||
* to receive Ctrl+C), and forwards direct SIGTERM kills to the child. A parent
|
||||
* signal latches `stopping` so an intentional shutdown never respawns. Exit code
|
||||
* FUSION_RESTART_EXIT_CODE is an operator-requested restart (System panel):
|
||||
* immediate respawn, no crash budget consumed.
|
||||
*
|
||||
@@ -3356,17 +3365,29 @@ export async function runDashboardSupervised(
|
||||
const restartCommand = formatSupervisorRestartCommand(respawn.command, respawn.args, childArgs);
|
||||
|
||||
let activeChild: ReturnType<typeof spawnAttached> | null = null;
|
||||
// `stopping` latches once the operator asks to quit so the restart loop never
|
||||
// respawns after an intentional shutdown, even if the child's post-signal
|
||||
// exit code is non-zero.
|
||||
let stopping = false;
|
||||
// Parent lifecycle: terminal Ctrl+C (SIGINT) already reaches the attached
|
||||
// child via the shared foreground process group, so the parent just waits
|
||||
// for the child's graceful exit. A direct SIGTERM to the parent (process
|
||||
// managers, `kill`) is forwarded so the child shuts down too. If the parent
|
||||
// dies unexpectedly, best-effort kill the child on exit.
|
||||
// child via the shared foreground process group, so when a child is alive the
|
||||
// parent just waits for its graceful exit. But during crash-backoff (or
|
||||
// between spawns) there is NO child to receive the terminal SIGINT, so Ctrl+C
|
||||
// would hang for up to the backoff window — exit immediately in that case.
|
||||
process.on("SIGINT", () => {
|
||||
/* child receives terminal SIGINT directly; wait for its exit */
|
||||
stopping = true;
|
||||
if (!activeChild) process.exit(130);
|
||||
});
|
||||
// A direct SIGTERM to the parent (process managers, `kill`) is forwarded so
|
||||
// the child shuts down too, then the loop stops. During crash-backoff there
|
||||
// is no child to forward to and the loop is parked in a sleep, so exit
|
||||
// immediately rather than waiting out the backoff. If the parent dies
|
||||
// unexpectedly, best-effort kill the child on exit.
|
||||
process.on("SIGTERM", () => {
|
||||
stopping = true;
|
||||
if (!activeChild) process.exit(143);
|
||||
try {
|
||||
activeChild?.child.kill("SIGTERM");
|
||||
activeChild.child.kill("SIGTERM");
|
||||
} catch {
|
||||
// Child may already be gone.
|
||||
}
|
||||
@@ -3396,6 +3417,12 @@ export async function runDashboardSupervised(
|
||||
const exitCode = exitResult.code ?? 1;
|
||||
const exitSignal = exitResult.signal;
|
||||
|
||||
// Operator asked to stop (SIGINT/SIGTERM to the parent) — never respawn,
|
||||
// regardless of the child's post-signal exit code.
|
||||
if (stopping) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean exit — propagate without restart
|
||||
if (exitSignal === "SIGINT" || exitSignal === "SIGTERM" || exitCode === 0) {
|
||||
return;
|
||||
|
||||
@@ -69,6 +69,11 @@ never grows the page; the page must not scroll horizontally (pre wraps).
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.cc-syscontrols-banner--error {
|
||||
justify-content: space-between;
|
||||
border-color: var(--danger, #f87171);
|
||||
}
|
||||
|
||||
.cc-syscontrols-job-status {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import {
|
||||
createBackup,
|
||||
fetchDashboardHealth,
|
||||
fetchCurrentSystemRebuild,
|
||||
fetchSystemInfo,
|
||||
fetchSystemLogs,
|
||||
reloadAllSystemPlugins,
|
||||
@@ -52,10 +53,14 @@ runtime-metrics area. Requirements this encodes:
|
||||
const LOG_VIEW_CAP = 500;
|
||||
const RESTART_POLL_MS = 1500;
|
||||
const BACK_ONLINE_RELOAD_DELAY_MS = 3000;
|
||||
// Bound the post-restart wait so a server that never comes back (crashed
|
||||
// respawn, unsupervised restart that stopped) doesn't leave the panel polling
|
||||
// forever with every control disabled.
|
||||
const RESTART_WAIT_TIMEOUT_MS = 90_000;
|
||||
const BUG_URL_BODY_CAP = 5500;
|
||||
const GITHUB_NEW_ISSUE_URL = "https://github.com/Runfusion/Fusion/issues/new";
|
||||
|
||||
type RestartPhase = null | "waiting" | "back";
|
||||
type RestartPhase = null | "waiting" | "back" | "timeout";
|
||||
|
||||
interface SystemControlsAreaProps {
|
||||
projectId?: string;
|
||||
@@ -95,7 +100,13 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
||||
setInfo(next);
|
||||
setInfoError(null);
|
||||
if (next.activeRebuild) {
|
||||
setJob((current) => (current && current.id === next.activeRebuild!.id ? current : next.activeRebuild));
|
||||
setJob((current) => {
|
||||
if (current && current.id === next.activeRebuild!.id) return current;
|
||||
// Adopting a different (resumed) job — clear stale lines so the new
|
||||
// job's stream doesn't render mixed with the previous job's output.
|
||||
setJobLines([]);
|
||||
return next.activeRebuild;
|
||||
});
|
||||
}
|
||||
return next;
|
||||
} catch (err) {
|
||||
@@ -104,8 +115,28 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
||||
}
|
||||
}, []);
|
||||
|
||||
// On mount, hydrate the buffered rebuild output. A running job's lines also
|
||||
// arrive via the SSE replay-on-connect below, but a job that already
|
||||
// succeeded/failed before the panel opened is never streamed (the stream
|
||||
// effect skips non-running jobs), so without this the operator would see a
|
||||
// finished job with an empty log — losing the output needed to diagnose it.
|
||||
useEffect(() => {
|
||||
void loadInfo();
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
await loadInfo();
|
||||
try {
|
||||
const { job: current } = await fetchCurrentSystemRebuild();
|
||||
if (!cancelled && current) {
|
||||
setJob(current);
|
||||
setJobLines(current.lines ?? []);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort hydration; the live stream still fills a running job.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadInfo]);
|
||||
|
||||
// ── Rebuild job output streaming ──────────────────────────────────────────
|
||||
@@ -154,8 +185,13 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
||||
useEffect(() => {
|
||||
if (restartPhase !== "waiting") return;
|
||||
let cancelled = false;
|
||||
const startedAt = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
void (async () => {
|
||||
if (Date.now() - startedAt > RESTART_WAIT_TIMEOUT_MS) {
|
||||
if (!cancelled) setRestartPhase("timeout");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const next = await fetchSystemInfo();
|
||||
if (cancelled) return;
|
||||
@@ -164,7 +200,7 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
||||
setRestartPhase("back");
|
||||
}
|
||||
} catch {
|
||||
// Server still restarting — keep polling.
|
||||
// Server still restarting — keep polling until the timeout above.
|
||||
}
|
||||
})();
|
||||
}, RESTART_POLL_MS);
|
||||
@@ -181,14 +217,13 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
||||
}, [restartPhase]);
|
||||
|
||||
// ── Live server log tail ──────────────────────────────────────────────────
|
||||
// FNXC:SystemPanel 2026-07-12-14:05: The SSE stream replays the recent log
|
||||
// ring on connect (and reconnect), so it is the single source of truth — a
|
||||
// separate REST backfill here duplicated every recent line. Reset on open so
|
||||
// a reconnect's replay overwrites rather than appends past the cap boundary.
|
||||
useEffect(() => {
|
||||
if (!logsOpen || !info?.logsSupported) return;
|
||||
let unsubscribed = false;
|
||||
void fetchSystemLogs(LOG_VIEW_CAP)
|
||||
.then((response) => {
|
||||
if (!unsubscribed) setLogEntries(response.entries);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
setLogEntries([]);
|
||||
const unsubscribe = subscribeSse("/api/system/logs/stream", {
|
||||
events: {
|
||||
log: (event) => {
|
||||
@@ -203,11 +238,9 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
||||
}
|
||||
},
|
||||
},
|
||||
onReconnect: () => setLogEntries([]),
|
||||
});
|
||||
return () => {
|
||||
unsubscribed = true;
|
||||
unsubscribe();
|
||||
};
|
||||
return unsubscribe;
|
||||
}, [logsOpen, info?.logsSupported]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -338,6 +371,20 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
||||
.then((r) => r.entries.filter((entry) => entry.level === "error").slice(-5))
|
||||
.catch(() => [])
|
||||
: [];
|
||||
// FNXC:SystemPanel 2026-07-12-14:05: The recent-errors excerpt is
|
||||
// server log content sent to github.com. Require explicit confirmation
|
||||
// before including it (operator may not want internal logs public), and
|
||||
// neutralize embedded ``` so a log line can't break out of the fence.
|
||||
const includeErrors =
|
||||
recentErrors.length > 0 &&
|
||||
window.confirm(
|
||||
t(
|
||||
"systemControls.reportBugConfirm",
|
||||
"Include the last {{count}} server error log line(s) in the GitHub issue? They will be sent to github.com — review after the issue opens.",
|
||||
{ count: recentErrors.length },
|
||||
),
|
||||
);
|
||||
const fenceSafe = (text: string) => text.replace(/`/g, "'");
|
||||
let body = [
|
||||
"### What happened",
|
||||
"",
|
||||
@@ -348,8 +395,8 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
||||
`- Platform: ${info?.platform ?? "unknown"} (${info?.arch ?? "?"}), Node ${info?.nodeVersion ?? "?"}`,
|
||||
`- Uptime: ${info?.uptimeSeconds ?? "?"}s, supervised: ${info?.supervised ?? false}`,
|
||||
"",
|
||||
...(recentErrors.length
|
||||
? ["### Recent server errors", "```", ...recentErrors.map((entry) => `${entry.prefix ? `[${entry.prefix}] ` : ""}${entry.message}`), "```"]
|
||||
...(includeErrors
|
||||
? ["### Recent server errors", "```", ...recentErrors.map((entry) => fenceSafe(`${entry.prefix ? `[${entry.prefix}] ` : ""}${entry.message}`)), "```"]
|
||||
: []),
|
||||
].join("\n");
|
||||
if (body.length > BUG_URL_BODY_CAP) body = `${body.slice(0, BUG_URL_BODY_CAP)}\n…(truncated)`;
|
||||
@@ -558,6 +605,19 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{restartPhase === "timeout" ? (
|
||||
<div className="cc-syscontrols-banner cc-syscontrols-banner--error" role="status" data-testid="cc-system-restart-timeout">
|
||||
<span>
|
||||
{t(
|
||||
"systemControls.restartTimeout",
|
||||
"The server did not come back within the expected time. It may still be restarting, or the restart may have stopped it.",
|
||||
)}
|
||||
</span>
|
||||
<button type="button" className="btn" onClick={() => window.location.reload()}>
|
||||
{t("systemControls.reloadNow", "Reload now")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="cc-syscontrols-grid">
|
||||
{controls.filter((control) => !("hidden" in control && control.hidden)).map((control) => (
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import http from "node:http";
|
||||
import type { Socket } from "node:net";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import express from "express";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { request as performRequest } from "../../test-request.js";
|
||||
@@ -155,7 +158,10 @@ describe("GET /system/info", () => {
|
||||
options: {
|
||||
systemControl: { supervised: true, requestRestart: vi.fn(() => true), sourceWorkspaceRoot: "/checkout" },
|
||||
systemLogs: { getRecent: vi.fn(() => []), subscribe: vi.fn(() => () => {}) },
|
||||
// engineAvailable requires BOTH engineManager and centralCore, matching
|
||||
// the /system/engine/restart guard.
|
||||
engineManager: {},
|
||||
centralCore: {},
|
||||
},
|
||||
});
|
||||
const res = await getJson(app, "/api/system/info");
|
||||
@@ -289,7 +295,7 @@ describe("POST /system/engine/restart", () => {
|
||||
});
|
||||
|
||||
it("pause+resumes each running project engine and reports failures", async () => {
|
||||
const pauseProject = vi.fn(async () => {});
|
||||
const pauseProject = vi.fn(async (_id: string) => {});
|
||||
const resumeProject = vi.fn(async (id: string) => {
|
||||
if (id === "p2") throw new Error("resume failed");
|
||||
});
|
||||
@@ -307,8 +313,36 @@ describe("POST /system/engine/restart", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.restarted).toEqual(["p1"]);
|
||||
expect(res.body.failed).toEqual([{ projectId: "p2", error: "resume failed" }]);
|
||||
// p3 has no running engine — must not be touched.
|
||||
expect(pauseProject).toHaveBeenCalledTimes(2);
|
||||
// p1 paused once (then resumed); p2 paused twice — the initial pause plus a
|
||||
// compensating pause after resume failed, so the project isn't left marked
|
||||
// active with a dead engine. p3 has no running engine and is untouched.
|
||||
expect(pauseProject.mock.calls.map((c) => c[0])).toEqual(["p1", "p2", "p2"]);
|
||||
expect(pauseProject).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("still succeeds when the compensating pause of a failed project also throws", async () => {
|
||||
const resumeProject = vi.fn(async () => {
|
||||
throw new Error("resume failed");
|
||||
});
|
||||
// First pause (pre-resume) succeeds; the recovery pause in the catch throws
|
||||
// — the route must swallow it and still report the original resume failure.
|
||||
let pauseCalls = 0;
|
||||
const pauseProject = vi.fn(async () => {
|
||||
pauseCalls += 1;
|
||||
if (pauseCalls === 2) throw new Error("pause failed too");
|
||||
});
|
||||
const engineManager = {
|
||||
getEngine: () => ({}),
|
||||
pauseProject,
|
||||
resumeProject,
|
||||
};
|
||||
const centralCore = { listProjects: vi.fn(async () => [{ id: "p1" }]) };
|
||||
const { app } = createApp({ options: { engineManager, centralCore } });
|
||||
|
||||
const res = await postJson(app, "/api/system/engine/restart");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.restarted).toEqual([]);
|
||||
expect(res.body.failed).toEqual([{ projectId: "p1", error: "resume failed" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -375,3 +409,123 @@ describe("POST /system/plugins/reload-all", () => {
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:SystemPanel 2026-07-12-15:10:
|
||||
SSE-stream contract tests. An open SSE response (/system/logs/stream with a live
|
||||
provider) never calls res.end(), so the finish-based performRequest harness would
|
||||
hang; instead openSseStream builds a MockSocket-backed req/res, drives the
|
||||
(synchronous) handler, captures the replayed bytes, and lets the test simulate a
|
||||
client disconnect via req "close" to assert unsubscribe/cleanup. Deterministic —
|
||||
no real timers or network. Error paths (404 unknown job, 409 no provider) DO end
|
||||
the response, so those ride the normal performRequest harness.
|
||||
*/
|
||||
class SseMockSocket extends PassThrough {
|
||||
public writable = true;
|
||||
public readable = true;
|
||||
public remoteAddress = "127.0.0.1";
|
||||
public encrypted = false;
|
||||
setTimeout(): this { return this; }
|
||||
setNoDelay(): this { return this; }
|
||||
setKeepAlive(): this { return this; }
|
||||
destroySoon(): void { this.destroy(); }
|
||||
}
|
||||
|
||||
function openSseStream(app: App, path: string, headers: Record<string, string> = {}) {
|
||||
const socket = new SseMockSocket();
|
||||
socket.resume();
|
||||
const req = new http.IncomingMessage(socket as unknown as Socket);
|
||||
const res = new http.ServerResponse(req);
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
req.method = "GET";
|
||||
req.url = path;
|
||||
req.httpVersion = "1.1";
|
||||
req.headers = Object.fromEntries(
|
||||
Object.entries({ host: "127.0.0.1", ...headers }).map(([key, value]) => [key.toLowerCase(), value]),
|
||||
);
|
||||
res.assignSocket(socket as unknown as Socket);
|
||||
|
||||
const originalWrite = res.write.bind(res);
|
||||
res.write = ((chunk: string | Buffer, encoding?: unknown, cb?: unknown) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof encoding === "string" ? (encoding as BufferEncoding) : undefined));
|
||||
return originalWrite(chunk as never, encoding as never, cb as never);
|
||||
}) as typeof res.write;
|
||||
|
||||
// The stream handlers are fully synchronous (no await before the initial
|
||||
// replay/subscribe), and express.json() skips a bodyless GET synchronously, so
|
||||
// routing + writes complete during this call.
|
||||
(app as unknown as (req: http.IncomingMessage, res: http.ServerResponse) => void)(req, res);
|
||||
|
||||
return {
|
||||
req,
|
||||
res,
|
||||
text: () => Buffer.concat(chunks).toString("utf8"),
|
||||
close: () => req.emit("close"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("SSE streams", () => {
|
||||
it("GET /system/jobs/:id/stream 404s for an unknown job id", async () => {
|
||||
const { app } = createApp();
|
||||
const res = await performRequest(app, "GET", "/api/system/jobs/does-not-exist/stream");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /system/logs/stream 409s when no systemLogs provider is wired", async () => {
|
||||
const { app } = createApp();
|
||||
const res = await performRequest(app, "GET", "/api/system/logs/stream");
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("GET /system/logs/stream replays getRecent(200) as SSE log events and unsubscribes on client close", () => {
|
||||
const entries = [
|
||||
{ timestamp: new Date(), level: "info" as const, message: "first-entry" },
|
||||
{ timestamp: new Date(), level: "warn" as const, message: "second-entry" },
|
||||
];
|
||||
const getRecent = vi.fn((limit?: number) => (limit === 200 ? entries : []));
|
||||
const unsubscribe = vi.fn();
|
||||
const subscribe = vi.fn(() => unsubscribe);
|
||||
const { app } = createApp({ options: { systemLogs: { getRecent, subscribe } } });
|
||||
|
||||
const stream = openSseStream(app, "/api/system/logs/stream");
|
||||
|
||||
// Initial replay reads exactly the last-200 window, and the live tail is
|
||||
// wired via a single subscribe().
|
||||
expect(getRecent).toHaveBeenCalledWith(200);
|
||||
expect(subscribe).toHaveBeenCalledTimes(1);
|
||||
|
||||
const text = stream.text();
|
||||
const logFrames = text.split("\n\n").filter((frame) => frame.startsWith("event: log"));
|
||||
expect(logFrames).toHaveLength(2);
|
||||
expect(text).toContain("first-entry");
|
||||
expect(text).toContain("second-entry");
|
||||
|
||||
// Client disconnect must tear down the live subscription (no leak).
|
||||
expect(unsubscribe).not.toHaveBeenCalled();
|
||||
stream.close();
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("POST /system/restart rejects a cross-origin Origin with 403 and passes a same-origin Origin", async () => {
|
||||
const { app } = createApp();
|
||||
|
||||
// Cross-origin: Origin host (evil.example) != Host (127.0.0.1) → the
|
||||
// same-origin CSRF guard returns 403 before touching any restart logic.
|
||||
const cross = await performRequest(app, "POST", "/api/system/restart", JSON.stringify({}), {
|
||||
"content-type": "application/json",
|
||||
origin: "https://evil.example",
|
||||
host: "127.0.0.1",
|
||||
});
|
||||
expect(cross.status).toBe(403);
|
||||
|
||||
// Same-origin: Origin host matches Host → guard passes, so the request
|
||||
// reaches the normal handler (409 here because no systemControl is wired).
|
||||
const same = await performRequest(app, "POST", "/api/system/restart", JSON.stringify({}), {
|
||||
"content-type": "application/json",
|
||||
origin: "http://127.0.0.1",
|
||||
host: "127.0.0.1",
|
||||
});
|
||||
expect(same.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Response } from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import { superviseSpawn, AgentStore } from "@fusion/core";
|
||||
import { ApiError, badRequest, notFound } from "../api-error.js";
|
||||
import { writeSSEEvent } from "../sse-buffer.js";
|
||||
@@ -34,6 +34,57 @@ builds would corrupt each other's dist output.
|
||||
const JOB_LINE_CAP = 4_000;
|
||||
const REBUILD_MAX_LIFETIME_MS = 30 * 60_000;
|
||||
|
||||
/*
|
||||
FNXC:SystemPanel 2026-07-12-14:05:
|
||||
SSE heartbeat interval. The shared client sse-bus force-reconnects a stream
|
||||
that goes silent for ~45s; each reconnect re-runs the route's replay-on-connect
|
||||
(the last N log lines / job lines), which surfaced as the log tail duplicating
|
||||
every entry. A periodic comment-frame heartbeat keeps the stream "live" so
|
||||
steady-state reconnects don't happen. Cleared on client disconnect / job end.
|
||||
*/
|
||||
const SSE_HEARTBEAT_MS = 25_000;
|
||||
|
||||
/*
|
||||
FNXC:SystemPanel 2026-07-12-14:05:
|
||||
Same-origin CSRF guard for the mutating /system/* POSTs. These are privileged
|
||||
operator actions (restart, rebuild, engine/agent bounce, plugin reload) and must
|
||||
stay safe even when bearer auth is off — `--no-auth`, and the desktop embedded
|
||||
server which runs unauthenticated on a random localhost port where a malicious
|
||||
web page can reach it via DNS-rebinding / localhost port-scan. A same-origin
|
||||
dashboard fetch sends an Origin matching Host; a cross-origin browser attack
|
||||
sends a mismatched Origin; a CLI/curl call sends none. Reject only a present,
|
||||
mismatched Origin so legitimate same-origin and non-browser callers pass.
|
||||
Returns true (and responds 403) when the request was rejected.
|
||||
*/
|
||||
function rejectCrossOrigin(req: Request, res: Response): boolean {
|
||||
const origin = req.headers.origin;
|
||||
if (typeof origin !== "string" || origin.length === 0) return false;
|
||||
let originHost: string;
|
||||
try {
|
||||
originHost = new URL(origin).host;
|
||||
} catch {
|
||||
res.status(403).json({ error: "Invalid Origin header" });
|
||||
return true;
|
||||
}
|
||||
if (originHost !== req.headers.host) {
|
||||
res.status(403).json({ error: "Cross-origin request rejected" });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function startSseHeartbeat(res: Response): () => void {
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
res.write(": heartbeat\n\n");
|
||||
} catch {
|
||||
// Stream already closed; the close handler clears this timer.
|
||||
}
|
||||
}, SSE_HEARTBEAT_MS);
|
||||
timer.unref?.();
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
type RebuildScope = "app" | "full" | "plugins";
|
||||
|
||||
interface SystemJobLine {
|
||||
@@ -199,7 +250,9 @@ export function registerSystemRoutes(ctx: ApiRoutesContext, deps: SystemRouteDep
|
||||
rebuildSupported: Boolean(systemControl?.sourceWorkspaceRoot),
|
||||
sourceWorkspaceRoot: systemControl?.sourceWorkspaceRoot,
|
||||
logsSupported: Boolean(systemLogs),
|
||||
engineAvailable: Boolean(options?.engineManager),
|
||||
// Engine restart needs both the manager and CentralCore (see the
|
||||
// /system/engine/restart guard), so advertise availability on both.
|
||||
engineAvailable: Boolean(options?.engineManager) && Boolean(options?.centralCore),
|
||||
pluginReloadSupported: Boolean(options?.pluginRunner?.reloadPlugin),
|
||||
pid: process.pid,
|
||||
uptimeSeconds: Math.floor(process.uptime()),
|
||||
@@ -214,12 +267,17 @@ export function registerSystemRoutes(ctx: ApiRoutesContext, deps: SystemRouteDep
|
||||
|
||||
/** POST /api/system/restart — graceful restart via the supervising parent. */
|
||||
router.post("/system/restart", (req, res) => {
|
||||
if (rejectCrossOrigin(req, res)) return;
|
||||
if (!systemControl) {
|
||||
throw new ApiError(409, "Restart is not available: host process did not wire system control");
|
||||
}
|
||||
const reason = typeof (req.body as { reason?: unknown })?.reason === "string"
|
||||
? (req.body as { reason: string }).reason
|
||||
: "operator-request";
|
||||
// Sanitize the client-supplied reason before it reaches host logs: strip
|
||||
// control chars/newlines (log injection) and bound the length.
|
||||
const rawReason = (req.body as { reason?: unknown })?.reason;
|
||||
const reason = (typeof rawReason === "string" ? rawReason : "operator-request")
|
||||
// eslint-disable-next-line no-control-regex -- deliberately strip C0 control chars (log injection)
|
||||
.replace(/[\r\n | ||||