Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ All notable changes to FixLab are documented in this file.
machine-specific helper script.
- Add a plan-first, checksum-verified portable Node.js repair for incoherent
NVM installations without changing NVM or the permanent system `PATH`.
- Refuse to execute unowned portable runtimes, verify `npm.cmd` with a real
operation, and keep portable-repair fallback commands directly runnable.
- Add `fixlab dashboard --stop` with an instance-specific local control token
and symlink-safe control record so users can gracefully stop only the
FixLab-owned dashboard.
- Keep the Node 18 compatibility test suite runnable on the declared minimum
runtime while using a newer supported runtime for development Playwright.
- Keep backend-first validation and transient synthesized-Playwright cleanup
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ pwsh -File scripts/repair-node-runtime.ps1 -Repository C:\path\to\application -Y
`fixlab init` adds the repository profile, packaged prompt commands, and the
`fixlab-autofix` Visual Studio Code agent without overwriting existing files.
`fixlab dashboard` starts the local loopback dashboard and opens it in the
system browser.
system browser. Run `fixlab dashboard --stop` from the same repository to stop
that FixLab-owned dashboard without terminating unrelated Node.js processes.

FixLab is not yet published to the public npm registry or merged into the
official GitHub Copilot plugin marketplace. Until marketplace review is
Expand Down
209 changes: 198 additions & 11 deletions bin/fixlab.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
#!/usr/bin/env node

