diff --git a/bin/fixlab.js b/bin/fixlab.js index f728ed9..12f9687 100644 --- a/bin/fixlab.js +++ b/bin/fixlab.js @@ -21,6 +21,7 @@ import { DEFAULT_DASHBOARD_PORT, FIXLAB_RUNTIMES, inspectRepository, + linkedDependencyDirectories, validateTargetEnvironment, validateLiveTestProfile, validatePort @@ -942,12 +943,14 @@ function launch(repository, request, runtime, targetEnvironment = "") { ? buildCopilotInvocation({ packageRoot, prompt, - sessionId: randomUUID() + sessionId: randomUUID(), + additionalDirs: linkedDependencyDirectories(repository) }) : buildAgencyInvocation({ packageRoot, prompt, - sessionId: randomUUID() + sessionId: randomUUID(), + additionalDirs: linkedDependencyDirectories(repository) }) : { args: [ diff --git a/dashboard/public/app.js b/dashboard/public/app.js index 300a70c..87a493d 100644 --- a/dashboard/public/app.js +++ b/dashboard/public/app.js @@ -442,7 +442,11 @@ function renderJob(job, currentActiveJob = job) { canResume && job?.pullRequestReadiness?.status === "approval-required"; const canDismissFailedJob = - canResume && job?.status === "failed"; + canResume && ["failed", "blocked"].includes(job?.status); + dismissFailedJobButton.textContent = + job?.status === "blocked" + ? "Dismiss blocked job and continue queue" + : "Dismiss failed job and continue queue"; const canCancelQueuedJob = !isActive && job?.status === "queued"; jobInputPanel.hidden = !(canResume || canComment || canCancelQueuedJob); @@ -1223,7 +1227,7 @@ dismissFailedJobButton.addEventListener("click", async () => { formError.textContent = ""; if ( !window.confirm( - "Dismiss this failed job? It will remain failed in dashboard history, its session will not resume, and the next queued job will start." + `Dismiss this ${activeJob?.status === "blocked" ? "blocked" : "failed"} job? It will remain ${activeJob?.status === "blocked" ? "blocked" : "failed"} in dashboard history, its session will not resume, and the next queued job will start.` ) ) { return; @@ -1234,8 +1238,8 @@ dismissFailedJobButton.addEventListener("click", async () => { method: "POST" }); jobInputMessage.textContent = body.job - ? "Failed job dismissed. The next queued job has started." - : "Failed job dismissed. The queue is empty."; + ? "Job dismissed. The next queued job has started." + : "Job dismissed. The queue is empty."; if (body.dismissedJob) { rememberJob(body.dismissedJob); } diff --git a/dashboard/server.js b/dashboard/server.js index 7c3e1a7..b1df376 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -3,6 +3,7 @@ import { createHash, randomUUID } from "node:crypto"; import { createReadStream, existsSync, + lstatSync, mkdirSync, readdirSync, readFileSync, @@ -18,6 +19,7 @@ import { extname, isAbsolute, join, + parse, relative, resolve, sep @@ -1341,11 +1343,95 @@ function writeCacheSummary(context, job) { } } +// Linked dependency folders (for example a node_modules junction) resolve outside +// the repository, and non-interactive agents cannot answer the path prompt they raise. +// Only targets inside a profile-declared trustedDependencyRoots entry are granted, so a +// repository-controlled symlink alone never widens the agent's file access. +export function linkedDependencyDirectories(repository) { + let root; + let profile; + try { + root = realpathSync(repository); + profile = loadRepositoryProfile(root); + } catch { + return []; + } + + const trustedRoots = ( + Array.isArray(profile?.trustedDependencyRoots) + ? profile.trustedDependencyRoots + : [] + ) + .filter((value) => typeof value === "string" && isAbsolute(value)) + .flatMap((value) => { + try { + const resolved = realpathSync(value); + return resolved === parse(resolved).root ? [] : [resolved]; + } catch { + return []; + } + }); + if (trustedRoots.length === 0) { + return []; + } + + const isInside = (parent, child) => { + const relation = relative(parent, child); + return ( + relation === "" || + (!relation.startsWith("..") && !isAbsolute(relation)) + ); + }; + const workingDirectories = new Set([root]); + for (const configured of [ + profile.browserAutomation?.workingDirectory, + ...Object.values(profile.applications ?? {}).map( + (application) => application?.workingDirectory + ) + ]) { + if (typeof configured !== "string") { + continue; + } + try { + const directory = realpathSync(resolve(root, configured)); + if (isInside(root, directory)) { + workingDirectories.add(directory); + } + } catch { + // A missing working directory has no dependency link to grant. + } + } + + const found = new Set(); + for (const directory of workingDirectories) { + const candidate = join(directory, "node_modules"); + try { + if (!lstatSync(candidate).isSymbolicLink()) { + continue; + } + const target = realpathSync(candidate); + if ( + !isInside(root, target) && + trustedRoots.some((trusted) => isInside(trusted, target)) + ) { + found.add(target); + } + } catch { + // Missing or unreadable dependency folders need no extra access. + } + } + return [...found]; +} + +const additionalDirectoryArguments = (additionalDirs) => + additionalDirs.flatMap((directory) => ["--add-dir", directory]); + export function buildAgencyInvocation({ packageRoot, prompt, sessionId, - resume = false + resume = false, + additionalDirs = [] }) { if (!sessionId) { throw new Error("Agency invocation requires a session ID"); @@ -1358,6 +1444,7 @@ export function buildAgencyInvocation({ "--agent", "fixlab:fixlab", "--allow-all-tools", + ...additionalDirectoryArguments(additionalDirs), "--no-ask-user", "--stream", "on", @@ -1371,7 +1458,8 @@ export function buildCopilotInvocation({ packageRoot, prompt, sessionId, - resume = false + resume = false, + additionalDirs = [] }) { if (!sessionId) { throw new Error("Copilot invocation requires a session ID"); @@ -1383,6 +1471,7 @@ export function buildCopilotInvocation({ "--agent", "fixlab:fixlab", "--allow-all-tools", + ...additionalDirectoryArguments(additionalDirs), "--no-ask-user", "--autopilot", "--stream", @@ -1420,7 +1509,8 @@ function createExecutor({ command, packageRoot, buildInvocation }) { packageRoot, prompt, sessionId, - resume + resume, + additionalDirs: linkedDependencyDirectories(repository) }); const child = spawn(executable, invocation.args, { cwd: repository, @@ -2741,9 +2831,9 @@ export function createDashboardServer({ }); return; } - if (currentJob.status !== "failed") { + if (!["failed", "blocked"].includes(currentJob.status)) { sendJson(response, 409, { - error: "only a failed FixLab job can be dismissed" + error: "only a failed or blocked FixLab job can be dismissed" }); return; } @@ -2753,8 +2843,7 @@ export function createDashboardServer({ index: dismissedJob.nextLogIndex, timestamp: new Date().toISOString(), stream: "dashboard", - message: - "Failed job dismissed by the user; its failed outcome remains in dashboard history." + message: `${dismissedJob.status === "blocked" ? "Blocked" : "Failed"} job dismissed by the user; its ${dismissedJob.status} outcome remains in dashboard history.` }); if (dismissedJob.artifactDirectory) { removeArtifactDirectory(dismissedJob.artifactDirectory); diff --git a/docs/repository-onboarding.md b/docs/repository-onboarding.md index 2601b38..eb1a865 100644 --- a/docs/repository-onboarding.md +++ b/docs/repository-onboarding.md @@ -56,6 +56,12 @@ The profile must also declare: - `browserAutomation.dataSafety.policy`, describing mock, read-only, or intercepted-mutation behavior. - `browserAutomation.dataSafety.productionAllowed: false`. +Optionally, declare `trustedDependencyRoots`, a list of absolute directories, when a +working directory's `node_modules` is a link to a shared install outside the +repository. Non-interactive agents cannot answer path-permission prompts, so FixLab +passes `--add-dir` only for `node_modules` links under these roots, in the profile's +working directories. Without this field, no external path is granted. Filesystem roots +are ignored. Synthesized scenario source is transient by default. The agent must remove generated test files and validation-only configuration changes before the product pull request. A repository can require a permanent generated browser diff --git a/test/dashboard-server.test.js b/test/dashboard-server.test.js index 25302b6..4c75e86 100644 --- a/test/dashboard-server.test.js +++ b/test/dashboard-server.test.js @@ -6,12 +6,14 @@ import { mkdirSync, mkdtempSync, readFileSync, + realpathSync, rmSync, + symlinkSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, parse } from "node:path"; import { PassThrough, Readable, Writable } from "node:stream"; import test from "node:test"; import { @@ -27,6 +29,7 @@ import { createProcessCompletion, FIXLAB_STAGES, inspectRepository, + linkedDependencyDirectories, MAX_JOB_ACTIVITY_ENTRIES, MAX_CACHE_BYTES, MAX_CACHE_ENTRIES, @@ -39,6 +42,60 @@ const packageRoot = new URL("..", import.meta.url).pathname.replace( "$1" ); +test("linked node_modules directories are granted only inside trusted dependency roots", () => { + const base = mkdtempSync(join(tmpdir(), "fixlab-linked-")); + try { + const repository = join(base, "repo"); + const trusted = join(base, "trusted"); + const external = join(trusted, "web", "node_modules"); + const untrusted = join(base, "untrusted", "node_modules"); + mkdirSync(join(repository, "apps", "web"), { recursive: true }); + mkdirSync(join(repository, "tools"), { recursive: true }); + mkdirSync(external, { recursive: true }); + mkdirSync(untrusted, { recursive: true }); + symlinkSync( + external, + join(repository, "apps", "web", "node_modules"), + "junction" + ); + symlinkSync(untrusted, join(repository, "tools", "node_modules"), "junction"); + + const writeProfile = (trustedDependencyRoots) => { + const directory = join(repository, ".github", "fixlab"); + mkdirSync(directory, { recursive: true }); + const profile = validProfile({ trustedDependencyRoots }); + profile.browserAutomation.workingDirectory = "apps/web"; + profile.applications.frontend.workingDirectory = "apps/web"; + profile.applications.tools = { workingDirectory: "tools" }; + writeFileSync( + join(directory, "repository-profile.json"), + JSON.stringify(profile) + ); + }; + + writeProfile(undefined); + assert.deepEqual(linkedDependencyDirectories(repository), []); + + writeProfile([parse(base).root]); + assert.deepEqual(linkedDependencyDirectories(repository), []); + + writeProfile([trusted]); + const directories = linkedDependencyDirectories(repository); + assert.deepEqual(directories, [realpathSync(external)]); + + const invocation = buildCopilotInvocation({ + packageRoot: "C:\\FixLab", + prompt: "x", + sessionId: "11111111-1111-4111-8111-111111111111", + additionalDirs: directories + }); + const index = invocation.args.indexOf("--add-dir"); + assert.equal(invocation.args[index + 1], realpathSync(external)); + } finally { + rmSync(base, { recursive: true, force: true }); + } +}); + test("Agency executor keeps long prompts out of process arguments", () => { const prompt = `Fix this batch:\n${"x".repeat(40000)}`; const invocation = buildAgencyInvocation({ @@ -1916,6 +1973,58 @@ test("dashboard persists privacy-safe token and duration metrics", async () => { rmSync(repository, { recursive: true, force: true }); }); +test("dashboard dismisses a blocked job and continues the queue", async () => { + const repository = createGitRepository(); + let executorCalls = 0; + const executor = ({ onOutput }) => { + executorCalls += 1; + if (executorCalls === 1) { + for (const stage of FIXLAB_STAGES.slice(0, -1)) { + onOutput("stdout", `FIXLAB_STAGE|${stage}|passed|completed\n`); + } + onOutput( + "stdout", + "FIXLAB_STAGE|pr|blocked|Approval is required before creating the pull request.\n" + ); + return { completion: Promise.resolve({ code: 0 }), terminate() {} }; + } + return { completion: new Promise(() => {}), terminate() {} }; + }; + const { dashboard, url } = await startDashboard(repository, executor); + + try { + const post = (request) => + jsonRequest(url, "/api/jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + request, + requestType: "bug-fix", + mode: "validate-only" + }) + }); + await post("This job ends blocked."); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal((await jsonRequest(url, "/api/job")).body.job.status, "blocked"); + + const queued = await post("This job waits behind the blocked one."); + assert.equal(queued.body.queued, true); + + const dismissed = await jsonRequest(url, "/api/job/dismiss", { + method: "POST" + }); + assert.equal(dismissed.response.status, 202, JSON.stringify(dismissed.body)); + assert.equal(dismissed.body.dismissedJob.status, "blocked"); + assert.equal(dismissed.body.job.request, "This job waits behind the blocked one."); + assert.equal(dismissed.body.job.status, "running"); + assert.equal(dismissed.body.history[0].status, "blocked"); + assert.equal(executorCalls, 2); + } finally { + await dashboard.close(); + rmSync(repository, { recursive: true, force: true }); + } +}); + test("dashboard restores private job summaries and PR readiness after restart", async () => { const repository = createGitRepository(); const executor = ({ onOutput }) => {