Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions bin/fixlab.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
DEFAULT_DASHBOARD_PORT,
FIXLAB_RUNTIMES,
inspectRepository,
linkedDependencyDirectories,
validateTargetEnvironment,
validateLiveTestProfile,
validatePort
Expand Down Expand Up @@ -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: [
Expand Down
12 changes: 8 additions & 4 deletions dashboard/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Comment on lines +445 to +449
const canCancelQueuedJob =
!isActive && job?.status === "queued";
jobInputPanel.hidden = !(canResume || canComment || canCancelQueuedJob);
Expand Down Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down
103 changes: 96 additions & 7 deletions dashboard/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createHash, randomUUID } from "node:crypto";
import {
createReadStream,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
Expand All @@ -18,6 +19,7 @@ import {
extname,
isAbsolute,
join,
parse,
relative,
resolve,
sep
Expand Down Expand Up @@ -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
: []
Comment on lines +1360 to +1363
)
.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);
Comment thread
Copilot marked this conversation as resolved.
Resolved
}
} 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");
Expand All @@ -1358,6 +1444,7 @@ export function buildAgencyInvocation({
"--agent",
"fixlab:fixlab",
"--allow-all-tools",
...additionalDirectoryArguments(additionalDirs),
"--no-ask-user",
"--stream",
"on",
Expand All @@ -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");
Expand All @@ -1383,6 +1471,7 @@ export function buildCopilotInvocation({
"--agent",
"fixlab:fixlab",
"--allow-all-tools",
...additionalDirectoryArguments(additionalDirs),
"--no-ask-user",
"--autopilot",
"--stream",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Comment on lines +2834 to +2836
});
return;
}
Expand All @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions docs/repository-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
111 changes: 110 additions & 1 deletion test/dashboard-server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -27,6 +29,7 @@ import {
createProcessCompletion,
FIXLAB_STAGES,
inspectRepository,
linkedDependencyDirectories,
MAX_JOB_ACTIVITY_ENTRIES,
MAX_CACHE_BYTES,
MAX_CACHE_ENTRIES,
Expand All @@ -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({
Expand Down Expand Up @@ -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 }) => {
Expand Down
Loading