- Add shared agent role policy helpers for implementation task detection and executor-role enforcement - Provide a standardized role-mismatch error formatter for delegation and assignment paths - Update test isolation leak detection to ignore ephemeral fusion-test-home-root temp directories while preserving baseline checks Fusion-Task-Id: FN-3716
298 lines
10 KiB
JavaScript
Executable File
298 lines
10 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
import { readdirSync, statSync, existsSync, writeFileSync, readFileSync, realpathSync } from "node:fs";
|
|
import { createHash } from "node:crypto";
|
|
import { homedir, tmpdir } from "node:os";
|
|
import { join, resolve, sep } from "node:path";
|
|
import { spawnSync } from "node:child_process";
|
|
|
|
function stableCwd() {
|
|
try {
|
|
return realpathSync(process.cwd());
|
|
} catch {
|
|
return process.cwd();
|
|
}
|
|
}
|
|
|
|
// Namespace the baseline by cwd so concurrent worktrees don't clobber each
|
|
// other's baseline. Without this, running `--before` in worktree A and the
|
|
// post-test check in worktree B reads a baseline that doesn't include B's
|
|
// `.fusion`, which then trips the "live data changed" failure path.
|
|
const cwdHash = createHash("sha1").update(stableCwd()).digest("hex").slice(0, 12);
|
|
const BASELINE_FILE = join(tmpdir(), `.fusion-isolation-baseline-${cwdHash}`);
|
|
|
|
const TRACKED_PREFIXES = [
|
|
"fusion-worker-",
|
|
"fusion-test-",
|
|
"fusion-test-cwd-",
|
|
"fusion-provider-settings-",
|
|
"fusion-provider-auth-",
|
|
"fusion-provider-auth-oauth-",
|
|
"fusion-agent-dir-",
|
|
"kb-db-test-",
|
|
"kb-backup-test-",
|
|
"kb-migration-test-",
|
|
"kb-fresh-",
|
|
"kb-needs-migration-",
|
|
"kb-compat-test-",
|
|
"kb-first-run-test-",
|
|
];
|
|
|
|
function stablePath(pathValue) {
|
|
try {
|
|
return realpathSync(pathValue);
|
|
} catch {
|
|
return resolve(pathValue);
|
|
}
|
|
}
|
|
|
|
function snapshotTmp() {
|
|
const entries = readdirSync(tmpdir());
|
|
const matching = [];
|
|
for (const name of entries) {
|
|
if (!TRACKED_PREFIXES.some((p) => name.startsWith(p))) continue;
|
|
const full = join(tmpdir(), name);
|
|
try {
|
|
const stat = statSync(full);
|
|
if (stat.isDirectory()) matching.push({ name, mtime: stat.mtimeMs });
|
|
} catch {
|
|
// Ignore transient file-system races while scanning /tmp.
|
|
}
|
|
}
|
|
return matching;
|
|
}
|
|
|
|
function listProtectedFusionDirs() {
|
|
const dirs = new Set();
|
|
dirs.add(stablePath(join(process.cwd(), ".fusion")));
|
|
dirs.add(stablePath(join(process.env.HOME || process.env.USERPROFILE || homedir(), ".fusion")));
|
|
return [...dirs];
|
|
}
|
|
|
|
// Paths inside a protected .fusion root that a concurrently-running fusion app
|
|
// is expected to mutate. Tests still must not write to these — the filter only
|
|
// suppresses noise from a live app sharing the same HOME during local dev.
|
|
const RUNTIME_IGNORE_PATTERNS = [
|
|
/^agent(?:[/\\]|$)/,
|
|
/^agents(?:[/\\]|$)/,
|
|
/^agent-memory(?:[/\\]|$)/,
|
|
/^automations(?:[/\\]|$)/,
|
|
/^backups(?:[/\\]|$)/,
|
|
/^plugins(?:[/\\]|$)/,
|
|
/^cache(?:[/\\]|$)/,
|
|
/^config\.json$/,
|
|
/^fusion-central\.db(?:-wal|-shm|-journal)?$/,
|
|
/^fusion\.db(?:-wal|-shm|-journal)?(?:\.backup-[\w-]+)?(?:\.pre-[\w-]+)?$/,
|
|
/^archive\.db(?:-wal|-shm|-journal)?(?:\.backup-[\w-]+)?$/,
|
|
/^kb\.db(?:-wal|-shm|-journal)?(?:\.backup-[\w-]+)?$/,
|
|
/^activity-log\.jsonl$/,
|
|
/^settings\.json$/,
|
|
/^logs(?:[/\\]|$)/,
|
|
/^tasks(?:[/\\]|$)/,
|
|
/^memory(?:[/\\]|$)/,
|
|
/^messages(?:[/\\]|$)/,
|
|
/^memory-insights\.md$/,
|
|
/^test-cache\.json$/,
|
|
/^HEARTBEAT\.md$/,
|
|
/^MEMORY\.md$/,
|
|
/^DREAMS\.md$/,
|
|
/^\d{4}-\d{2}-\d{2}\.md$/,
|
|
/^scripts\.json$/,
|
|
/^update-check\.json$/,
|
|
/^disabled-auto-extension-discovery$/,
|
|
];
|
|
|
|
function isRuntimePath(relPath) {
|
|
return RUNTIME_IGNORE_PATTERNS.some((re) => re.test(relPath));
|
|
}
|
|
|
|
function collectFusionSignature(rootDir, out = []) {
|
|
if (!existsSync(rootDir)) return out;
|
|
let stat;
|
|
try {
|
|
stat = statSync(rootDir);
|
|
} catch {
|
|
return out;
|
|
}
|
|
if (!stat.isDirectory()) return out;
|
|
|
|
const entries = readdirSync(rootDir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const fullPath = join(rootDir, entry.name);
|
|
const relPath = fullPath.slice(rootDir.length + (rootDir.endsWith(sep) ? 0 : 1));
|
|
if (isRuntimePath(relPath)) continue;
|
|
try {
|
|
statSync(fullPath);
|
|
} catch {
|
|
continue;
|
|
}
|
|
// Track only path + kind. Size/mtime would flip every time the live app
|
|
// touches an existing file (fusion.db-wal heartbeats, settings.json saves),
|
|
// producing false-positive "leak" failures. New files still get detected
|
|
// because they add a new entry to the set; tests are independently blocked
|
|
// from writing to the real .fusion via the fs guards in vitest-setup.ts.
|
|
out.push(`${relPath}|${entry.isDirectory() ? "d" : "f"}`);
|
|
if (entry.isDirectory()) collectFusionSignature(fullPath, out);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function snapshotProtectedFusion() {
|
|
return listProtectedFusionDirs().map((dir) => ({
|
|
dir,
|
|
exists: existsSync(dir),
|
|
entries: collectFusionSignature(dir).sort(),
|
|
}));
|
|
}
|
|
|
|
function sleepMs(ms) {
|
|
// Cross-platform enough for CI and local dev; fall back to best-effort no-op.
|
|
spawnSync(process.platform === "win32" ? "powershell" : "sleep", process.platform === "win32" ? ["-NoProfile", "-Command", `Start-Sleep -Milliseconds ${ms}`] : [String(ms / 1000)], { stdio: "ignore" });
|
|
}
|
|
|
|
function recordBaseline() {
|
|
const samples = [snapshotProtectedFusion()];
|
|
for (let i = 0; i < 4; i++) {
|
|
sleepMs(500);
|
|
samples.push(snapshotProtectedFusion());
|
|
}
|
|
|
|
const latestProtected = samples[samples.length - 1];
|
|
const unstableProtectedDirs = [];
|
|
const firstProtected = samples[0];
|
|
for (const first of firstProtected) {
|
|
let unstable = false;
|
|
for (let i = 1; i < samples.length; i++) {
|
|
const current = samples[i].find((entry) => entry.dir === first.dir);
|
|
if (!current) continue;
|
|
if (JSON.stringify(first.entries) !== JSON.stringify(current.entries)) {
|
|
unstable = true;
|
|
break;
|
|
}
|
|
}
|
|
if (unstable) unstableProtectedDirs.push(first.dir);
|
|
}
|
|
|
|
const payload = {
|
|
tmpNames: snapshotTmp().map((e) => e.name),
|
|
protectedFusion: latestProtected,
|
|
unstableProtectedDirs,
|
|
};
|
|
writeFileSync(BASELINE_FILE, JSON.stringify(payload));
|
|
console.log(`[test-isolation] Baseline recorded: ${payload.tmpNames.length} temp dir(s), ${payload.protectedFusion.length} protected .fusion root(s).`);
|
|
if (unstableProtectedDirs.length > 0) {
|
|
console.log(`[test-isolation] Ignoring ${unstableProtectedDirs.length} externally-active protected dir(s):`);
|
|
for (const dir of unstableProtectedDirs) console.log(` ${dir}`);
|
|
}
|
|
}
|
|
|
|
function checkAgainstBaseline() {
|
|
let baseline = { tmpNames: [], protectedFusion: [] };
|
|
if (existsSync(BASELINE_FILE)) {
|
|
try {
|
|
baseline = JSON.parse(readFileSync(BASELINE_FILE, "utf-8"));
|
|
} catch {
|
|
// Ignore malformed baseline payloads and treat as empty baseline.
|
|
}
|
|
}
|
|
|
|
const baselineNames = new Set(baseline.tmpNames ?? []);
|
|
// Caller (scripts/test-changed.mjs) tells us which fusion-test-home-root-*
|
|
// basenames it minted this run. We allow-list them unconditionally so a
|
|
// transient cleanup failure or a rotated baseline file can't masquerade as
|
|
// a real test leak.
|
|
const callerIgnoreNames = (process.env.FUSION_TEST_ISOLATION_IGNORE_NAMES ?? "")
|
|
.split(",")
|
|
.map((name) => name.trim())
|
|
.filter(Boolean);
|
|
for (const name of callerIgnoreNames) baselineNames.add(name);
|
|
const leaks = snapshotTmp().filter((e) => {
|
|
if (baselineNames.has(e.name)) {
|
|
return false;
|
|
}
|
|
if (e.name.startsWith("fusion-test-home-root-")) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const baselineByDir = new Map((baseline.protectedFusion ?? []).map((entry) => [entry.dir, entry]));
|
|
const unstableProtectedDirs = new Set(baseline.unstableProtectedDirs ?? []);
|
|
const currentProtected = snapshotProtectedFusion();
|
|
const candidateViolations = [];
|
|
const skippedUnknownDirs = [];
|
|
for (const current of currentProtected) {
|
|
if (unstableProtectedDirs.has(current.dir)) continue;
|
|
const base = baselineByDir.get(current.dir);
|
|
if (!base) {
|
|
// No baseline for this dir — the `--before` step ran from a different
|
|
// cwd (or never ran). We can't distinguish pre-existing entries from
|
|
// test-created ones, so warn and skip instead of false-failing.
|
|
skippedUnknownDirs.push(current.dir);
|
|
continue;
|
|
}
|
|
const changedExistence = Boolean(base.exists) !== Boolean(current.exists);
|
|
const changedEntries = JSON.stringify(base.entries) !== JSON.stringify(current.entries);
|
|
if (changedExistence || changedEntries) {
|
|
candidateViolations.push(current.dir);
|
|
}
|
|
}
|
|
|
|
const protectedViolations = [];
|
|
if (candidateViolations.length > 0) {
|
|
// A live local app can write in bursts (e.g. heartbeat every few seconds),
|
|
// so do a short mutability probe before blaming tests.
|
|
const postSamples = [currentProtected];
|
|
for (let i = 0; i < 4; i++) {
|
|
sleepMs(500);
|
|
postSamples.push(snapshotProtectedFusion());
|
|
}
|
|
|
|
for (const dir of candidateViolations) {
|
|
let externallyActive = false;
|
|
for (let i = 1; i < postSamples.length; i++) {
|
|
const prev = postSamples[i - 1].find((entry) => entry.dir === dir);
|
|
const next = postSamples[i].find((entry) => entry.dir === dir);
|
|
if (!prev || !next) continue;
|
|
if (JSON.stringify(prev.entries) !== JSON.stringify(next.entries)) {
|
|
externallyActive = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!externallyActive) {
|
|
protectedViolations.push(dir);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (skippedUnknownDirs.length > 0) {
|
|
console.warn(`[test-isolation] WARN: ${skippedUnknownDirs.length} protected dir(s) absent from baseline (was \`--before\` run from a different cwd?):`);
|
|
for (const dir of skippedUnknownDirs) console.warn(` ${dir}`);
|
|
}
|
|
|
|
if (leaks.length === 0 && protectedViolations.length === 0) {
|
|
console.log("[test-isolation] No temp leaks or live .fusion mutations detected.");
|
|
process.exit(0);
|
|
}
|
|
|
|
if (leaks.length > 0) {
|
|
console.error(`[test-isolation] FAIL: ${leaks.length} leaked temp director${leaks.length === 1 ? "y" : "ies"}:`);
|
|
for (const leak of leaks) console.error(` ${join(tmpdir(), leak.name)}`);
|
|
console.error("");
|
|
}
|
|
|
|
if (protectedViolations.length > 0) {
|
|
console.error("[test-isolation] FAIL: protected live .fusion data changed during tests:");
|
|
for (const dir of protectedViolations) console.error(` ${dir}`);
|
|
console.error("Tests must use temp HOME / temp workspaces and never write repo or user .fusion data.");
|
|
}
|
|
|
|
process.exit(1);
|
|
}
|
|
|
|
const args = process.argv.slice(2);
|
|
if (args.includes("--before")) {
|
|
recordBaseline();
|
|
} else {
|
|
checkAgainstBaseline();
|
|
} |