test: guard tests from killing the live dashboard port
Adds a static pretest check and a runtime vitest-setup wrapper that block shell/process calls matching `kill|pkill|killall|fuser|lsof ... <port>` or `.listen(<port>)` against reserved Fusion ports. Reserved set is dynamic: default 4040 plus $PORT, $FUSION_SERVER_PORT, $FUSION_RESERVED_PORTS, and any port responding to /api/health on 4040..4045 at worker startup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
21
.changeset/test-guard-no-kill-live-port.md
Normal file
21
.changeset/test-guard-no-kill-live-port.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
"@fusion/core": patch
|
||||
"fusion-workspace": patch
|
||||
---
|
||||
|
||||
test: guard against tests killing the live dashboard port
|
||||
|
||||
Adds a static `pretest` check (`scripts/check-no-kill-4040.mjs`) that scans
|
||||
tracked files for shell patterns like `kill|pkill|killall|fuser|lsof ... <port>`
|
||||
or `.listen(<port>)` targeting reserved Fusion ports, and a runtime guard in
|
||||
`packages/core/src/__test-utils__/vitest-setup.ts` that throws on any
|
||||
`child_process.{spawn,exec,fork,...}` call matching the same patterns.
|
||||
|
||||
Both guards resolve the reserved port set dynamically: default 4040, plus
|
||||
`$PORT`, `$FUSION_SERVER_PORT`, and `$FUSION_RESERVED_PORTS` (comma-separated).
|
||||
The runtime guard also probes `http://127.0.0.1:4040..4045/api/health` at
|
||||
worker startup and adds any responding live dashboard to the protected set.
|
||||
Set `FUSION_TEST_SKIP_PORT_PROBE=1` to disable probing.
|
||||
|
||||
Files that legitimately quote the rule (agent prompts) are exempt via a
|
||||
`port-4040-allowlist` marker comment.
|
||||
@@ -14,8 +14,8 @@
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
"pretest": "node scripts/check-no-nohup.mjs",
|
||||
"pretest:full": "node scripts/check-no-nohup.mjs",
|
||||
"pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs",
|
||||
"pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs",
|
||||
"local": "node scripts/start-local.mjs",
|
||||
"dev": "node scripts/dev-with-memory.mjs",
|
||||
"dev:ui": "pnpm --filter @fusion/dashboard dev",
|
||||
|
||||
@@ -468,6 +468,81 @@ function shouldBlockRealTestCli(commandLine: string): boolean {
|
||||
return !isSafeIntrospectionCommand(commandLine);
|
||||
}
|
||||
|
||||
// The live Fusion dashboard port(s) must not be killed by tests. We protect:
|
||||
// - the documented default (4040)
|
||||
// - process.env.PORT (set by `fusion serve` / desktop / docker)
|
||||
// - process.env.FUSION_SERVER_PORT (set when desktop spawns serve)
|
||||
// - any ports listed in FUSION_RESERVED_PORTS (comma-separated escape hatch)
|
||||
// - any port detected by a synchronous probe of localhost candidates
|
||||
// Detection runs once per worker at setup time so the regex set is stable.
|
||||
function parsePortList(value: string | undefined): number[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(",")
|
||||
.map((part) => Number.parseInt(part.trim(), 10))
|
||||
.filter((port) => Number.isInteger(port) && port > 0 && port < 65_536);
|
||||
}
|
||||
|
||||
async function probeFusionHealthPort(port: number, timeoutMs: number): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const text = await response.text();
|
||||
// The Fusion dashboard health payload always includes a `status` field.
|
||||
return /"status"\s*:/.test(text);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function detectLiveFusionPorts(candidates: readonly number[]): Promise<number[]> {
|
||||
const results = await Promise.all(
|
||||
candidates.map(async (port) => ((await probeFusionHealthPort(port, 250)) ? port : null)),
|
||||
);
|
||||
return results.filter((port): port is number => port !== null);
|
||||
}
|
||||
|
||||
async function resolveReservedFusionPorts(): Promise<number[]> {
|
||||
const reserved = new Set<number>([4040]);
|
||||
for (const port of parsePortList(process.env.FUSION_RESERVED_PORTS)) reserved.add(port);
|
||||
for (const port of parsePortList(process.env.PORT)) reserved.add(port);
|
||||
for (const port of parsePortList(process.env.FUSION_SERVER_PORT)) reserved.add(port);
|
||||
if (process.env.FUSION_TEST_SKIP_PORT_PROBE !== "1") {
|
||||
const probeRange = [4040, 4041, 4042, 4043, 4044, 4045];
|
||||
for (const port of await detectLiveFusionPorts(probeRange)) reserved.add(port);
|
||||
}
|
||||
return [...reserved];
|
||||
}
|
||||
|
||||
const RESERVED_FUSION_PORTS = await resolveReservedFusionPorts();
|
||||
|
||||
function buildPortKillPatterns(ports: readonly number[]): readonly RegExp[] {
|
||||
return ports.flatMap((port) => {
|
||||
const p = String(port);
|
||||
return [
|
||||
new RegExp(`\\b(?:kill|pkill|killall|fuser)\\b[^\\n]*\\b${p}\\b`),
|
||||
new RegExp(`\\blsof\\b[^\\n]*\\b${p}\\b`),
|
||||
new RegExp(`\\b${p}\\b[^\\n]*\\b(?:kill|pkill|killall|fuser)\\b`),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
const RESERVED_PORT_KILL_PATTERNS = buildPortKillPatterns(RESERVED_FUSION_PORTS);
|
||||
|
||||
function shouldBlockReservedPortKill(commandLine: string): boolean {
|
||||
return RESERVED_PORT_KILL_PATTERNS.some((pattern) => pattern.test(commandLine));
|
||||
}
|
||||
|
||||
function blockedReservedPortError(commandLine: string): Error {
|
||||
return new Error(
|
||||
`Reserved Fusion port kill blocked during tests: ${commandLine}\n` +
|
||||
`Reserved ports: ${RESERVED_FUSION_PORTS.join(", ")}. ` +
|
||||
"Use --port 0 or another free port for test servers.",
|
||||
);
|
||||
}
|
||||
|
||||
function blockedCliError(commandLine: string): Error {
|
||||
return new Error(
|
||||
`Real AI CLI launch blocked during tests: ${commandLine}\n` +
|
||||
@@ -534,6 +609,9 @@ function installChildProcessGuards(): void {
|
||||
const args = Array.isArray(argsOrOptions) ? [...argsOrOptions] : [];
|
||||
const options = Array.isArray(argsOrOptions) ? (maybeOptions ?? {}) : (argsOrOptions ?? {});
|
||||
const commandLine = describeTestSubprocessCommand(command, args);
|
||||
if (shouldBlockReservedPortKill(commandLine)) {
|
||||
throw blockedReservedPortError(commandLine);
|
||||
}
|
||||
if (shouldBlockRealTestCli(commandLine)) {
|
||||
throw blockedCliError(commandLine);
|
||||
}
|
||||
@@ -546,6 +624,9 @@ function installChildProcessGuards(): void {
|
||||
const args = Array.isArray(argsOrOptions) ? [...argsOrOptions] : [];
|
||||
const options = Array.isArray(argsOrOptions) ? withDefaultTimeout(maybeOptions) : withDefaultTimeout(argsOrOptions);
|
||||
const commandLine = describeTestSubprocessCommand(command, args);
|
||||
if (shouldBlockReservedPortKill(commandLine)) {
|
||||
throw blockedReservedPortError(commandLine);
|
||||
}
|
||||
if (shouldBlockRealTestCli(commandLine)) {
|
||||
throw blockedCliError(commandLine);
|
||||
}
|
||||
@@ -553,6 +634,9 @@ function installChildProcessGuards(): void {
|
||||
}) as ChildProcessModule["spawnSync"];
|
||||
|
||||
mutableChildProcess.execSync = ((command: string, options?: ExecSyncOptions) => {
|
||||
if (shouldBlockReservedPortKill(command)) {
|
||||
throw blockedReservedPortError(command);
|
||||
}
|
||||
if (shouldBlockRealTestCli(command)) {
|
||||
throw blockedCliError(command);
|
||||
}
|
||||
@@ -563,6 +647,9 @@ function installChildProcessGuards(): void {
|
||||
const args = Array.isArray(argsOrOptions) ? [...argsOrOptions] : [];
|
||||
const options = Array.isArray(argsOrOptions) ? withDefaultTimeout(maybeOptions) : withDefaultTimeout(argsOrOptions);
|
||||
const commandLine = describeTestSubprocessCommand(file, args);
|
||||
if (shouldBlockReservedPortKill(commandLine)) {
|
||||
throw blockedReservedPortError(commandLine);
|
||||
}
|
||||
if (shouldBlockRealTestCli(commandLine)) {
|
||||
throw blockedCliError(commandLine);
|
||||
}
|
||||
@@ -573,6 +660,9 @@ function installChildProcessGuards(): void {
|
||||
// and our wrapper drop the original [util.promisify.custom] symbol, which would otherwise
|
||||
// make awaited execAsync resolve to a raw stdout string and break destructuring.
|
||||
const execWrapper = ((command: string, optionsOrCallback?: ExecOptions | ((error: Error | null, stdout: string, stderr: string) => void), maybeCallback?: (error: Error | null, stdout: string, stderr: string) => void) => {
|
||||
if (shouldBlockReservedPortKill(command)) {
|
||||
throw blockedReservedPortError(command);
|
||||
}
|
||||
if (shouldBlockRealTestCli(command)) {
|
||||
throw blockedCliError(command);
|
||||
}
|
||||
@@ -599,6 +689,9 @@ function installChildProcessGuards(): void {
|
||||
const execFileWrapper = ((file: string, argsOrOptions?: readonly string[] | ExecFileOptions | ((error: Error | null, stdout: string, stderr: string) => void), optionsOrCallback?: ExecFileOptions | ((error: Error | null, stdout: string, stderr: string) => void), maybeCallback?: (error: Error | null, stdout: string, stderr: string) => void) => {
|
||||
const args = Array.isArray(argsOrOptions) ? [...argsOrOptions] : [];
|
||||
const commandLine = describeTestSubprocessCommand(file, args);
|
||||
if (shouldBlockReservedPortKill(commandLine)) {
|
||||
throw blockedReservedPortError(commandLine);
|
||||
}
|
||||
if (shouldBlockRealTestCli(commandLine)) {
|
||||
throw blockedCliError(commandLine);
|
||||
}
|
||||
@@ -630,6 +723,9 @@ function installChildProcessGuards(): void {
|
||||
const args = Array.isArray(argsOrOptions) ? [...argsOrOptions] : [];
|
||||
const options = Array.isArray(argsOrOptions) ? maybeOptions : argsOrOptions;
|
||||
const commandLine = describeTestSubprocessCommand(modulePath, args);
|
||||
if (shouldBlockReservedPortKill(commandLine)) {
|
||||
throw blockedReservedPortError(commandLine);
|
||||
}
|
||||
if (shouldBlockRealTestCli(commandLine)) {
|
||||
throw blockedCliError(commandLine);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// port-4040-allowlist: this file embeds the "never kill port 4040" rule in agent prompts.
|
||||
/**
|
||||
* Agent role prompt templates for customizable system prompts.
|
||||
*
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// port-4040-allowlist: this file embeds the "never kill port 4040" rule in the executor prompt.
|
||||
import { exec, execSync } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// port-4040-allowlist: this file embeds the "never kill port 4040" rule in the reviewer prompt.
|
||||
/**
|
||||
* Reviewer — spawns a separate pi agent to review a worker's plan or code.
|
||||
*
|
||||
|
||||
49
scripts/__tests__/check-no-kill-4040.test.mjs
Normal file
49
scripts/__tests__/check-no-kill-4040.test.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
// port-4040-allowlist: test fixtures intentionally include the rule itself
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { formatFailureMessage, scanFileContent } from "../check-no-kill-4040.mjs";
|
||||
|
||||
test("scanFileContent flags `kill ... 4040`", () => {
|
||||
const matches = scanFileContent("lsof -ti:4040 | xargs kill -9\n", "x.sh");
|
||||
assert.equal(matches.length, 1);
|
||||
assert.equal(matches[0].lineNumber, 1);
|
||||
});
|
||||
|
||||
test("scanFileContent flags pkill targeting 4040", () => {
|
||||
const matches = scanFileContent("pkill -f 'port 4040'\n", "x.sh");
|
||||
assert.equal(matches.length, 1);
|
||||
});
|
||||
|
||||
test("scanFileContent flags fuser -k :4040", () => {
|
||||
const matches = scanFileContent("fuser -k 4040/tcp\n", "x.sh");
|
||||
assert.equal(matches.length, 1);
|
||||
});
|
||||
|
||||
test("scanFileContent flags `.listen(4040)` bindings", () => {
|
||||
const matches = scanFileContent("app.listen(4040)\n", "x.test.ts");
|
||||
assert.equal(matches.length, 1);
|
||||
});
|
||||
|
||||
test("scanFileContent ignores files containing the allowlist marker", () => {
|
||||
const src = "// port-4040-allowlist\nlsof -ti:4040 | xargs kill\n";
|
||||
const matches = scanFileContent(src, "docs.md");
|
||||
assert.equal(matches.length, 0);
|
||||
});
|
||||
|
||||
test("scanFileContent ignores benign 4040 mentions (URLs, configs)", () => {
|
||||
const src = [
|
||||
"const url = 'http://localhost:4040';",
|
||||
"port: 4040,",
|
||||
"expect(node.port).toBe(4040);",
|
||||
].join("\n");
|
||||
const matches = scanFileContent(src, "x.test.ts");
|
||||
assert.equal(matches.length, 0);
|
||||
});
|
||||
|
||||
test("formatFailureMessage cites file and line and points at remediation", () => {
|
||||
const msg = formatFailureMessage([
|
||||
{ filePath: "tests/x.sh", lineNumber: 3, line: "kill $(lsof -ti:4040)" },
|
||||
]);
|
||||
assert.match(msg, /tests\/x\.sh:3/);
|
||||
assert.match(msg, /--port 0/);
|
||||
});
|
||||
110
scripts/check-no-kill-4040.mjs
Normal file
110
scripts/check-no-kill-4040.mjs
Normal file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
// Repo-wide guard: no code (especially tests) may kill processes on the live
|
||||
// Fusion dashboard port(s) or bind a server to them. Default reserved port is
|
||||
// 4040 (the documented default); additional ports may be supplied via the
|
||||
// FUSION_RESERVED_PORTS env var (comma-separated) so the guard tracks whatever
|
||||
// port the dashboard is actually configured to use. Use --port 0 or another
|
||||
// free port. Add `port-4040-allowlist` anywhere in a file's contents to mark
|
||||
// it as documentation that may legitimately discuss the rule.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ALLOWLIST_MARKER = "port-4040-allowlist";
|
||||
|
||||
function parsePortList(value) {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(",")
|
||||
.map((part) => Number.parseInt(part.trim(), 10))
|
||||
.filter((port) => Number.isInteger(port) && port > 0 && port < 65_536);
|
||||
}
|
||||
|
||||
// The documented default live port is 4040. Additional reserved ports may be
|
||||
// supplied via FUSION_RESERVED_PORTS (comma-separated) so the guard tracks
|
||||
// whichever port the dashboard is actually configured to use.
|
||||
const RESERVED_PORTS = [...new Set([4040, ...parsePortList(process.env.FUSION_RESERVED_PORTS)])];
|
||||
|
||||
function buildPatterns(ports) {
|
||||
return ports.flatMap((port) => {
|
||||
const p = String(port);
|
||||
return [
|
||||
new RegExp(`\\b(kill|pkill|killall|fuser)\\b[^\\n]*\\b${p}\\b`),
|
||||
new RegExp(`\\blsof\\b[^\\n]*\\b${p}\\b`),
|
||||
new RegExp(`\\b${p}\\b[^\\n]*\\b(kill|pkill|killall|fuser)\\b`),
|
||||
new RegExp(`\\.listen\\s*\\(\\s*${p}\\b`),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
export const PATTERNS = buildPatterns(RESERVED_PORTS);
|
||||
|
||||
const SCAN_ROOTS = ["packages", "scripts", "plugins"];
|
||||
|
||||
function listTrackedTargets() {
|
||||
const result = spawnSync("git", ["ls-files", "--", ...SCAN_ROOTS], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr?.trim() || "git ls-files failed");
|
||||
}
|
||||
return result.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.filter((path) => /\.(m?[jt]sx?|cjs|mjs|sh|json|md|mdx|txt)$/.test(path));
|
||||
}
|
||||
|
||||
export function scanFileContent(content, filePath) {
|
||||
if (content.includes(ALLOWLIST_MARKER)) return [];
|
||||
const matches = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
for (const pattern of PATTERNS) {
|
||||
if (pattern.test(line)) {
|
||||
matches.push({ filePath, lineNumber: i + 1, line });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function scanTrackedFiles(files = listTrackedTargets()) {
|
||||
const matches = [];
|
||||
for (const filePath of files) {
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
matches.push(...scanFileContent(content, filePath));
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function formatFailureMessage(matches) {
|
||||
const lines = matches.map(
|
||||
({ filePath, lineNumber, line }) => `${filePath}:${lineNumber}: ${line.trim()}`,
|
||||
);
|
||||
return [
|
||||
`[check-no-kill-4040] found code that may kill or bind reserved Fusion port(s): ${RESERVED_PORTS.join(", ")}.`,
|
||||
"These are the live dashboard ports. Use `--port 0` or another free port.",
|
||||
"If this match is documentation (e.g. agent prompts), add a `port-4040-allowlist` marker comment to the file.",
|
||||
...lines,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function main() {
|
||||
const matches = scanTrackedFiles();
|
||||
if (matches.length === 0) return 0;
|
||||
console.error(formatFailureMessage(matches));
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
process.exitCode = main();
|
||||
}
|
||||
Reference in New Issue
Block a user