import { spawn, spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import {
copyFileSync,
existsSync,
lstatSync,
mkdirSync,
readFileSync
readFileSync,
unlinkSync,
writeFileSync
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
Expand Down Expand Up @@ -72,7 +76,7 @@ Usage:
fixlab authenticate [repository] [--yes]
fixlab run [repository] [--runtime <agency|copilot>] [--environment <name>] [--] [request...]
fixlab validate [repository] --pr <number> [--runtime <agency|copilot>]
fixlab dashboard [repository] [--port <number>] [--no-open] [--runtime <agency|copilot>]
fixlab dashboard [repository] [--port <number>] [--no-open] [--stop] [--runtime <agency|copilot>]
fixlab --help

Commands:
Expand All @@ -86,7 +90,7 @@ Commands:
Plan or run the repository-owned browser authentication command.
run Launch the FixLab agent for a request.
validate Launch validation-only mode for a pull request.
dashboard Start the local FixLab dashboard (127.0.0.1:${DEFAULT_DASHBOARD_PORT}).
dashboard Start or stop the local FixLab dashboard (127.0.0.1:${DEFAULT_DASHBOARD_PORT}).

Runtime:
agency Use Agency Copilot (default).
Expand Down Expand Up @@ -1006,8 +1010,13 @@ function parseDashboardArguments(args) {
let repositoryArgument;
let port = DEFAULT_DASHBOARD_PORT;
let open = true;
let stop = false;
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
if (argument === "--stop") {
stop = true;
continue;
}
if (argument === "--no-open") {
open = false;
continue;
Expand Down Expand Up @@ -1035,10 +1044,165 @@ function parseDashboardArguments(args) {
return {
repository: resolveRepository(repositoryArgument),
port,
open
open,
stop
};
}

function dashboardControlPath(repository) {
const identity = createHash("sha256")
.update(resolve(repository))
.digest("hex")
.slice(0, 20);
return join(
tmpdir(),
"fixlab-dashboard-control",
identity,
"dashboard.json"
);
}

function dashboardControlDirectory(repository, create) {
const controlPath = dashboardControlPath(repository);
const root = dirname(dirname(controlPath));
const directory = dirname(controlPath);
for (const candidate of [root, directory]) {
if (!existsSync(candidate)) {
if (!create) {
return { controlPath, directoryExists: false };
}
try {
mkdirSync(candidate, { mode: 0o700 });
} catch (error) {
if (error.code !== "EEXIST") {
throw error;
}
}
}
const status = lstatSync(candidate);
if (status.isSymbolicLink() || !status.isDirectory()) {
throw new Error(
`dashboard control directory is not a trusted local directory: ${candidate}`
);
}
}
return { controlPath, directoryExists: true };
}

function assertDashboardControlFile(controlPath) {
const status = lstatSync(controlPath);
if (status.isSymbolicLink() || !status.isFile()) {
throw new Error(
`dashboard control record is not a trusted local file: ${controlPath}`
);
}
}

function writeDashboardControl(repository, port, token) {
const { controlPath } = dashboardControlDirectory(repository, true);
writeFileSync(
controlPath,
JSON.stringify({
version: 1,
repository: resolve(repository),
host: "127.0.0.1",
port,
pid: process.pid,
token
}),
{ flag: "wx", mode: 0o600 }
);
assertDashboardControlFile(controlPath);
return controlPath;
}

function removeDashboardControl(controlPath, token) {
try {
assertDashboardControlFile(controlPath);
const record = JSON.parse(readFileSync(controlPath, "utf8"));
if (record.token === token) {
unlinkSync(controlPath);
return true;
}
} catch {
// A missing or replaced record does not belong to this dashboard instance.
}
return false;
}

async function stopDashboard(repository) {
let controlPath;
let directoryExists;
try {
({ controlPath, directoryExists } = dashboardControlDirectory(
repository,
false
));
} catch (error) {
console.error(`Cannot use FixLab dashboard stop control: ${error.message}`);
return 1;
}
if (!directoryExists || !existsSync(controlPath)) {
console.log(`No running FixLab dashboard is registered for ${repository}.`);
return 0;
}

let record;
try {
assertDashboardControlFile(controlPath);
record = JSON.parse(readFileSync(controlPath, "utf8"));
} catch (error) {
console.error(
`FixLab dashboard control record is invalid: ${controlPath} (${error.message})`
);
return 1;
}
if (
record.version !== 1 ||
record.repository !== resolve(repository) ||
record.host !== "127.0.0.1" ||
!Number.isSafeInteger(record.port) ||
record.port < 1 ||
record.port > 65535 ||
typeof record.token !== "string" ||
!/^[0-9a-f-]{36}$/iu.test(record.token)
) {
console.error(`FixLab dashboard control record is invalid: ${controlPath}`);
return 1;
}

try {
const response = await fetch(
`http://127.0.0.1:${record.port}/api/control/stop`,
{
method: "POST",
headers: { "X-FixLab-Shutdown-Token": record.token },
signal: AbortSignal.timeout(5000)
}
);
if (response.status !== 202) {
console.error(
`FixLab dashboard refused the stop request (HTTP ${response.status}).`
);
return 1;
}
} catch {
if (removeDashboardControl(controlPath, record.token)) {
console.log(
`No running FixLab dashboard was found; removed stale control record for ${repository}.`
);
return 0;
}
console.error(
`No running FixLab dashboard was found, but its control record could not be removed: ${controlPath}`
);
return 1;
}

console.log(`Stopping FixLab dashboard for ${repository}.`);
return 0;
}

function parseOnboardArguments(args) {
const runtimeArguments = parseRuntimeArguments(args);
if (runtimeArguments.error) {
Expand Down Expand Up @@ -1195,10 +1359,14 @@ async function dashboard(repository, port, shouldOpen, runtime) {
console.error(`Cannot start FixLab dashboard: ${readiness.error}`);
return 1;
}
const shutdownToken = randomUUID();
let close;
const dashboardServer = createDashboardServer({
repository,
packageRoot,
runtime
runtime,
shutdownToken,
onShutdown: () => close?.()
});
let address;
try {
Expand All @@ -1211,21 +1379,37 @@ async function dashboard(repository, port, shouldOpen, runtime) {
console.log(`FixLab dashboard: ${address.url}`);
console.log(`Repository: ${repository}`);
console.log(`Runtime: ${runtime}`);
console.log("Press Ctrl+C to stop the local dashboard.");
console.log(
'Press Ctrl+C or run "fixlab dashboard --stop" from the repository directory to stop the local dashboard.'
);
let controlPath;
try {
controlPath = writeDashboardControl(repository, address.port, shutdownToken);
} catch (error) {
await dashboardServer.close();
console.error(
`Cannot register FixLab dashboard stop control: ${error.message}`
);
return 1;
}
if (shouldOpen) {
openBrowser(address.url);
}

let closing = false;
const close = async () => {
close = async () => {
if (closing) {
return;
}
closing = true;
await dashboardServer.close();
try {
await dashboardServer.close();
} finally {
removeDashboardControl(controlPath, shutdownToken);
}
};
process.once("SIGINT", close);
process.once("SIGTERM", close);
process.once("SIGINT", () => void close());
process.once("SIGTERM", () => void close());
return 0;
}

Expand Down Expand Up @@ -1330,6 +1514,9 @@ async function main(args) {
console.error(parsed.error);
return 1;
}
if (parsed.stop) {
return stopDashboard(parsed.repository);
}
return dashboard(
parsed.repository,
parsed.port,
Expand Down
30 changes: 28 additions & 2 deletions dashboard/server.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { spawn, spawnSync } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
import {
createReadStream,
existsSync,
Expand Down Expand Up @@ -2114,7 +2114,9 @@ export function createDashboardServer({
publicDirectory = join(packageRoot, "dashboard", "public"),
executor = createRuntimeExecutor({ packageRoot, runtime }),
workItemLoader = createAzureDevOpsLoader(),
executionIdleTimeoutMs
executionIdleTimeoutMs,
shutdownToken = "",
onShutdown
}) {
const resolvedRepository = resolve(repository);
let currentJob = null;
Expand Down Expand Up @@ -2345,6 +2347,30 @@ export function createDashboardServer({
`http://${request.headers.host ?? DASHBOARD_HOST}`
);

if (
request.method === "POST" &&
requestUrl.pathname === "/api/control/stop" &&
shutdownToken
) {
const suppliedToken = String(
request.headers["x-fixlab-shutdown-token"] ?? ""
);
const expected = Buffer.from(shutdownToken);
const supplied = Buffer.from(suppliedToken);
if (
supplied.length !== expected.length ||
!timingSafeEqual(supplied, expected)
) {
sendJson(response, 403, { error: "dashboard stop was not authorized" });
return;
}
sendJson(response, 202, { stopping: true });
response.once("finish", () => {
setImmediate(() => onShutdown?.());
});
return;
}

if (request.method === "GET" && requestUrl.pathname === "/api/status") {
persistDashboardState();
sendJson(response, 200, {
Expand Down
8 changes: 8 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ read credentials, or make network requests.

```text
fixlab dashboard [repository] [--port <number>] [--no-open]
fixlab dashboard [repository] --stop
```

The command starts the packaged local UI on `127.0.0.1:4317` by default and
Expand All @@ -68,6 +69,13 @@ installation or `fixlab init`. The UI starts one selected-runtime job at a time
and displays the intake, diagnosis, reproduce, fix, review, local-stack,
live-test, and pull-request stages with polled logs.

Use `fixlab dashboard [repository] --stop` from another terminal to request a
graceful shutdown. FixLab stores an instance-specific local control token and
will stop only the dashboard that created that record; it does not terminate
unrelated Node.js processes. Control directories and records must be regular
local filesystem entries; FixLab rejects symbolic-link or junction
substitution instead of following it.

The user supplies only the bug or required enhancement. The agent loads
validation context from the repository profile and autonomously owns contract
generation, diagnosis or affected-surface inspection, the smallest required
Expand Down
9 changes: 6 additions & 3 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,12 @@ The repair script:
- Downloads the official Windows archive and `SHASUMS256.txt` only from
`nodejs.org`.
- Verifies the archive SHA-256 checksum before extraction.
- Validates the extracted `node.exe` version and npm CLI.
- Marks the portable root as FixLab-owned and refuses to replace an existing
runtime in an unowned directory.
- Validates the extracted `node.exe`, `npm.cmd`, and a real npm dry-run package
operation before accepting the runtime.
- Marks the portable root as FixLab-owned and refuses to execute, inspect, or
replace an existing runtime in an unowned directory.
- Rejects reparse points, junctions, and symbolic links in the destination
path, ownership marker, runtime tree, and temporary download workspace.
- Replaces only the exact FixLab-owned portable runtime directory after
checksum and runtime validation.
- Does not uninstall NVM, change the permanent `PATH`, or modify the system
Expand Down
Loading
Loading